⚙️

ラズパイで自動復旧・ロールバックを実装する

に公開

目次

  1. はじめに
  2. 前提: 基本のunit fileはこれくらい
  3. 再起動ループを止める StartLimitBurstとStartLimitIntervalSec
  4. OnFailure=で失敗をフックして別unitを起動する
  5. リカバリスクリプト側で気をつけたこと
  6. まとめ
  7. 参考

はじめに

みなさんRaspberry Piはご存知ですか。

Raspberry Pi(ラズベリー パイ)は、ARMプロセッサを搭載したシングルボードコンピュータ。イギリスのRaspberry Pi 財団(非営利組織)とRaspberry Pi Ltd.(営利組織)によって開発されている。日本語では略称としてラズパイとも呼ばれる[3]。
2012年の立ち上げ当初は教育で利用されることを想定して制作された。IoTが隆盛した2010年代後半以降は、安価に入手できるシングルボードコンピュータとして趣味や業務(試作品の開発)等としても用いられるようになった。その後Raspberry Pi Compute Moduleを商品に組み込む用途まで広がっていき、2023年は売上の72%が産業・組み込み市場となっている

出典:Wikipedia Raspberry Pi

要するにめっちゃ小さいパソコン型知育用玩具がいつの間にか開発でも使えるやんってなって、普及したってわけですね。
昔は2000〜3000円で買えていたイメージでしたが、今は2万以上するモデルもあります。
(昔のはブラウザすらろくに開けないくらいのスペックでしたが、今の最新型はYouTubeすら見れます。すごい。)

今回は案件のなかで、Raspberry Piを利用した常駐エッジシステムの開発時に詰まったポイントについてお話できればと思います。

今回一番懸念していたのは「アプリをアップデートした後、何らかの理由で起動しなくなる」というリスクです。物理的にすぐ触りに行けない端末だと、再起動すれば直るでは済まない障害になります。
どう自動回復させるかを調べてみたのですが、これがピンポイントでは意外と出てこない。systemctl enable で自動起動させる系や、Restart=on-failure でとりあえず再起動させる系の記事は多いのですが、「リトライしても直らない失敗にどう見切りをつけて、次にどう動くか」まで書かれた記事にはなかなか出会えませんでした。

前提: 基本のunit fileはこれくらい

[Unit]
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/path/to/app
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Restart=on-failureWantedBy=multi-user.target くらいは既存記事でも定番なので、ここから先が本題です。

再起動ループを止める StartLimitBurstとStartLimitIntervalSec

  • 何も設定しないと、Restart=on-failure だけでは「クラッシュ→再起動→クラッシュ…」を無限に繰り返します
  • これが地味に厄介で、失敗ごとにjournalにログが積まれ続けること。SDカードなど書き込み回数を気にしたい構成だと、ここが無視できないコストになります
  • StartLimitIntervalSec=60 / StartLimitBurst=3 を設定すると、「60秒間に3回失敗したら、それ以上は再起動を試みない」という上限を作れます
[Service]
ExecStart=/path/to/app
Restart=on-failure
RestartSec=5

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=3

上限に達すると unit は failed 状態になり、systemctl status はこう表示されます。

● my-app.service - My App
     Loaded: loaded (/etc/systemd/system/my-app.service; enabled)
     Active: failed (Result: start-limit-hit) since ...

Result: start-limit-hit という文言が、単なる Result: exit-code と区別できるポイントです。アプリが落ちたのかもう諦めたのかを、ここで切り分けられます。

ハマりやすいのが、上限に達した後に原因を直しても再起動してこない現象です。failed 状態が残っているだけなので、次のコマンドで手動リセットしないと Restart= のカウンタが復活しません。

systemctl reset-failed my-app.service

OnFailure=で失敗をフックして別unitを起動する

  • OnFailure= が発火するタイミングは「Restart= を使い切って StartLimitBurst に達し、unit が failed になった瞬間」です。Restartでリトライしている間は発火しないというのが、よく誤解されるポイントです
  • つまり OnFailure 単体では何も起きません。Restart=on-failureStartLimit* の3点セットで初めて「短期的な自己修復→ダメなら外部委任」という段階的な設計になります。
[Unit]
Description=My App
StartLimitIntervalSec=60
StartLimitBurst=3
OnFailure=app-recovery.service

[Service]
Type=simple
ExecStart=/path/to/app
Restart=on-failure
RestartSec=5
# app-recovery.service
[Unit]
Description=Recovery for My App

[Service]
Type=oneshot
ExecStart=/path/to/recovery.sh

Type=oneshot にしておくと「実行して終わったら完了」という扱いになり、リカバリ処理自体が常駐する心配がありません。ユースケースとしては、通知、ログの吸い上げ、設定ファイルの初期状態への復元、(該当すれば)別バージョンへのロールバック起動などが考えられます。

リカバリスクリプト側で気をつけたこと

一番のリスクは「アップデートで壊れたパターン」でした。この場合、Restart=on-failure で何度再起動させても直りません。壊れた新バージョンを立ち上げ直しても壊れたままなので、リトライし切ったら「前のバージョンに戻す」方向に倒すしかない。これが OnFailure を使う一番の理由でした。

そのため、「今動いている版」と「一つ前に動いていた版」を両方残しておく構成にしています。バージョンごとにディレクトリを分け、current というシンボリックリンクで参照先を切り替える形にしておくと、ロールバックは「リンクを張り替えて systemctl restart」の2手で済みます。

構成

/opt/myapp/
├── releases/
│   ├── v1.2.0/
│   └── v1.3.0/
├── current -> releases/v1.3.0    # デプロイ時に張り替えるシンボリックリンク
└── update_pending                 # デプロイ時に作成し、アプリが生存確認後に自分で消すフラグファイル

運用時の注意点

  • パーミッション: $APP_DIR 配下(releases/current シンボリックリンク、update_pending)は、リカバリスクリプトを実行するユーザーが書き込める必要があります。app-recovery.serviceUser= を指定しなければ root で実行されるので lnsystemctl restart はそのまま通りますが、非rootユーザーで動かす場合は systemctl restart の実行に polkit 側の許可設定が別途必要になる点に注意してください
  • ファイルが存在しない場合のガード: 初回デプロイ直後で current がまだ存在しない、あるいは releases/ ディレクトリ自体が無い状態でこのスクリプトが呼ばれるケースも考慮しておくと安全です。下のスクリプトの冒頭にガードを入れています

デプロイスクリプト側では、リンクを切り替えるタイミングで一緒に「保留中」を示すフラグファイルを作成します。

# deploy.sh(デプロイ時にリンクを張り替える部分)
ln -sfn "$APP_DIR/releases/$NEW_VERSION" "$CURRENT_LINK"
touch "$APP_DIR/update_pending"
systemctl restart my-app.service

リカバリスクリプト本体は、この update_pending がまだ残っているかどうかで「直近のアップデートに起因する失敗か」を判定します。時間経過で推測するのではなく、フラグの有無という確定情報で判定する形です。

#!/usr/bin/env bash
# recovery.sh
set -euo pipefail

APP_DIR="/opt/myapp"
CURRENT_LINK="$APP_DIR/current"
UPDATE_PENDING_FILE="$APP_DIR/update_pending"

if [ ! -e "$UPDATE_PENDING_FILE" ]; then
  echo "no pending update flag -> failure unrelated to a recent update, notify only"
  exit 0
fi

if [ ! -e "$CURRENT_LINK" ]; then
  echo "current link not found, cannot determine current version" >&2
  exit 1
fi

if [ ! -d "$APP_DIR/releases" ]; then
  echo "releases directory not found" >&2
  exit 1
fi

current_version=$(basename "$(readlink -f "$CURRENT_LINK")")
previous_version=$(ls -1 "$APP_DIR/releases" | sort -V | grep -B1 "^${current_version}$" | head -n1)

if [ -z "$previous_version" ] || [ "$previous_version" = "$current_version" ]; then
  echo "no previous version available to roll back to" >&2
  rm -f "$UPDATE_PENDING_FILE"
  exit 1
fi

echo "rolling back from $current_version to $previous_version"
ln -sfn "$APP_DIR/releases/$previous_version" "$CURRENT_LINK"
rm -f "$UPDATE_PENDING_FILE"
systemctl restart my-app.service

recoveryユニット自身のリトライ上限も、この unit 側で設定しておきます。ロールバック処理自体が失敗すると(前のバージョンのファイルが既に消えている等)、OnFailure が再帰的に発火し続けてしまうためです。

# app-recovery.service
[Unit]
Description=Recovery for My App
StartLimitIntervalSec=300
StartLimitBurst=3

[Service]
Type=oneshot
ExecStart=/opt/myapp/bin/recovery.sh

最初はこのアップデート起因かどうかの区別をせず、とりあえず何でもロールバックする実装にしていました。ただそれだと、本当に直したかった新バージョンのバグまで問答無用でロールバックされてしまい、アップデートが結局反映されないという状態になってしまったので、この区別を入れることにしました。

また、ロールバックして再起動した時点で安心とも言えません。本当に直ったかまで見る必要があります。案としては、sleepを挟んでsystemctl is-activeを見る、pgrepでプロセス自体を確認する、HTTPエンドポイントがあればcurlで疎通確認するといった「外部から一定時間後に監視する」方法も検討しました。

ただ、色々悩んだ結果、私は外部から監視するのではなく「クラッシュループ判定の時間より長く生き延びたことを、アプリ自身に自己申告させる」という方式を選びました。StartLimitIntervalSec=60 に対して、アプリ起動時にバックグラウンドで70秒待ってから、update_pending を自分で削除する処理をアプリ本体に仕込んでおきます。

# アプリ起動時に1回だけ、バックグラウンドで実行する
func confirmUpdateSurvived():
    sleep(70)  # StartLimitIntervalSec(60) より長い時間を待つ
    if exists("/opt/myapp/update_pending"):
        remove("/opt/myapp/update_pending")

# メイン処理の起動直後、非同期で呼ぶ
confirmUpdateSurvived()  # Goならgoroutine、他言語ならスレッド/非同期タスクでOK

こうすると、クラッシュループの判定期間より長く生き残れたアプリだけが自分で合格の印を消せるので、外部からの監視コマンドを増やさずに済みます。

構成、スペック、動作状況によって対応方針は変わっていくものだと思います。

まとめ&補足

  • アップデートしたら起動しなくなるかもしれないを前提に組み込んでおくと、現地に行かないと直せない状況を減らせます
  • Restart= は短期的な自己修復、OnFailure はそれで直らなかった後の一段上の処置、という役割分担で設計するのがポイントです
  • 組み合わせ自体はsystemdの標準機能ですが、アップデート失敗時のロールバックという具体的な使い方の解説は意外と見当たらなかったので、参考になれば幸いです
  • アプリを非rootユーザーで動かす場合は、フラグファイルの書き込み・削除権限(パーミッションや実行ユーザーの整合性)に注意してください

参考

株式会社アクトビ

Discussion