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.dump と d.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 --stdin + set -o pipefail | 検知できるが壊れた1本は残る | ほぼ効かない | 不要 |
| ファイルに出してからバックアップ | ダンプ失敗時はバックアップ自体が走らない | 効く | ダンプ1本分必要 |
--stdin が有効なのは、ダンプがディスクに載らないほど大きい場合です。それ以外なら、一時領域を使ってでもファイルに書くほうが安全です。
そして、どちらを選んでも「毎晩ちゃんと動いているか」は分かりません。 ここまで来ても、第1回の要件1は満たせていません。
次回
次回はもうひとつの連携先、Docker コンテナを扱います。バックアップのためにコンテナを止め、終わったら起こす。ここでも「失敗したときに何が起きるか」が本題になります。書き方を1行間違えると、バックアップが失敗した夜、アプリが止まったまま朝を迎えます。
この回で使ったツール
- restic — 重複排除と暗号化を備えたバックアッププログラム(標準入力からのバックアップ)
- resticprofile — restic を設定ファイルから駆動するラッパー
- PostgreSQL — 検証に使ったデータベース(pg_dump)