WEELWORKS

コンテナを止めて取る。そして、必ず起こす

前回は DB のダンプを扱いました。ただ、全てのアプリがダンプ機能を持っているわけではありません。SQLite を使うアプリ、独自形式のインデックスを持つ検索エンジン、キャッシュを書き続けるサービス。こうしたものは、動かしたままファイルをコピーしても正しく取れません。

素直な解決は、止めてから取ることです。今回はそれを組みます。そして、この構成にはアプリを止めたまま朝を迎えるという失敗の形があります。

バックアップ対象

検証には、1秒ごとにボリュームへ書き込み続けるコンテナを使います。

services:
  app:
    image: alpine:3.22
    container_name: rl-app
    command: ["sh","-c","while true; do date -Iseconds >> /data/app.log; sleep 1; done"]
    volumes:
      - appdata:/data

「常に書き込みが起きているアプリ」の最小形です。

バックアップする側から見えるようにする

まず、バックアップを実行するホストから対象のデータが見える必要があります。名前付きボリュームの実体はホスト上にあります。

docker volume inspect restic-lab_appdata --format '{{.Name}} -> {{.Mountpoint}}'
restic-lab_appdata -> /var/lib/docker/volumes/restic-lab_appdata/_data

/var/lib/docker/volumes/ の下を直接バックアップすることもできますが、この連載では対象のボリュームだけをバックアップ側にマウントしています。Docker の内部構造に依存しないほうが、Docker のバージョンが上がったときに壊れにくいからです。

コンテナを操作するために、Docker のソケットもバックアップ側から見えるようにします。

  server:
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - appdata:/mnt/appdata

権限について /var/run/docker.sock を渡すことは、Docker デーモンへの全権を渡すことと同じです。この連載では検証のためにこうしていますが、本番では「バックアップ用のスクリプトが docker を実行できるサーバ」の権限設計を別途検討してください。

まず素直に書く

resticprofile の run-beforerun-after を使います。

version: 2

profiles:
  app:
    repository: "/srv/backup/apprepo"
    password-file: "/root/.config/restic/password"
    initialize: true

    backup:
      source: [/mnt/appdata]
      verbose: true
      run-before:
        - "docker stop rl-app"
      run-after:
        - "docker start rl-app"

止めて、取って、起こす。読んだとおりに動きます。

resticprofile --config /etc/resticprofile/app.yaml --name app backup
2026/08/14 12:01:05 profile 'app': starting 'backup'
scan finished in 0.002s: 1 files, 1.219 KiB

Files:           1 new,     0 changed,     0 unmodified
Added to the repository: 2.298 KiB (1.031 KiB stored)

processed 1 files, 1.219 KiB in 0:00
snapshot 4c7aa1f1 saved
2026/08/14 12:01:06 profile 'app': finished 'backup'
docker ps --filter name=rl-app --format "{{.Names}}\t{{.Status}}"
rl-app	Up Less than a second

正常に動きました。ここで終われば話は簡単です。

バックアップが失敗した夜

バックアップは失敗します。ディスクが埋まる、NAS が落ちる、リポジトリがロックされたまま残る。失敗させてみます。

設定に存在しないパスを足して、restic を失敗させます。

    backup:
      source: [/mnt/appdata, /does/not/exist]
      run-before:
        - "docker stop rl-app"
      run-after:
        - "docker start rl-app"
resticprofile --config /etc/resticprofile/appfail.yaml --name app backup
docker ps -a --filter name=rl-app --format "{{.Names}}\t{{.Status}}"
rl-app	Exited (137) 1 second ago

アプリが止まったままです。

run-after は「成功したら実行する」フックだからです。restic が失敗した時点で処理が終わり、docker start には到達しません。

夜中の2時半にこれが起きると、翌朝までアプリが止まっています。バックアップの失敗が、サービスの停止に化けました。 バックアップは本来、障害に備える仕組みです。それ自体が障害の原因になるのは、本末転倒としか言えません。

run-finally を使う

resticprofile のフックには4種類あります。

フック実行されるタイミング
run-beforeコマンドの実行前
run-afterコマンドが成功したとき
run-after-failコマンドが失敗したとき
run-finally成功・失敗にかかわらず、最後に必ず

「必ず起こす」なら run-finally です。

    backup:
      source: [/mnt/appdata, /does/not/exist]
      run-before:
        - "docker stop rl-app"
      run-finally:
        - "docker start rl-app"

同じように失敗させます。

resticprofile --config /etc/resticprofile/appfinally.yaml --name app backup
docker ps -a --filter name=rl-app --format "{{.Names}}\t{{.Status}}"
rl-app	Up Less than a second

バックアップは失敗しましたが、アプリは動いています。

run-beforedocker stoprestic backup成功失敗run-afterrun-after-failrun-finallydocker start

止める処理と起こす処理は、必ず対で run-beforerun-finally に置く。 これが今回の結論です。run-after に「起こす」を書いてはいけません。

なお run-after-faildocker start を書いても、失敗時にはアプリが復帰します。ただし成功時と失敗時の両方に同じコマンドを書くことになり、片方を直し忘れる余地が残ります。run-finally に1行だけ書くほうが安全です。

どれだけ止まるのか

「止めて取る」の実用性は、停止時間で決まります。測りました。

run() {
  s=$(date +%s%3N)
  docker stop rl-app
  resticprofile --config /etc/resticprofile/app.yaml --name app backup
  docker start rl-app
  e=$(date +%s%3N)
  echo "停止時間: $(( e - s )) ms"
}
条件停止時間
小さいデータ(ログ1本、約 3 KB)3,342 ms
500 MB を追加した直後(初回)9,672 ms
同じ 500 MB を再度(差分なし)2,819 ms

読みどころは3行目です。500 MB のデータがあっても、2回目以降は小さいデータのときと変わりません。 第2回で見た重複排除がそのまま効いていて、restic は変わっていないファイルを読み直さないからです(更新時刻とサイズで判定します)。

つまり、この構成の停止時間はデータの総量ではなく変化量で決まります。毎日数十 MB しか変わらないアプリなら、数百 GB のボリュームを抱えていても停止は数秒で済みます。

ただし初回だけは全量を読みます。500 MB で 9.7 秒でしたから、100 GB なら単純計算で30分を超えます。初回だけは手動で、サービス影響のない時間帯に実行しておくのが実務的です。

停止時間の内訳のうち、コンテナの停止と起動そのものにも時間がかかります。検証環境では docker stop に約1.3秒かかりました。docker stop --time 1 で待ち時間の上限を短くできますが、アプリが終了処理を持っている場合、短くしすぎると強制終了になります。今回のコンテナは終了コード 137(SIGKILL)で落ちていました。アプリが終了シグナルを正しく扱えるかどうかを、先に確認してください。

止めずに済ませたい場合

停止が許されないサービスもあります。選択肢を挙げておきます。

  • アプリの機能を使う:前回の pg_dump がこれです。アプリ自身に整合性のある形で吐き出させるのがいちばん確実です。
  • ファイルシステムのスナップショットを使う:LVM や ZFS/Btrfs のスナップショットを取り、そのスナップショットをバックアップします。停止時間はスナップショット作成の一瞬だけです。ただしアプリがメモリ上に持っている未書き込みのデータは含まれません。
  • 一時停止だけにするdocker pause はプロセスを凍結します。stop より速く復帰しますが、ディスクへの書き込みが完了しているとは限りません。

未検証 上の3点のうち、pg_dump を使う方法は前回検証済みです。ファイルシステムのスナップショットと docker pause については、この連載では検証していません。採用する場合は、実際に復元して整合性を確認してから運用に載せてください。

今回の設定

第5回までの内容と合わせると、こうなります。

version: 2

global:
  restic-binary: /usr/local/bin/restic

profiles:
  app:
    repository: "/srv/backup/apprepo"
    password-file: "/root/.config/restic/password"
    lock: "/tmp/resticprofile-app.lock"
    force-inactive-lock: true

    backup:
      source: [/mnt/appdata]
      verbose: true
      schedule: "*-*-* 03:00:00"
      schedule-permission: system
      run-before:
        - "docker stop rl-app"
      run-finally:
        - "docker start rl-app"

    retention:
      after-backup: true
      keep-daily: 7
      keep-weekly: 4
      keep-monthly: 6
      prune: true

次回

第5回の最後に書いた穴が、そのまま残っています。毎晩動いているかどうかを、誰も見ていません。

今回それはさらに重くなりました。バックアップが静かに失敗しているとき、失われているのはバックアップだけではないからです。次回は healthchecks.io と Discord を使って、失敗と――失敗より厄介な――無音を検知します。

この回で使ったツール