等間隔でない時系列データからトレンドを抽出する方法の考案

私は去年からモルモットを飼っている。毎週土曜日にケージ掃除し、その時に体重測定も行っている。体重というのはノイズが多い値である。体重測定の直前に餌をたくさん食べていれば大きめの値がでるし、逆に直前にフンをたくさんしていたりすると少なく出てしまう。そういったノイズの影響を排除するには、データを平滑化するというのが一般的である。よくやるのは、各データを前後の点と足して平均を取ったものをその点での値とする方法である。

平均値を用いた方法は簡単な割にそこそこいい感じのグラフを描くことができるのであるが、1つ問題がある。通常、時系列データといった場合には暗黙のうちにデータが等間隔で並んでいることが前提となっている。しかし、例えばモルモットの体重データの場合、土曜日が忙しいと日曜日に体重を測ったり、もしくはその週は体重を計るのをやめてしまったりするため、そうはいかない。つまり、ある点の1つ先のデータは一週間後のものだが、1つ前のものは二週間前のデータだったりすると、それらの平均を取るというのは正しくない。なぜなら、今着目している点により近いデータの方が、その点のデータとより大きな相関があるためである。このように、等間隔に並んでいない時系列データを平滑化する際には、ちょっとした工夫が必要となる。

そこで、今回はそんな工夫を1つ形として書き表してみようと思う。基本的なアイディアとしては、着目している点により近い点に対して大きな重みを与えるような形で加重平均を取るというものである。重みの与え方であるが、着目している点を中心とした正規分布を考え、その正規分布の値を利用する。こうすることで、近い点についてはある程度着目している点に影響を与えることができるし、またずっと遠い点については影響がほとんどなくなる。

また、計算のちょっとした工夫として、ある程度離れた点については、加重平均の計算に加えずとも、ほとんど影響がない。そこで、最初から計算から除外してしまっても差し支えない。これにより、計算を高速化することが可能となる。

まあこんな感じで大したアルゴリズムではないが、話を明確にするためにちょっと数理的に記述してみる。ある時刻の数列t0, t1, ... , tn-1に対して、その時の時系列データの値がy0, y1, ... , yn-1であるとする。また、平均値μ, 標準偏差σの正規分布の点xでの値をN(μ, σ, x)と表す。さらに、ある時刻tiに着目したとき、その点の前後の時間T以内にある点の添字集合をMti, Tとする。このとき、ある時点ti (0 <= i < n) について、平滑化後の値y'iは以下のようになる。

{ \displaystyle
y'_i = \frac{\sum_{k \in M_{t_i, T}} y_k N(t_i, \sigma, t_k)}{\sum_{k \in M_{t_i, T}} N(t_i, \sigma, t_k)}
}

このアルゴリズムではTとσがパラメータとなり、これらの値によってグラフの様子が変化する。適用される際には、これらの値をいろいろ変えてみて、よさ気な値を選んで貰えればと思う。

template引数にコンテナを指定し、関数内でiterateする方法

自分が修士の学生でC++を書いていたとき、どうにも分からなかったことがある。それは、template関数のtemplate引数にstd::vectorなどのコンテナを指定し、関数内でそのコンテナに対してiteratorを回す方法である。当時の自分は以下のようなコードを書いており、コンパイルエラーから抜けだせなくなった。

template <class X>
void iterateTest(const X& var)
{
  // Xはstd::vectorなどのコンテナであり、以下でそのconst_iteratorにアクセスしたい
  for(X::const_iterator itr = std::begin(var);
    itr != std::end(var); itr++)
  {
    std::cout << *itr << std::endl;
  }
}

これがコンパイルエラーになる理由が当時全く分からず、最終的に諦めてしまったのであった。

しかし、最近これに対する解答を見つけた。それが以下のコードである。

template <class X>
void iterateTest(const X& var)
{
  // X::const_iteratorの前にtypenameを付け、これが型名であることを明示
  for(typename X::const_iterator itr = std::begin(var);
    itr != std::end(var); itr++)
  {
    std::cout << *itr << std::endl;
  }
}

つまり、型名の前にtypenameをつければよかったのである。こうしなければならない理由は、X::const_iteratorとだけ書くと、これがXクラスのconst_iteratorという名前のstatic変数とも解釈できるなど、Xの正体がこの時点で不明であるがために、解釈が一意に定まらないためである。

さて、これでようやく長年の疑問が解決したわけだが、実はもっと簡単に書ける方法があることを発見した。それはrange based forを使う方法である。コードを以下に示す。

template <class X>
void iterateTestNew(const X& var)
{
  // range based forを使えばよりスッキリ書ける
  for(const auto& el : var)
  {
    std::cout << el << std::endl;
  }
}

以前の記事でも取り上げたrange based forにまさかこんな使い道があったとは・・・C++はまだまだ知らないことが多く、いろんな場面で損をしていると思う。今後も着実に勉強を進め、効率のよいコードを書けるようになりたい。

Range based forとauto&&によるperfect forwarding

C++11からrange based forという構文が導入された。これはpythonなどで言うところのforeach文と似たような動作をする構文である。以下に典型的な使用例を示す。

std::vector<int> vec(10);

/* vecに何か値を設定 */

for(int el : vec)
{
  /* Do something using "el". */
}


ここで、elはコンテナの要素のコピーである。コンテナに格納するオブジェクトが大きく、コピーのコストが気になる場合や、ループ内でコンテナの要素を更新したい場合には参照を使うこともできる。

std::vector<MyObject> vec(10); // vectorのテンプレート引数にユーザ定義型を指定

/* vecの中身をいろいろ更新 */

for(MyObject& el : vec) // ループ変数を参照型にできる
{
  /* Do something using "el". */
}

さらなる工夫として、ループ変数の型をautoとし、型推論させることも可能である。

for(auto& el : vec) // autoによる型推論
{
  /* Do something using "el". */
}

上記のように、autoに対して参照を使うことも可能である。その他、コピーのコストが気になる場合で、かつコンテナの要素が変更されないことを保証したければ、ループ変数にconstを指定することもできる。

for(const auto& el : vec) // const + 型推論
{
  /* Do something using "el". */
}

さて、ここからが本題である。今、vecは変数として宣言しているため、当然ながら左辺値となる。そのため、ループ変数の型は当然左辺値参照(auto&)となる。

では、ループ対象のコンテナが右辺値の場合はどうなるだろうか?この場合、ループ変数を右辺値参照型で定義しなければならないのだろうか?答えはYesである。典型的にはstd::vectorに対してループを回す例が挙げられる。

for(auto&& el : std::vector<bool>(10)) // std::vector<bool>は右辺値を返す。
{
  /* Do something using "el". */
}

実際、このコードは正しく動く。ただし、通常の右辺値参照型と異なり、auto&&という型は特別な意味を持つ。それがすなわちperfect forwardingである。perfect forwardingとは、右辺値参照は右辺値参照に、左辺値参照は左辺値参照にといった具合に、適切に型を判断して振る舞ってくれる型のことである。auto&&の他に、this&&も同じくperfect forwardingを行うらしいが、こちらは詳しく知らない。

このような背景から、range based forのループ変数に対しては、常にauto&&としておくのがよいというような記事をネット上でよく見かける。auto&&を使っておけば、ループ対象コンテナが右辺値か左辺値かなどといちいち気にしなくて良い、というロジックである。(constを使用したい場合は別だが。)

ちなみに、auto&&の代わりにauto&を使用すると本当にダメなのか試してみたところ、以下のようなコンパイルエラーが出た。

error: invalid initialization of non-const reference of type ‘std::_Bit_reference&’ from an rvalue of type ‘std::_Bit_iterator::reference {aka std::_Bit_reference}’
  for(auto& el : std::vector<bool>(10))

エラーを見ると、確かに右辺値参照を用いて左辺値参照を初期化しようとしているようなメッセージが出ている。やはりダメなようだ。

その他にもいろいろと手を動かして見たところ、以下の2つは問題なく動作する。

// ループ変数をconst auto&型にした場合
for(const auto& el : std::vector<bool>(10))
{
  /* Do something using "el". */
}

// ループ変数を参照ではなく値渡しにした場合
for(auto el : std::vector<bool>(10))
{
  /* Do something using "el". */
}

しかしよく分からないのは、コンテナに対してrange based forを回したときに右辺値参照が返ってくるというのは、一体どういう状況なのだろうか?そして、そいういうケースはどれくらい頻繁に起こりえるのだろうか?std::vectorはテンプレート特殊化によって少々直感的でない挙動をする特殊ケースだと思うのだが、そういったケースでしか右辺値参照を用いる機会がないのであれば、別にauto&を使っておけばよいような気がする。というのも、自分の開発チームが常にC++の達人揃いとは限らないからである。つまり、「そもそも右辺値参照って何?」というレベルの人がチームにいてもおかしくはない。そこで自分が一から右辺値参照とかperfect forwardingの説明をするには時間も実力も気力もないし、「とにかくおまじないと思ってauto&&を使って下さい」と言って納得してもらえるかも微妙である。であれば、そんなに問題がないのであれば、普段はatuo&を使っておけばよいという気がする。それだけでもrange based forの恩恵の大部分を受けられると思う。

Perfect forwardingはさておき、C++11からはコンテナの要素に対するイテレーションがずっと楽になった。こういう便利な新機能は今後もガンガン使っていきたい。欲をいうと、ループの中でループインデックスにアクセスできるともっとよいのだが・・・D言語にはその機能があるので、C++も今後同様の機能が追加されることを期待したい。

C++11におけるstd::vectorへの最速な値設定方法の考察を通して学ぶemplace_backとstd::move

最近C++を書く機会が多いのだが、いかんせん自分の知識は2014年でぱったりと止まっている。しかも2014年当時、自分はまだまだ未熟で、C++11やらC++14なんかの機能についてはまったくの無知であった。そんなわけで、C++のモダンな機能を学びがてら、タイトルにあるようにstd::vectorに値を設定する最速の方法は何かということを調べてみようと思う。

前提として、vectorのテンプレート引数は、ぼちぼちの大きさをもつ自作クラス型とした。また、vectorのメモリ確保動作が性能ネックとならないように、初めにvectorのメモリをreserveしてから計測を行った。つまり、初めに以下のようなコードを書いておく。

std::vector<LargeObject> lov;  // 自作のぼちぼちの大きさのクラスをテンプレート引数に設定
const int NUM_LOOP = 3000000;
lov.reserve(NUM_LOOP);


今回は4つの方法を試してみたので、それらについて順に説明する。

1. push_backを使用する

これは至って普通の方法である。コードは以下。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.push_back(LargeObject());
}
2. emplace_backを使用する

これはC++11で追加された新機能である。解説はググればいくらでも出てくるので深くは触れないが、push_backでは一時的にオブジェクトを生成し、それをvectorの要素としてコピーする必要があるのだが、emplace_backでは最初からvectorの要素としてオブジェクトを作成することで、コピーの手間を省いているようだ。使い方は至って簡単で、push_backをemplace_backに置き換えるだけだ。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.emplace_back();
}
3. std::moveを使用する

これもC++11から追加された機能であり、またC++11の目玉機能の1つでもある。これも正直100%理解していないので深入りしないが、C++11では右辺値参照という概念があり、これを利用することで従来オブジェクトのコピーを行っていたところを、オブジェクトの移動に置き換えられるようだ。ただし、moveと書けば必ずmoveされるという簡単なものではなく、move関数はあくまでオブジェクトの型を右辺値参照型にキャストしているだけのようだ。詳しいことは難しいのでググって欲しい。

moveを使う際の注意点として、ムーブコンストラクタと呼ばれる新しいコンストラクタを定義しておく必要がある。コピーセマンティクスではコピーコンストラクタを使用していたが、ムーブセマンティクスではムーブコンストラクタを使用することになる。以下に今回定義したムーブコンストラクタを示す。(※追記2参照)

LargeObject::LargeObject(LargeObject &&obj)
{
  a = obj.a;
  mvConstCallCnt++;
}

ここで、aというのはLargeObjectが持つメンバ変数である。今回はstd::vectorという型としており、コンストラクタの中でサイズ10のvectorで初期化している。また、引数の&&というのが右辺値参照型を表す文法である。

サンプルコードは以下のようになる。

for(int i = 0; i < NUM_LOOP; i++)
{
  lov.push_back(std::move(LargeObject()));
}
4. resizeする

これもシンプルな方法である。vectorのresizeを行うと、その要素の数だけオブジェクトが生成されることになる。コードは以下。

lov.resize(NUM_LOOP);

これが恐らく最速ケースになるだろう。ただし、resizeの場合はデフォルトコンストラクタしか使用できず、コンストラクタに引数を渡したい場合は、上記3つの方法を取るしか無いと思われる。(※追記参照)

性能測定

以上、4通りの値設定方法を説明した。これらを用いて実際に性能測定を行った。コンパイラはg++ version4.8.4を使用し、-O3オプションを付けてコンパイルした。また、性能測定と同時に、各コンストラクタがコールされる回数を取得した。結果を以下に示す。

====push back test====
Elapsed time:	0.311718
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====emplace back test====
Elapsed time: 0.0983019
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.207351
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====resize test====
Elapsed time: 0.0974371
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====push back test2====
Elapsed time:	0.206877
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000

最後のpush back test2というのは、初回はキャッシュヒット率の観点で不利ではないかと思い、push backを念のため2回実行したものである。これを見ると、resizeを利用したケースが一番速く、次いでemplace_backを使用したケースが速く処理を終えていることが分かる。emplace_backのケースではコンストラクタ呼び出しが3000000回しか行われておらず、余計なオブジェクトコピーが発生していないことが分かる。

また、push_backのmoveあり・なしでの差を見ると、コンストラクタとムーブコンストラクタが呼ばれている回数が同じである。これはちょっと予想外だ。つまり、moveと明示的に書かなくても、勝手にmoveされてしまうようだ。(※追記3参照)

では、ムーブコンストラクタの定義を行っていない状態でpush_backを行うとどうなるのだろうか?試しに定義をコメントアウトしてからコンパイルし、測定を行った結果が以下である。

====push back test====
Elapsed time:	0.321736
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0
====emplace back test====
Elapsed time: 0.0978141
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.210126
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0
====resize test====
Elapsed time: 0.0969071
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====push back test2====
Elapsed time:	0.216425
Constructor call:	3000000
Copy constructor call:	3000000
Move constructor call:	0

これを見ると、今度はムーブコンストラクタの代わりにコピーコンストラクタが使用されていることが分かる。この結果からも、move関数は単に右辺値参照へのキャストを行っているだけだということが見て取れる。ただし、ムーブコンストラクタが定義されていた場合と比べて性能がほとんど変わらない点はちょっと解せない。いずれにせよ、vectorに値を設定するという場面においては、ムーブセマンティクスなどという難しいことは考えず、単にemplace_backを使用すれば良さそうだ。

以上、C++11の勉強がてら、vectorへの値の設定の仕方とその性能について調べてみた。たったこれだけの簡単な操作に対しても、ベストな性能を出すためにはいろいろなことを考えなければならないことを思うと、ソフトウェアの性能というのは本当に難しい問題だと思う。強いC++エンジニアになるべく、今後も継続的に勉強していきたい。

※追記
resizeには実は隠れた第二引数があり、ここにオブジェクトを指定すると、vectorの要素が全てそのオブジェクトのコピーによって初期化されるようだ。試してみたところ、コンストラクタの代わりにコピーコンストラクタが走り、vectorの要素が生成されることが分かった。こういう方法があることを考えると、vectorを一括で初期化したい場合はresizeを使用し、個別に要素を追加したい場合にはemplace_backを使用するのが良さそうである。

※追記2

move関数を使うとコピーコンストラクタの代わりにムーブコンストラクタが呼ばれるわけだが、ムーブコンストラクタをうまく書いてあげないとムーブの恩恵が得られないようだ。本文中で紹介したムーブコンストラクタでは内部でvector型変数aをコピーしてしまっていたが、これ自体ムーブするように書き換えてやった結果、想定通りの動作となった。サンプルコードを以下に示す。

LargeObject::LargeObject(LargeObject &&obj)
{
  a = std::move(obj.a);  // メンバをコピーでなくムーブする
  mvConstCallCnt++;
}

測定結果は以下である。新しく"rich resize test"という項目が追加されているが、これは「追記」に記載した方法の測定結果である。

====push back test====
Elapsed time:	0.215382
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====emplace back test====
Elapsed time: 0.101542
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====move test====
Elapsed time: 0.109494
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000
====resize test====
Elapsed time: 0.0983009
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	0
====rich resize test====
Elapsed time: 0.08794
Constructor call:	1
Copy constructor call:	3000001
Move constructor call:	0
====push back test2====
Elapsed time:	0.111539
Constructor call:	3000000
Copy constructor call:	0
Move constructor call:	3000000

これを見ると、emplace backとmoveの場合でほとんど性能差がないことが分かる。moveの方がムーブコンストラクタ呼び出し3000000回が上乗せされているが、これ自体は通常のコンストラクタと比べて内部の処理がずっと軽い(変数aのポインタ付け替えのみ)ため、このような結果になったのだと思われる。

moveはしっかりと理解した上で使わないと、思ったように性能が出ないという現象に悩まされそうだ。

※追記3

moveと書いても書かなくてもムーブコンストラクタがコールされてしまった件だが、これはそもそもpush_backの引数に渡しているのが一時オブジェクトであり、暗黙的に右辺値参照にキャストできたためだと思われる。上述の通りstd::moveは右辺値参照へのキャストを行う関数なので、今回の場合は無駄にキャストしていただけなのだと思われる。

差が生まれるケースとしては、例えば一旦定義して左辺値となった変数をムーブするケースが挙げられる。調べてみたところ、この場合は普通にpush_backするとコピーコンストラクタがコールされ、move関数を噛ませるとムーブコンストラクタがコールされることが分かった。

Debian WheezyがインストールされているパーティションをRAID1に変更する方法

前回の記事で自宅サーバのバックアップであれこれ悩んでみたが、結論としてはRAID1+定期バックアップという構成に落ち着いた。やはり今からbtrfsを入れるのは腰が重かった。

本構成を実現するにあたり1つ問題となったのが、どうやってDebian WheezyがインストールされているパーティションをRAID1に変更するかであった。RAID1を組んで、RAIDボリュームを認識させた後にOSをインストールするのは容易いが、逆は思いの外難しい。あれこれ調べてみると、日本語の記事ではDebian Etchで実施したという記事は出てくるのだが、いかんせんgrubが古いので参考にならない。結局、以下のサイトを参考にした。

参考
How To Set Up Software RAID1 On A Running System (Incl. GRUB2 Configuration) (Ubuntu 10.04) - Page 2 - Page 2

Setting up RAID on an existing Debian/Ubuntu installation

上記を参考にして実際にやってみた結果を書いてみようと思う。結論を言うと、無事にRAID1のボリュームを認識させることができた。

まず、大きな流れは以下のようになる。

  1. 新規ディスクのパーティションにRAID1をデグレード状態で構築。
  2. grub等を設定し、RAIDデバイスからbootできるようにする。
  3. 作成したRAID1デイバスをmountし、既存のdebianのシステムをまるごとコピーする。
  4. RAIDデバイスからのbootに成功したら、元々Debianが入っていたパーティションをRAID1に参加させる。

grubの設定以外は簡単である。

仮定として、現在Debian Wheezyがインストールされているパーティションは/dev/sda1、RAID1として追加したい新規ディスクのパーティションは/dev/sdb1とする。両者のサイズは予め同じにしておき、パーティションタイプを0xfdにしておく。これはfdiskを使えば簡単にできる。(入力する際には0xは不要。)

root@emperor:~# fdisk /dev/sda

Command (m for help): t
Partition number (1-5): 1
Hex code (type L to list codes): fd  

また、RAIDの構築にはmdadmというツールを使用する。入っていなければインストールしよう。

では、ステップ1について述べる。まずは新規パーティションを使ってRAID1を構築する。RAID1はミラーリングなので、当たり前だがディスクは2つ必要である。しかし、まずはディスク1つの状態でRAID1を構築する。(デグレード状態。)

mdadm --create /dev/md0 --level=1 --raid-devices=2 missing /dev/sdb1 

これで新しく/dev/md0というRAID1のデバイスが作成される。上記コマンドのmissingというところがミソで、これによってディスク1つでRAID1が構築できるようになる。

作成したmd0にファイルシステムを作成するのをお忘れなく。

mkfs.ext4 /dev/md0

では、ステップ2に進もう。最大の難関はgrubであるが、それ以外にもいろいろ設定が必要である。

まず、mdadmの設定ファイルに今作ったmd0の情報を追記する。

mdadm --examine --scan >> /etc/mdadm/mdadm.conf

">>"ではなく">"にしてしまうと死亡するので注意が必要である。

次に、boot時にmd0のボリュームを最初に起こすように設定する。

dpkg-reconfigure mdadm

あれこれ言われるが、allとなっているところを/dev/md/0に置き換えればOKだ。なぜか/dev/md0だとうまくいかないのは謎である。どちらにせよ、/dev/md/0は/dev/md0へのシンボリックリンクになっているので、同じことである。

fstabも変更しておく必要がある。今までは/dev/sda1をルートパーティションとしていたが、今度からは/dev/md0がルートパーティションになる。例として私のfstabの設定を示す。

/dev/md0    /    ext4    errors=remount-ro    0    1

ここでいよいよgrubの設定を行う。正直に言うと、一度失敗してgrub rescueに落ちてしまい、あれやこれやと手を打ってなんとか直したというのが事実なので、ここは何が正しい手順なのか自信がない。とりあえずやってみたことをあれこれ書いていく。

まず、grubのconfig設定を行う。

dpkg-reconfigure grub-pc

grubのインストール先を聞かれるところで、/dev/sdaと/dev/sdbを選択する。/dev/md0を選んではいけないので注意が必要である。

次に、grubの設定ファイルを書いていく。ベースとして/etc/grub.d/40_customをコピーし、/etc/grub.d/09_swraid1_setupというファイルを作成する。*1

作成したファイルを開いて、以下のように記述する。

#!/bin/sh
exec tail -n +3 $0
# This file provides an easy way to add custom menu entries.  Simply type the
# menu entries you want to add after this comment.  Be careful not to change
# the 'exec tail' line above.
menuentry 'Debian GNU/Linux, with Linux 3.2.0-4-amd64' --class debian --class gnu-linux --class gnu --class os {
        insmod gzio
        insmod mdraid1x
        insmod part_msdos
        insmod raid
        insmod ext2
        set root='(md/0)'
        search --no-floppy --fs-uuid --set=root 9d87ce79-fa38-4fd0-a936-b7dc9ce159cf
        linux   /boot/vmlinuz-3.2.0-4-amd64 root=/dev/md0 ro quiet
        initrd  /boot/initrd.img-3.2.0-4-amd64
}

searchの行が必要かどうかは自信がないが、あれこれ試行錯誤しているうちにつけてみた。最後の番号はmd0のUUIDを記述する。UUIDはblkidコマンドで調べられる。最初のmenuentryやlinux, initrdの項目のカーネルバージョンは、自分の環境に合ったものを設定する必要がある。/boot/grub/grub.cfgの中で"### BEGIN /etc/grub.d/10_linux ###"と書かれているセクションの設定を参考にすると良いだろう。

上記ファイルが設定できたら、以下のコマンドでgrub.cfgファイルを更新する。

update-grub 

grub2の方針なのか知らないが、直接/boot/grub/grub.cfgをいじるということはしないようだ。

initramfsも更新する。

update-initramfs -u

これでgrubの設定はおしまいである。

続いてステップ3に移る。ここは簡単で、md0を適当なところにmountしてルートファイルシステムをまるごとコピーすればよい。

mkdir /mnt/raid
mount /dev/md0 /mnt/raid
rsync -auHx --exclude=/proc/* --exclude=/sys/* --exclude=/tmp/* /* /mnt/raid

状況が気になる人はrsyncに-vを足しても良いだろう。

最後に、これも必要なのかよく分かっていないが、grub-installを実行しておく。

grub-install /dev/sda
grub-install /dev/sdb

さあ、次が最後のステップである。システムをrebootし、起動できるかどうか確かめよう。ちなみに、私の環境だとfd0がないとかなんとかいうエラーが出るようになってしまったが、起動はできている。致命的ではないが、時間ができたら解決したいところだ。

システムが無事に起動できたらdfコマンドを叩いてみよう。以下のような行があれば成功である。

/dev/md0        82504240 30052404  48260816  39% /

ここまで確認できたら、既存の/dev/sda1をmd0に加える。

mdadm /dev/md0 -a /dev/sda1 

/dev/sda1をmd0に追加すると、すぐにミラーリングが始まる。以下のコマンドで進捗が監視できる。

watch -n1 cat /proc/mdstat

無事にミラーリングが完了したら、もう一度以下のコマンドを叩いておく。

update-grub

あとはもう一度rebootして、立ち上がってくるのを祈る。ちなみに私は最初ここでgrub rescueに落ちた(笑)

以上で手順はおしまいだが、grub rescueに落ちるようになってしまった際の復旧のヒントについて書いておくことにする。

まず、一番大切なのは慌てないことである。データは存在しているのだから、grubさえ直せば元通りになる。落ち着いて対処しよう。

次に、CDやUSBメモリなどからOSを立ち上げて復旧作業に入る。ここで私が焦ったポイントが1つある。それは、/dev/sda1や/dev/sdb1はもはやmountできないということである!なぜならRAIDだから。ではどうするかというと、慌てず騒がずmdadmをインストールしよう。そうすれば/dev/md0が見えるはずなので、これを適当なところにmountできる。すると、データは全て無事であることが確認できる。そうなればあとはmountしたところにchrootして設定をやり直せばよいだけの話だ。chrootするときは/procやら/sysやらの扱いに注意して欲しい。以下の記事などを参考にすると良いだろう。

d.hatena.ne.jp

以上で今回の記事はおしまいだ。起動時に謎のエラーメッセージが出るのだけは気に食わないが、RAID1自体は問題なく構築できて大満足であった。ついでにgrub rescueに落ちても動じない鉄の精神が身についた。こういう保守作業を通して人は強くなっていくものなので、また何か面白いことを思いついたらやってみよう。

*1:ファイル名は好きにして良いと思うが、恐らく09というところは変えない方がよい。grubの仕組みをさっぱり理解していないので推測だが、ここの番号が若い順に設定ファイルの優先度が高くなっているような気がする。そして、通常のboot設定の番号が10になっているので、ここでは09としているのだと思う。

自宅サーバのバックアップについて悩む

現在我が家にあるサーバは、主に写真や映画の貯蔵庫として使用されている。とても大切な思い出がたくさん詰まっているので、データのバックアップは欠かしていない。しかし、現在の方法が果たして最適なのかという点については疑問が残る。最近、この問題に対する最適なソリューションについて思いを巡らせている。というわけで、素人なりに考えてみたことをいろいろメモってみる。

まず、データのバックアップの理想と現状について考えてみる。理想的には、ディスクが1本くらい吹き飛んでもダウンタイム0で復旧できるとよい。まあ必ずしも24時間動く必要はないので多少の妥協は可能だが、とにかく復旧は楽にできるとよい。現状の我が家のシステムでは、必要なファイルのみ夜間にバックアップを取得しているだけである。バックアップにはrdiff-backupを使用している。ここで、必要なファイルとは以下の通りである。

  • 画像・映像などが入っているデータディレクトリ
  • 稼働しているVMイメージまるごと(複数
  • VMのxmldump
  • mysqldump
  • インストールされているパッケージのリスト

これだけあればディスク故障が発生した場合でも復旧することは理論上可能だと思う。しかし、恐らく面倒くさい作業になるだろう。新しいディスクを購入したらdebianをインストールし直し、バックアップしてあるインストール済みパッケージリストから地道に再インストールを行い、あれこれ設定を行い、VMを戻して起動テストを行い・・・気が遠くなる・・・

そこで最近思いついたのが、RAID+スナップショットの活用である。まずRAIDについて述べる。例えばRAID1とかにしておけば、システムがまるごとミラーリングされるため、片方が吹き飛んでもダウンタイムゼロで復旧ができる。しかし、当時システムを組んだとき、私はRAIDにはしなかった。なぜなら、RAIDだけではバックアップの体をなさないからである。これは私が学生の頃勘違いしていたポイントでもあるが、RAID1やらRAID5, 6なんかは、現在の状態を保ち続けるという意味においては優れているが、過去のある時点に戻るということはできないのである。つまり、例えばついうっかりファイルを消した場合などには復旧不可能である。バックアップが必要なのは何も障害発生時だけではなく、こういったオペミスのときも考慮が必要なのである。

そんなわけで、以前はバックアップというところに重きを置いて、RAIDは組まなかったのである。HDDを2つしか持っていないので、RAID1+バックアップという運用も諦めた。しかし、最近のファイルシステムの中には、ボリュームのスナップショットを取得できるものが存在していることを知った。スナップショットを使えば、ある時点のボリュームの状態に簡単に戻ることができる。これはRAIDと極めて相性がよいのではなかろうか。RAIDを組むことで物理的な障害に耐えられるようにし、スナップショットを活用することで過去の特定の時点へのrevertも簡単に行えるというわけだ。すごい!

ただし、スナップショットは多分ボリューム単位に取得されるであろうから、例えば複数ファイルに変更を加え、そのうち1つのファイルを誤って消去した場合などに問題が残るだろう。というのも、revertしたいのは最後に誤って消したファイルだけで、その他の変更済みファイルは変更後の状態にしておきたいからである。そう考えると、現行のバックアップ方式も、あながち悪くない。

そこで考えた。恐らく、普通のバックアップとボリュームのスナップショットは両方必要である。システムがソフトウェア的にクラッシュしてカーネルパニックなんかを起こしたときは、いっそスナップショットを使ってrevertしたいし、特定ファイルだけ元に戻したいときはバックアップからの復元が便利である。そうなると、我が家における最強の構成は、RAID1に加えて、必要ファイルの定期バックアップを取得し、さらにRAID1上のボリュームは定期的にスナップショットを取得するという方法だろう。もともとHDDが2つしかないというのが我が家のネックであったが、この際もう一枚買うのもやむを得ないかもしれない。

さて、先ほどからスナップショットを取得すると言っているが、これが可能なファイルシステムにはどのようなものがあるのだろうか?私の調べたところでは、btrfsとzfsの2つが存在する。このうちzfsは様々なモダンな機能が搭載されているのに加え、RAIDZなるRAID5の欠点を補うような仕組みが存在するなど、非常に魅力的である。が、Linux上で使用するには実用に耐える性能が出ないというのが現状のようだ。(情報が間違っていたらすみません。)

そこで、現在btrfsの導入を検討している。Wikipediaを見るとまだ安定版は存在しないと書かれているが、最近ではかなり安定してきているようである。
FAQ - btrfs Wiki

残る問題は、どのように現在のシステムをbtrfsに置き換えるかであるが、どうやらext4からbtrfsに変換する方法があるようだ。
20.12 Ext2、Ext3またはExt4ファイル・システムのBtrfsファイル・システムへの変換
しかしこれを実行するのはかなり勇気がいるな・・・ついでにどうやってRAIDを組めばよいのかもよく分かっていないので、これからまだ調べる必要がありそうだ。

現状のシステムをかなりいじくりまわさないといけないため腰が重いが、HDDがクラッシュして泣くのは自分なので、そのうち頑張ってやってみようと思う。