Macで論理障害をいち早く検知する方法

中々取り上げられることの少ない話題なので、敢えて期待値を上げてしまうタイトルにしてみました。

Macユーザーの皆さんはバックアップをどうしていますか？
TimeMachineで取っていますか？大事なデータは、ローカルでも分散させていますか？
無くなっては困るもの、S3等の外部ストレージに入れいていますか？

そんな当たり前のことを聞くな！と言われてしまいそうですが、意外と後回しにされる課題でもあったりします。

# MacOSが提供しているTimeMachineという素晴らしい機能

MacOSでは、純正の機能として皆さんが大好きなTimeMachineという機能がございます。
OSやデータなど、外付けストレージにも対応していて、容量を無駄にしない差分バックアップの仕組みが備わっています。

大容量のHDDを用意しておけば、これ一つでOSからローカルデータまで、一発でバックアップが可能なわけです。

めでたしめでたし！

# 冗長化も万全！（大嘘）
TimeMachineに利用するストレージは、複数台つなげることで、交互バックアップが可能になります。
1時間に1度のバックアップが、それぞれのストレージに割り振られるわけですね。
これで1つのディスクが故障しても大丈夫だ！
十分な知識や管理能力、資金力があれば、RAID構成でももちろんOK。

# データの管理方法
私の環境では、ある程度のリスク管理を踏まえて下記のような構成にしておりました。

* Git関係は外付けのSSDで管理（メインマシンが壊れても、サブマシンで作業できるように）
* 仮想環境も外付けHDDで管理（上記と同様の理由）
* 作業用HDD（ダウンロードしたファイル、作業中のファイルや取り急ぎの保管など。上記と同様の理由）
* 顧客データは、すべてファイルサーバーに保管
* MacOS（内蔵のM.2 SSD。いつ動かなくなっても良い状態）
* TimeMachine Primary HDD 6TB（すべての外付けディスクとOSをバックアップ）
* TimeMachine Secondary HDD 6TB（Primaryと同様で交互バックアップ用）
* ストレージの物理障害に備えて、DriveDXというアプリ（有料）を導入。障害時にメールでアラートしてくれる。

# ことの発端

梅雨時期で珍しく晴れていた日でした。「今日は良い一日になる！」と思っていた矢先で、
DriveDXから障害を知らせるメールが届きました。内容を見ると、TimeMachineの2台とも物理障害があり、
I/Oエラーが多発している。それに加え、作業用HDDが認識されない。

そんなことがあるのか？？？？？？？？
と思いつつ、急いでPCの前に座るも、気づいたら空いた口が塞がらない放心状態で座っていた。

そう、誰も思うはずです。まさか同時に壊れるなんて、宝くじがあたる確立だ。
自分の環境は安全だ。そう思っていた自分を叩きたい。

幸いにも、データの復旧はできましたが、多くの時間を消費しました。
やっかいなことに、TimeMachineの差分バックアップは、ハードリンクで成り立っており、
復旧がそう簡単ではありませんでした。
`rsync`でコピーすると、時間がかかりすぎる上に障害を悪化させてしまう可能性がある。
`dd`でブロックサイズを512バイトにすると、数ヶ月かかる計算に。
`ddrescue`でなんとか取り出しましたが、論理障害は残ったままだ。
最後に必要なデータだけを`rsync`で取り出すことにして、事なきを得ました。

# 物理故障も怖いが、論理故障の方がヤバいこともある
論理故障は、パーティションなどのファイルシステムに障害が起こり、ディスクを正常にマウントできなかったり、
内部データが壊れたりする症状を伴うことがあります。
今回は1週間もかけて、あらゆる方法でデバッグ情報を収集しました。
その結果、**論理障害は結構前から始まっていた**ことが判明しました。

そう、DriveDXでも定期診断をやってくれていたが、論理障害が起こっていることに気が付きませんでした。
物理障害が起きていなければ、完全に使えなくなる状態まで気が付かなかった可能が高いということです。

# 本題

論理故障からストレージを守ることは不可能です。新品のHDDでも起こることがあれば、
5万時間経っても起きないことがあります。もちろん、物理障害が原因で引き起こる論理障害もある。
物理障害は検知しやすいが、論理障害はどうでしょうか？
MacOSの場合は、HFS+というファイルシステムが使われることが多いです。

そのファイルシステムを定期的に検証できれば、被害を最小限に抑えられる可能性が増えます。
根本的な解決策にはならないので、定期的なストレージ交換や冗長化で複合的な対策は必要という前提ですね。

MacOSには、ディスクユーティリティーの一部として、First Aidという機能があります。
内部では、`fsck_hfs`コマンドが使われており、論理障害を検証し、自動的に修復を試みます。

ここでも気をつけなければならないのは、この自動修復機能です。場合によっては、障害を悪化させてしまう可能性があります。

なので、
* 検証はしたいけど、自動修復されては困る
* デバッグログを見てから、自分で対応方法を考えたい
という風に考えました。

そこで`fsck_hfs`コマンドを使ったアプローチを考察しまして、簡易的なシェルスクリプトを作ることにしました。

## 都合の良い挙動を考察

### 自動修復は行わない
上に書かせて頂いた通りですね。

### ライブ検証
`fsck_hfs`コマンドによる通常の検証では、ボリュームのマウントが解除されてしまいますが、可能であれば、アンマウントせずに検証してほしい。

### I/Oの制御
アンマウントしない状態では、検証中にI/Oが発生してしまう可能性がありますね。なので、検証中はI/Oをブロックできるようにしたいところです。

### TimeMachineのバックアッププロセスを考慮
ファイルシステムの検証は、PCが使われていない深夜などの時間帯に行う前提なので、通常の作業には支障ありませんが、
1時間ごとにバックアップを行っているタイムマシンは考慮しなければいけません。TimeMachineが動いている最中は、「検証を待つ」機能が必要ですね。

### 定期的にバックグラウンドで実行したい
macOSにもcronの仕組みが備わっているので、今回はそちらを利用します。

### デバイス名による指定やめたい
定期的なバックグラウンド実行を踏まえると、非永続的なデバイス名（/dev/disk4s2など）よりかは、
変更される可能性の少ない識別子を利用したいですね。上記のデバイス名は、ストレージをつなぎ直した際に、
接続方法によっては可変してしまう恐れがあるからです。なので、今回はボリュームのUUIDを利用することにしました。

### 結果を通知させたい
今回はSlackのWebhooksを利用して、簡易的に特定のチャンネルに通知する仕組みにしました。

## スクリプトを書く

実際のスクリプトを[こちら](https://github.com/timoshka-lab/hfshealth)に置いています。
興味のある方は、自己責任でお試し下さい。今回は、本格的な配布というよりかは、自分用に作っていますので、
書き方や設計が最善ではないことをご了承下さい。

ここからは、希望していた挙動の要点だけ書こうと思います。

### 自動修復とI/O、アンマウントの制御
`fsck_hfs`コマンドの下記オプションを駆使します。

| オプション名 | 説明 |
|  --------  |  ---  |
| -n | 障害が見つかっても、修復を行わない |
| -l | ファイルシステムをロックし、アンマウントを行わない |
| -E | ボリュームの利用に支障を与えるような重大なエラーには、47のexitコードを適用する |

実際の利用方法：

``` bash
fsck_hfs -nlE /dev/diskXXX
```

### TimeMachineの実行が終わるまで待つ

最初は、TimeMachineプロセスのPIDを取得して、waitする方法を検討しましたが、どうやらバックアップが終わってもプロセスが生き続ける仕様でした。
なので、TimeMachineのユーティリティーコマンドを使って、実行ステータスを取得し、アクティブだったら待つという処理にしました。

``` bash
while :
do
  TIMEMACHINE_STATUS=$(tmutil status | grep -c "Running = 1")

  if [ "$TIMEMACHINE_STATUS" = 0 ]; then
    break;
  fi

  sleep 10s
done
```

### UUIDからデバイス名を取得する

シェルスクリプトでは、UUIDを渡す前提で作っていますが、`fsck_hfs`を実行するにはデバイス名が必要です。
なので、デバイス名は`diskutil`を利用して取得します。

```bash
DEVICE=$(diskutil info "$UUID" | grep "Device Node" | awk '{print $3}')
```

### 結果をSlackに通知

ここはシンプルにcurlに頼る感じです（笑）
curlの結果として、「ok」という文字列が標準出力されるので、そちらを`>& /dev/null`で消します。

```bash
curl -X POST -H 'Content-type: application/json' --data '{"text": "Report Content"}' $SLACK_ENDPOINT >& /dev/null
```

### 同一デバイスに対する同時実行を避ける
プロセスの起動時に、UUIDが付いたPIDファイルを書き出しておくことで、重複起動の監視を行います。
`trap`を利用して、コマンドがexitされた際にPIDファイルを自動削除しておきます。

```bash
start_process() {
  if [ ! -d "$PID_DIR" ]; then
    mkdir -p $PID_DIR
  fi

  if [ -e "$PID_FILE" ]; then
    echo "$CMD: Job already in process." 1>&2
    exit 1
  fi

  echo $$ > "$PID_FILE"

  trap end_process exit
}

end_process() {
  unlink "$PID_FILE"
}
```

# まとめ

日々忙しかったりすると、自分のことより、進行しているプロジェクトや社内の管理方法に労力を使いがちですが、
身の回りのことも忘れずにしたいところですね。
根本的な解決策が定まらない問題も存在しますが、放置せずに試行錯誤を繰り返しながら対応していくことが重要だと思います。