WEELWORKS

DB のダンプを流し込むと、静かに壊れる

第1回の冒頭に書いた話に戻ります。pg_dump ... | gzip > dump.gz が毎晩 0 バイトのファイルを作り続け、cron は「成功」を返していた、というものです。

restic を使えばこれは解決する――と言いたいところですが、しません。今回は実際にその場面を作ります。

restic は標準入力を受け取れる

DB のバックアップは、データファイルをそのままコピーしても正しく取れません。書き込みの途中で読むと、壊れた状態が保存されます。DB 自身にダンプを出させて、それを保存するのが基本です。

restic には --stdin があり、標準入力をそのままスナップショットにできます。

export PGHOST=rl-db PGUSER=labuser PGDATABASE=labdb
pg_dump --format=custom | restic backup --stdin --stdin-filename labdb.dump --tag pgdump

--stdin-filename は、スナップショットの中で使われるファイル名です。指定しないと stdin になります。

中間ファイルを作らないので、ディスクの空き容量を気にせずに済みます。ダンプが 100 GB あっても、パイプを流れるだけです。

最初の落とし穴:バージョン不一致

検証環境では、まずこうなりました。

pg_dump: error: aborting because of server version mismatch
pg_dump: detail: server version: 18.6 (Debian 18.6-1.pgdg13+2); pg_dump version: 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1)

Ubuntu 24.04 の postgresql-client は 16 系で、サーバは 18 系でした。pg_dump は自分より新しいサーバを扱えません。サーバを上げたときにクライアントを上げ忘れるという、いちばんありがちな経路です。

このときの restic の挙動を見ます。

error: failed to save /labdb.dump: read labdb.dump: no data read

Files:           0 new,     0 changed,     0 unmodified
Added to the repository: 13 B  (95 B  stored)

processed 0 files, 0 B in 0:00
snapshot dad2f901 saved
Warning: at least one source file could not be read

snapshot dad2f901 saved と出ていますが、processed 0 files, 0 B です。中身が空のスナップショットが保存されました。

ただし終了コードは正しく返っています。

pg_dump --format=custom 2>/dev/null | restic backup --stdin --stdin-filename labdb.dump --quiet
echo "終了コード: $?"
終了コード: 3

restic 0.19.1 は「読めなかった入力があった」ことを終了コード 3 で伝えます。ここは検知できます。

本当の落とし穴:途中まで出力して失敗する

問題は、pg_dump何も出さずに死ぬとは限らないことです。数百 MB 出力した時点でネットワークが切れる、ディスクが埋まる、権限エラーでテーブルの途中で止まる。こうした失敗は普通に起こります。

再現します。1,000,000 バイトだけ出力してから失敗するコマンドを流します。

bash -c "head -c 1000000 /tmp/c.dump; exit 1" | restic backup --stdin --stdin-filename db.dump --quiet
echo "restic の終了コード: $?"
restic の終了コード: 0

0 です。

restic snapshots
ID        Time                 Host         Tags        Paths     Size
-----------------------------------------------------------------------
10d2e238  2026-08-14 11:58:41  backup-host              /db.dump  976.562 KiB
-----------------------------------------------------------------------
1 snapshots
restic ls -l latest
-rw-r--r--     0     0 1000000 2026-08-14 11:58:41 /db.dump

1,000,000 バイトのスナップショットが、正常なものとして保存されました。restic から見れば、標準入力は 1,000,000 バイトで終わっただけです。パイプの反対側で何が起きたかを知る手立てがありません。

これが冒頭の gzip と同じ構造です。ツールを restic に替えても、パイプで繋いだ時点で同じ穴が開きます。

パイプの失敗を拾う

bash にはパイプライン全体の成否を見る設定があります。

set -o pipefail
bash -c "head -c 1000000 /tmp/c.dump; exit 1" | restic backup --stdin --stdin-filename db.dump --quiet
echo "パイプライン全体の終了コード: $?"
パイプライン全体の終了コード: 1

拾えました。set -o pipefail は「パイプライン中のどれか1つでも失敗したら、全体を失敗とする」という指定です。パイプで組む場合、これは必須だと考えてください。

ただし、これで解決ではありません。

restic snapshots --json | jq length
2

切り詰められたスナップショットは、すでに作られています。 検知はできても、リポジトリの中には壊れた1本が残ります。それを後から forget で消す処理まで書く必要があります。

もうひとつの問題:重複排除が効かない

失敗の話から離れて、容量の話をします。同じデータベースを、内容を変えずに3回バックアップしました。

for i in 1 2 3; do
  pg_dump --format=custom | restic backup --stdin --stdin-filename labdb.dump --tag pgdump
done
Added to the repository: 432.783 KiB (432.829 KiB stored)
Added to the repository: 432.783 KiB (432.828 KiB stored)
Added to the repository: 432.801 KiB (432.846 KiB stored)

毎回、全量が追加されています。 第2回では、同じデータを2回取ったときの追加が 0 B でした。ここではまったく効いていません。

原因は圧縮です。pg_dump --format=custom は既定で出力を圧縮します。圧縮されたデータは、元データが少し違うだけで全体のビット列が変わります。restic は内容のハッシュでチャンクを識別するので、似ているかどうかは関係ありません。違えば別物です。

圧縮を切ってみます。

pg_dump --format=custom --compress=0 | restic backup --stdin --stdin-filename labdb.dump
1回目: Added to the repository: 3.029 MiB
2回目: Added to the repository: 813.078 KiB
3回目: Added to the repository: 813.078 KiB

改善しましたが、まだ 813 KiB ずつ増えています。内容は変えていないのにです。

原因を追いました。2回ダンプを取ってバイト単位で比較します。

pg_dump --format=custom --compress=0 > /tmp/c.dump
sleep 2
pg_dump --format=custom --compress=0 > /tmp/d.dump
cmp /tmp/c.dump /tmp/d.dump
cmp -l /tmp/c.dump /tmp/d.dump | wc -l
/tmp/c.dump /tmp/d.dump differ: char 14, line 1
1

違うのは1バイトだけ、しかも14バイト目です。custom 形式のヘッダに入っているダンプ作成時刻の、秒の部分でした。

この1バイトがどれだけの再保存を引き起こすか測ります。空のリポジトリに c.dumpd.dump を順に入れます。

c.dump(3,177,812 B):  Added to the repository: 3.031 MiB
d.dump(1バイト違い):  Added to the repository: 2.160 MiB
まったく同じ c.dump  :  Added to the repository: 437 B

同じものを入れれば 437 バイト(目録の分だけ)で済みます。1バイト違うと 2.160 MiB です。

restic はファイルを可変長のチャンクに分割し、チャンク単位で重複を判定します。1バイトでも変われば、そのチャンクは丸ごと別物として保存されます。 変わった1バイトを含むチャンクが 2.160 MiB あった、ということです。

素直な解決:ファイルに書いてから取る

パイプをやめると、ここまでの問題がまとめて解決します。pg_dump --format=directory はテーブルごとにファイルを分けて出力します。

pg_dump --format=directory --compress=0 --file=/var/tmp/pgdump
ls -l /var/tmp/pgdump
-rw-r--r-- 1 root root 2922396 Aug 14 11:59 3441.dat
-rw-r--r-- 1 root root    2723 Aug 14 11:59 toc.dat

これを普通のディレクトリとしてバックアップします。

1回目(初回)        : Added to the repository: 2.791 MiB
2回目(取り直しただけ): Added to the repository: 4.161 KiB

4.161 KiB です。 時刻が入っているのは小さな toc.dat だけで、実データの .dat は変わらないため、そこが丸ごと重複排除されます。

この形にすると、失敗の検知も素直になります。ダンプが完了してからバックアップを始めるので、ダンプの失敗はバックアップが始まる前に分かります。

resticprofile で組む

第4回・第5回で作った設定に載せます。

version: 2

profiles:
  db:
    repository: "/srv/backup/dbrepo"
    password-file: "/root/.config/restic/password"

    backup:
      source: [/var/tmp/pgdump]
      verbose: true
      run-before:
        - "rm -rf /var/tmp/pgdump"
        - "pg_dump --format=directory --compress=0 --file=/var/tmp/pgdump"
      run-finally:
        - "rm -rf /var/tmp/pgdump"

run-before にダンプの取得、run-finally に後始末を書きました。run-finally は成功・失敗にかかわらず実行されるので、ダンプが失敗しても中途半端なファイルが残りません。

接続情報は PGPASSFILE で渡します。設定ファイルにパスワードを書かないためです。

printf "rl-db:5432:*:labuser:labpass\n" > /etc/restic-secrets/pgpass
chmod 600 /etc/restic-secrets/pgpass
export PGHOST=rl-db PGUSER=labuser PGPASSFILE=/etc/restic-secrets/pgpass

実行します。

1回目: Added to the repository: 2.792 MiB (419.997 KiB stored)
2回目: Added to the repository: 4.576 KiB (2.391 KiB stored)

そして肝心の点です。ダンプが失敗したらバックアップは走りません。

# 接続先を存在しないホストにして失敗させる
resticprofile --config /etc/resticprofile/db.yaml --name db backup
pg_dump: error: could not translate host name "nonexistent-db" to address: Name or service not known
2026/08/14 12:16:49 run-before backup on profile 'db': exit status 1
echo "resticprofile の終了コード: $?"
restic snapshots --json | jq length
resticprofile の終了コード: 1
2

スナップショット数は 2 のまま増えていません。run-before が失敗した時点で処理が止まり、壊れたスナップショットが1本も作られませんでした。 パイプで組んだときとの決定的な違いです。

戻せることを確認する

第3回に書いたとおり、戻していないものはバックアップではありません。別のデータベースへ復元して突き合わせます。

psql -d postgres -c "create database restored;"
restic restore latest --target /srv/pgrestore
pg_restore --dbname=restored --no-owner /srv/pgrestore/var/tmp/pgdump
psql -d labdb    -tAc "select count(*), sum(amount) from orders;"
psql -d restored -tAc "select count(*), sum(amount) from orders;"
元DB   件数: 50003   合計: 250192718.85
復元DB 件数: 50003   合計: 250192718.85

一致しました。

今回のまとめ

やり方失敗の検知重複排除一時領域
pg_dump | restic --stdin途中失敗を検知できないほぼ効かない不要
pg_dump | restic --stdinset -o pipefail検知できるが壊れた1本は残るほぼ効かない不要
ファイルに出してからバックアップダンプ失敗時はバックアップ自体が走らない効くダンプ1本分必要

--stdin が有効なのは、ダンプがディスクに載らないほど大きい場合です。それ以外なら、一時領域を使ってでもファイルに書くほうが安全です。

そして、どちらを選んでも「毎晩ちゃんと動いているか」は分かりません。 ここまで来ても、第1回の要件1は満たせていません。

次回

次回はもうひとつの連携先、Docker コンテナを扱います。バックアップのためにコンテナを止め、終わったら起こす。ここでも「失敗したときに何が起きるか」が本題になります。書き方を1行間違えると、バックアップが失敗した夜、アプリが止まったまま朝を迎えます。

この回で使ったツール