PostgreSQL を用意し、残高を安全に更新する
PostgreSQL を導入し、不正な値を拒む制約、更新をまとめて確定する transaction、同時更新を防ぐ行ロックによって、残高とイベントを安全に更新し、確定状態を証拠から説明します。
| 所要時間 | 約 80 分(概念 15 分+演習 65 分) |
|---|---|
| 前提知識 | Part 1 のファイル・権限操作、Part 2 の接続確認、Part 3 の systemd・ログ、Part 4 Chapter 1 のパッケージ取得経路 |
| 使用機材 | データベースサーバ db01、操作端末 workstation01、LAN 内のパッケージ取得経路 |
| 関連章 | 前: Part 4 / Chapter 1「nginx を入れて、サービスの入口を公開する」/後: Part 4 / Chapter 3「Web、アプリ、DB をつないで、タップを処理する」 |
入口の後ろに、壊れないデータ層を置く
Chapter 1 では nginx を導入し、別ノードから要求が届く入口を作りました。しかし、入口が応答するだけでは、残高やタップ履歴を安全に保管できません。本章では db01 に PostgreSQL(データベース管理システム)を導入し、残高とイベントをひとまとまりで更新するデータ層を作ります。
Part 4 の App が扱う残高と event ID(1 回のタップ処理を見分ける識別子)は、すべて PostgreSQL に記録します(ほかの場所には持ちません)。更新は DB のトランザクション(transaction:複数の更新を「全部成功」か「全部なかったこと」のどちらかにまとめる仕組み)で守ります。このほか、制約(不正な値を DB が拒否する決まり)と行ロック(同じ行を同時に更新させない仕組み)も使います。共有ファイルの提供・書き込み・後始末は Part 6 で扱うため、本章の演習には含めません。
workstation01
│ SSH
▼
db01 ───── PostgreSQL
├─ cards(残高)
└─ tap_events(処理履歴)
この章のゴール: PostgreSQL を導入して接続し、制約・transaction・行ロックによって残高と event ID を安全に更新できる。さらに、未確定 transaction と確定済みデータの違いを証跡で説明できる。
この章で作る DB は、街の改札サービスの「残高」を守る層にあたります。街の全体像と、街の言葉・技術の言葉の対応は Part 0(この教材で作る街)にまとめています。
本章では、次の範囲は扱いません。
- アプリケーションの実装。Chapter 3 で教材資産として配布します。
- 複数バックエンドからの同時利用。Chapter 4 で扱います。
- PostgreSQL の高可用化・レプリケーション。本章は単一 DB の整合性が対象です。
- 共有ストレージの提供・書き込み・容量設計・後始末。Part 6 で扱います。
- DB の継続監視。メトリクス収集やアラートは Part 7 で扱います。
この章を終えるとできること
データを「保存できた」だけでなく、競合と失敗を含めて状態を説明できるようになります。本章で身につく 4 動作 は次のとおりです。
psql 接続を別々に確かめる。psql セッションで同じ行を操作し、行ロックによる待ちと重複拒否を確認する。DB の整合性と共有ファイルを分けて考える
1. transaction:複数の変更をひとまとまりにする
交通 IC カード型サービスでは、残高を減らす処理と「どの event を処理したか」の記録が対になります。残高だけ減って履歴が無い、または履歴だけあって残高が変わらない状態は、処理の途中で接続が切れたときに起こり得ます。
transaction は BEGIN から COMMIT までの変更をひとまとまりにします。途中で処理を続けられなくなれば、未確定の変更は rollback されます。本章では、次の境界を SQL の実行順で確認します。
BEGIN ├─ 対象カードをロックする ├─ 残高が足りる場合だけ減額する └─ event ID を履歴へ登録する COMMIT ── ここで残高と履歴が同時に確定する
2. 行ロックと待ち:同時更新を順番にする
2 つの接続が同じ残高を同時に読み、それぞれが古い値を基に更新すると、片方の変更を失う危険があります。SELECT ... FOR UPDATE は transaction が終わるまで対象行をロックし、同じ行を更新しようとする別の transaction を待たせます。
「待つ」は故障ではありません。先に処理している transaction の確定結果を見てから次の処理を進めるための動作です。ただし待ちが長いときは、誰がどの transaction を開いたままにしているかを確認する必要があります。
3. 制約:異常な値を後から探すのではなく、入れさせない
CHECK (balance >= 0) は負の残高を拒否し、PRIMARY KEY (event_id) は同じ event ID の二重登録を拒否します。アプリ側の確認だけに頼ると、別の経路から SQL が実行された場合や、同時実行の隙間で不正な値が入る可能性があります。
制約はデータの最後の防波堤です。エラーが出たこと自体を失敗と決めつけず、「守るべき条件に反したため DB が拒否した」という成功の証拠として読みます。
PostgreSQL のデータディレクトリと、他ノードが読み書きする共有領域は責務が異なります。DB の整合性は PostgreSQL が管理し、共有領域の隔離と後始末は Part 6 で扱います。
📔 コラム · rollback の境界は VM の境界
db01の snapshot を戻せば、VM 内の package、schema(テーブルや制約などの DB の構造)、DB の変更は戻せます。一方、別装置に書いた共有ファイルは VM snapshot の外側に残り得ます。Part 4 では実際に書き込まず、この外部残留の実装と検査を Part 6 へ引き渡します。
演習:残高更新と transaction の境界を観測する
演習環境
| 項目 | 値 |
|---|---|
| 構築対象 | db01 |
| 操作端末 | workstation01(STEP 05 は左右 2 枚の端末を使う) |
| 接続方法 | workstation01 から ssh deployer@db01 |
| 必要権限 | sudo。package 導入、PostgreSQL 管理ユーザでの操作に使う |
| パッケージ取得 | 署名済み内部 APT リポジトリ(http://172.16.10.22/ubuntu) |
| 所要時間 | 約 65 分(STEP 1〜8 の実行) |
🛟 失敗しても安全: DB 内の未確定変更は SQL の
ROLLBACKで戻します。章を終えるときは、STEP 08 末尾の削除手順で本章が追加した DB と PostgreSQL を消し、構築前の状態へ戻ったことを確認します。
LAN 内の取得経路を確認して PostgreSQL を導入する
db01 は本章で初めて使うサーバです。ここでは第 1 章の STEP 01 と同じ 4 段階を省略せずに実行します。公開鍵の取得、別経路での fingerprint 照合、既存取得先を変更しない確認、専用 .sources の作成を順に行い、最後に apt update の取得先を確かめます。
ほかの操作を始める前に、db01 1 台で構築前の manifest(ファイルの場所・権限・更新日時などを一覧にしたもの)と package 一覧を採ります。manifest の対象は path・type・mode・uid・gid・size・mtime です。章末に同じ条件でもう一度採り、4 分類の列挙後に差分を確認します。
# db01 で、章のほかの操作より先に実行する
sudo find /etc /var/lib /srv /opt /usr/local "$HOME" \
-not -path '/var/lib/apt/*' \
-not -path '/var/lib/dpkg/*' \
-not -path '/var/lib/systemd/*' \
-not -path '/var/log/*' \
-not -path '/var/cache/*' \
-not -name '.bash_history' \
-not -path '/etc/passwd-' -not -path '/etc/group-' \
-not -path '/etc/shadow-' -not -path '/etc/gshadow-' \
-not -path '/var/lib/fwupd' -not -path '/var/lib/fwupd/*' \
-not -path '/etc/fwupd' -not -path '/etc/fwupd/*' \
-not -path '/var/lib/ucf' -not -path '/var/lib/ucf/*' \
-not -path '/var/lib/landscape' -not -path '/var/lib/landscape/*' \
-not -path '/etc/ld.so.cache' \
-not -path '/var/lib/ubuntu-release-upgrader/release-upgrade-available' \
-not -path '/var/lib/snapd/state.json' \
-not -path '/var/lib/snapd/maintenance.json' \
-not -path '/var/lib/logrotate/status' \
-printf '%p\t%y\t%m\t%U\t%G\t%s\t%T@\n' 2>/dev/null \
| LC_ALL=C sort \
| sudo tee /tmp/part4-manifest-before.tsv >/dev/null
dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort > /tmp/part4-packages-before.tsv
test -s /tmp/part4-packages-before.tsv
1. 公開鍵を取得する
ssh deployer@db01 REPO_URI='http://172.16.10.22/ubuntu' curl --fail --show-error --head "$REPO_URI/dists/noble/InRelease" curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" \ -o /tmp/infra-study-archive-keyring.asc
2. fingerprint を別経路の正本と照合する
鍵を取得した HTTP 経路だけを信頼の起点にしません。取得した鍵の fingerprint を表示し、HTTPS の教材ページ に掲載された正本と全桁で照合します。
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
gpg --homedir "$GPG_HOME" --show-keys --with-fingerprint --keyid-format long \
/tmp/infra-study-archive-keyring.asc
EXPECTED_FINGERPRINT='089E07BDFFA26DB7C7203016297FAD1317CA1DC4'
ACTUAL_FINGERPRINT="$(gpg --homedir "$GPG_HOME" --show-keys --with-colons \
/tmp/infra-study-archive-keyring.asc | awk -F: '$1=="fpr" {print $10; exit}')"
test "$ACTUAL_FINGERPRINT" = "$EXPECTED_FINGERPRINT"
fingerprint_status=$?
rm -rf -- "$GPG_HOME"
echo "fingerprint match=$fingerprint_status"
test "$fingerprint_status" -eq 0
fingerprint match=0 であることを確認してから次へ進みます。一致しなければ鍵を配置せず、演習を止めます。確認には専用の一時 GNUPGHOME を使い、既存の ~/.gnupg には触れません。
3. 既存の取得先を確認し、変更しない
先に現在有効な source を確認します。本章は次の手順で章専用ディレクトリだけを APT に読ませるため、既存 source の削除・移動・改名は行いません。
sudo grep -HnE '^[[:space:]]*(deb |Types:|URIs:)' \ /etc/apt/sources.list \ /etc/apt/sources.list.d/*.list \ /etc/apt/sources.list.d/*.sources 2>/dev/null || true
4. 専用 source を作り、取得先を確認する
sudo install -d -m 0755 /etc/apt/keyrings
sudo install -o root -g root -m 0644 \
/tmp/infra-study-archive-keyring.asc \
/etc/apt/keyrings/infra-study-archive-keyring.asc
sudo install -d -m 0755 /etc/apt/sources.list.d.part4
sudo tee /etc/apt/sources.list.d.part4/infra-study.sources > /dev/null <<EOF
Types: deb
URIs: $REPO_URI
Suites: noble
Components: part4
Architectures: amd64
Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc
EOF
sudo chmod 0644 /etc/apt/sources.list.d.part4/infra-study.sources
cat /etc/apt/sources.list.d.part4/infra-study.sources
# 章専用 source だけを見る。既存の取得先には触れない
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
sudo env LC_ALL=C apt-get "${APT_ONLY_PART4[@]}" update 2>&1 | tee /tmp/apt-update-db01.log
grep -E '^(Get|Hit):' /tmp/apt-update-db01.log
apt-cache "${APT_ONLY_PART4[@]}" policy postgresql postgresql-client
sudo apt-get "${APT_ONLY_PART4[@]}" install postgresql postgresql-client
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Tue, 18 Aug 2026 01:52:30 GMT
Content-Type: application/octet-stream
Content-Length: 2699
Last-Modified: Sun, 16 Aug 2026 01:35:52 GMT
Connection: keep-alive
ETag: "6a8113f8-a8b"
Accept-Ranges: bytes
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
0 2699 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0
100 425 100 425 0 0 363k 0 --:--:-- --:--:-- --:--:-- 415k
pub ed25519/297FAD1317CA1DC4 2026-08-05 [SC] [expires: 2027-08-05]
Key fingerprint = 089E 07BD FFA2 6DB7 C720 3016 297F AD13 17CA 1DC4
uid infra-study lab archive <archive@lab.invalid>
fingerprint match=0
--- before ---
/etc/apt/sources.list.d/ubuntu.sources:43:Types: deb
/etc/apt/sources.list.d/ubuntu.sources:44:URIs: http://archive.ubuntu.com/ubuntu
/etc/apt/sources.list.d/ubuntu.sources:51:Types: deb
/etc/apt/sources.list.d/ubuntu.sources:52:URIs: http://security.ubuntu.com/ubuntu
--- after ---
Types: deb
URIs: http://172.16.10.22/ubuntu
Suites: noble
Components: part4
Architectures: amd64
Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc
WARNING: apt does not have a stable CLI interface. Use with caution in scripts.
Get:1 http://172.16.10.22/ubuntu noble InRelease [2699 B]
Get:2 http://172.16.10.22/ubuntu noble/part4 amd64 Packages [14.5 kB]
Fetched 17.2 kB in 0s (121 kB/s)
Reading package lists...
Building dependency tree...
Reading state information...
All packages are up to date.
Get:1 http://172.16.10.22/ubuntu noble InRelease [2699 B]
Get:2 http://172.16.10.22/ubuntu noble/part4 amd64 Packages [14.5 kB]
postgresql:
Installed: (none)
Candidate: 16+257build1.1
Version table:
16+257build1.1 500
500 http://172.16.10.22/ubuntu noble/part4 amd64 Packages
postgresql-client:
Installed: (none)
Candidate: 16+257build1.1
Version table:
16+257build1.1 500
500 http://172.16.10.22/ubuntu noble/part4 amd64 Packages
Data page checksums are disabled.
fixing permissions on existing directory /var/lib/postgresql/16/main ... ok
creating subdirectories ... ok
selecting dynamic shared memory implementation ... posix
selecting default max_connections ... 100
selecting default shared_buffers ... 128MB
selecting default time zone ... Etc/UTC
creating configuration files ... ok
running bootstrap script ... ok
performing post-bootstrap initialization ... ok
syncing data to disk ... ok
Setting up postgresql (16+257build1.1) ...
Processing triggers for man-db (2.12.0-4build2) ...
Processing triggers for libc-bin (2.39-0ubuntu8.8) ...
Running kernel seems to be up-to-date.
No services need to be restarted.
No containers need to be restarted.
No user sessions are running outdated binaries.
No VM guests are running outdated hypervisor (qemu) binaries on this host.
● postgresql.service - PostgreSQL RDBMS
Loaded: loaded (/usr/lib/systemd/system/postgresql.service; enabled; preset: enabled)
Active: active (exited) since Tue 2026-08-18 01:53:56 UTC; 8s ago
Main PID: 2964 (code=exited, status=0/SUCCESS)
CPU: 3ms
導入結果だけでなく、apt update が http://172.16.10.22/ubuntu の InRelease と Packages だけを取得したことを確認します。外部の配布元が 1 件でも表示されたら導入を止め、有効な source を再確認します。
service・待受 socket・接続を別々に確認する
まず systemd が管理する service の状態を確認します。次に TCP の待受 socket を確認し、最後に PostgreSQL 管理ユーザとして psql へ接続します。
systemctl status postgresql --no-pager sudo ss -lntp | grep postgres sudo -u postgres psql -c 'SELECT version();'
SELECT version() の表示が pager の (END) で止まったら、q を押して psql の表示へ戻ります。固まったわけではありません。
● postgresql.service - PostgreSQL RDBMS
Loaded: loaded (/usr/lib/systemd/system/postgresql.service; enabled; preset: enabled)
Active: active (exited) since Tue 2026-08-18 01:53:56 UTC; 24s ago
Main PID: 2964 (code=exited, status=0/SUCCESS)
CPU: 3ms
LISTEN 0 200 127.0.0.1:5432 0.0.0.0:* users:(("postgres",pid=3851,fd=7))
LISTEN 0 200 [::1]:5432 [::]:* users:(("postgres",pid=3851,fd=6))
version
------------------------------------------------------------------------------------------------------------------------------------------
PostgreSQL 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0, 64-bit
(1 row)
3 つは同じ確認ではありません。systemctl は管理状態、ss は通信の受付、psql は DB として問い合わせへ応答できることを見ています。
残高と event ID を守る schema を作る
演習用 DB を作り、cards と tap_events を定義します。残高が負にならない条件と event ID の一意性を DB 自身に守らせます。
sudo -u postgres createdb transit_lab
sudo -u postgres psql -P pager=off -d transit_lab <<'SQL'
CREATE TABLE cards (
card_id text PRIMARY KEY,
balance integer NOT NULL CHECK (balance >= 0)
);
CREATE TABLE tap_events (
event_id text PRIMARY KEY,
card_id text NOT NULL REFERENCES cards(card_id),
amount integer NOT NULL CHECK (amount > 0),
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO cards (card_id, balance)
VALUES ('card-demo', 1000);
\d cards
\d tap_events
TABLE cards;
SQL
CREATE TABLE
CREATE TABLE
INSERT 0 1
Table "public.cards"
Column | Type | Collation | Nullable | Default
---------+---------+-----------+----------+---------
card_id | text | | not null |
balance | integer | | not null |
Indexes:
"cards_pkey" PRIMARY KEY, btree (card_id)
Check constraints:
"cards_balance_check" CHECK (balance >= 0)
Referenced by:
TABLE "tap_events" CONSTRAINT "tap_events_card_id_fkey" FOREIGN KEY (card_id) REFERENCES cards(card_id)
Table "public.tap_events"
Column | Type | Collation | Nullable | Default
------------+--------------------------+-----------+----------+---------
event_id | text | | not null |
card_id | text | | not null |
amount | integer | | not null |
created_at | timestamp with time zone | | not null | now()
Indexes:
"tap_events_pkey" PRIMARY KEY, btree (event_id)
Check constraints:
"tap_events_amount_check" CHECK (amount > 0)
Foreign-key constraints:
"tap_events_card_id_fkey" FOREIGN KEY (card_id) REFERENCES cards(card_id)
card_id | balance
-----------+---------
card-demo | 1000
(1 row)
CHECK と主キー(各行を見分けるための列)が表示されることを確認します。これらは処理後に異常を探す仕組みではなく、不正な状態への変更を入口で拒否する仕組みです。
残高とイベントを 1 transaction で確定する
対象カードをロックし、残高が足りる場合だけ減額して、同じ transaction 内で event を記録します。各文を 1 行ずつ読みながら実行してください。
sudo -u postgres psql -P pager=off -d transit_lab
BEGIN;
SELECT card_id, balance
FROM cards
WHERE card_id = 'card-demo'
FOR UPDATE;
UPDATE cards
SET balance = balance - 120
WHERE card_id = 'card-demo'
AND balance >= 120
RETURNING card_id, balance;
INSERT INTO tap_events (event_id, card_id, amount)
VALUES ('event-first', 'card-demo', 120);
COMMIT;
SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';
SELECT event_id, card_id, amount FROM tap_events WHERE event_id = 'event-first';
BEGIN card_id | balance -----------+--------- card-demo | 1000 (1 row) card_id | balance -----------+--------- card-demo | 880 (1 row) UPDATE 1 INSERT 0 1 COMMIT card_id | balance -----------+--------- card-demo | 880 (1 row) event_id | card_id | amount -------------+-----------+-------- event-first | card-demo | 120 (1 row)
UPDATE 1 と INSERT 0 1 は、どちらも COMMIT まで未確定です。残高と履歴が同時に確定したことを、最後の 2 つの SELECT で確認します。
2 つの接続で行ロックの待ちと重複拒否を観測する
端末を左右に並べ、両方から db01 へ接続します。先に左側で対象行をロックし、transaction を開いたままにします。
左側の psql で実行します。
BEGIN;
SELECT card_id, balance
FROM cards
WHERE card_id = 'card-demo'
FOR UPDATE;
UPDATE cards
SET balance = balance - 100
WHERE card_id = 'card-demo'
AND balance >= 100
RETURNING card_id, balance;
INSERT INTO tap_events (event_id, card_id, amount)
VALUES ('event-lock-a', 'card-demo', 100);
BEGIN card_id | balance -----------+--------- card-demo | 880 (1 row) card_id | balance -----------+--------- card-demo | 780 (1 row) UPDATE 1 INSERT 0 1
左側を COMMIT せず、右側の psql で同じ行をロックします。
BEGIN; SELECT card_id, balance FROM cards WHERE card_id = 'card-demo' FOR UPDATE;
右側は結果を表示せず、ロックが解放されるまで待ち続けます。終了コードは表示されません。右側を待たせたまま、左側の psql で次の SQL を直接実行し、接続状態を観測します。
SELECT pg_stat_clear_snapshot(); SELECT pid, state, wait_event_type, wait_event FROM pg_stat_activity WHERE datname = 'transit_lab' ORDER BY pid;
左側は BEGIN したまま COMMIT していないため、同じ transaction の中では pg_stat_activity の結果が使い回されます。1 文目の pg_stat_clear_snapshot() は、その結果を捨てて取り直すための操作です。中身が空の 1 行((1 row))が表示されれば正常です。同じ確認をやり直すときも、この 2 文を続けて実行してください。表示された行のうち、右側の接続が wait_event_type=Lock、wait_event=transactionid になっていることを確認します。pid は実行のたびに変わります。確認できたら、左側で確定します。
COMMIT;
左側の確定後、右側の SELECT が返り、確定後の残高 780 を読めることを確認します。右側は変更せずに終了します。
card_id | balance -----------+--------- card-demo | 780 (1 row)
ROLLBACK;
続いて、確定済みの event ID をもう一度登録します。
INSERT INTO tap_events (event_id, card_id, amount)
VALUES ('event-lock-a', 'card-demo', 100);
ERROR: duplicate key value violates unique constraint "tap_events_pkey" DETAIL: Key (event_id)=(event-lock-a) already exists.
重複登録のエラーは、同じ event を二重処理させない制約が働いた証拠です。右側の prompt が transaction 失敗状態になった場合は ROLLBACK; して通常状態へ戻します。
transaction と接続の状態を読み分ける
STEP 05 では、右側の接続が待っている間に、左側の psql で pg_stat_activity を直接確認しました。ここでは観測結果を、画面の見え方と DB 内部の状態へ対応づけます。
| 観測 | 意味 | 切り分け |
|---|---|---|
| 右側に結果が出ない | 先行 transaction の終了待ち | 通信断や service 停止と決めつけない |
wait_event_type=Lock | DB 内のロック待ち | wait_event と先行 transaction を確認する |
左側の COMMIT 後に結果が返る | ロックが解放された | 確定後の残高を読めたことを確認する |
PostgreSQL の保存先と確定状態を確認する
PostgreSQL のデータディレクトリと、確定済みの残高・event を確認します。データディレクトリを移動する操作は行いません。
sudo -u postgres psql -Atc 'SHOW data_directory;' sudo -u postgres psql -P pager=off -d transit_lab -c \ "SELECT card_id, balance FROM cards ORDER BY card_id;" sudo -u postgres psql -P pager=off -d transit_lab -c \ "SELECT event_id, card_id, amount FROM tap_events ORDER BY event_id;"
/var/lib/postgresql/16/main card_id | balance -----------+--------- card-demo | 780 (1 row) event_id | card_id | amount --------------+-----------+-------- event-first | card-demo | 120 event-lock-a | card-demo | 100 (2 rows)
cards と tap_events は同じ transaction で確定します。App は残高とイベントをこの DB だけに記録し、ローカルファイルへ別の残高を持ちません。
未確定 transaction を壊し、確定済みデータだけが残ることを確認する + GOAL 🎯
まず psql で残高を更新し、COMMIT せずに接続を切ります。
BEGIN; SELECT card_id, balance FROM cards WHERE card_id = 'card-demo' FOR UPDATE; UPDATE cards SET balance = balance - 50 WHERE card_id = 'card-demo' AND balance >= 50 RETURNING card_id, balance; \q
再接続し、切断前に表示された未確定の残高ではなく、最後に commit した残高へ戻っていることを確認します。
sudo -u postgres psql -P pager=off -d transit_lab \ -c "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"
BEGIN card_id | balance -----------+--------- card-demo | 780 (1 row) card_id | balance -----------+--------- card-demo | 730 (1 row) UPDATE 1 card_id | balance -----------+--------- card-demo | 780 (1 row)
最後に、最後に commit した event と残高が対応し、切断した transaction の変更が残っていないことを確認します。証跡を保存したら、次の削除手順で本章が追加した構築物を消します。
ゴール到達 ✓ ① PostgreSQL の稼働を service・socket・接続で確認できた / ② 制約・transaction・行ロックで残高更新を守れた / ③ 未確定 transaction を切断し、確定済みデータだけが残ることを確認できた。
演習の証跡を保存したら、自分で追加した構築物を削除します。先に DB transit_lab を削除し、その後で PostgreSQL 本体を削除します。package を先に消すと dropdb を実行する手段がなくなるため、順序を逆にしてはいけません。
STEP 01 で配置した infra-study.sources と keyring は本章の構築物なので削除します。既存の APT source は最初から変更していないため、復元操作や終了時の無限定 apt update は行いません。fingerprint 確認用の専用 GNUPGHOME は STEP 01 内ですでに削除済みで、既存の ~/.gnupg は変更していません。
package は章開始時の一覧との差分を取り、PostgreSQL 導入で確認済みの名前だけかを検査します。想定外の package が 1 つでもあれば、削除せずに停止します(「想定外の package: …」と「SSH 接続は維持したまま処理を中止しました」が表示されます)。検査を通った差分だけを名指しで削除します。apt autoremove は使いません。章の外で auto 扱いになった package まで対象になり、clean snapshot の状態によって削除範囲が変わるためです。削除後に /etc/ssl/private の group と mode を基準値へ戻してから、postgres ユーザの不在を確認します。
(
test -s /tmp/part4-packages-before.tsv || { echo '構築前の package 一覧がありません' >&2; exit 1; }
mapfile -t chapter_packages < <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort | comm -13 /tmp/part4-packages-before.tsv -)
unexpected=()
for pkg in "${chapter_packages[@]}"; do
case "${pkg%:amd64}" in
libcommon-sense-perl|libjson-perl|libjson-xs-perl|libllvm17t64|libpq5|libtypes-serialiser-perl|postgresql|postgresql-16|postgresql-client|postgresql-client-16|postgresql-client-common|postgresql-common|ssl-cert) ;;
*) unexpected+=("$pkg") ;;
esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
sudo -u postgres dropdb --if-exists transit_lab
sudo -u postgres psql -Atqc "SELECT count(*) FROM pg_database WHERE datname = 'transit_lab';"
# package を消してもデータは残る。先に cluster を落とす
pg_lsclusters --no-header
pg_lsclusters --no-header | awk '{print $1, $2}' | while read -r version cluster; do
sudo pg_dropcluster --stop "$version" "$cluster"
done
APT_ONLY_PART4=(
-o Dir::Etc::sourcelist=/dev/null
-o Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4
-o APT::Get::List-Cleanup=0
)
((${#chapter_packages[@]} == 0)) || sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" purge -y "${chapter_packages[@]}"
sudo userdel postgres 2>/dev/null || true
sudo groupdel postgres 2>/dev/null || true
sudo groupdel ssl-cert 2>/dev/null || true
sudo chown root:root /etc/ssl/private
sudo chmod 0700 /etc/ssl/private
sudo rm -rf /etc/apt/sources.list.d.part4
sudo rm -f /etc/apt/keyrings/infra-study-archive-keyring.asc
rm -f /tmp/infra-study-archive-keyring.asc /tmp/apt-update-db01.log
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
mapfile -t package_diff < <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort | comm -13 /tmp/part4-packages-before.tsv -)
if ((${#package_diff[@]})); then
printf '章開始時より増えた package: %s\n' "${package_diff[@]}" >&2
exit 1
else
echo '章開始時より増えた package は 0 件'
fi
rm -f /tmp/part4-packages-before.tsv
test ! -e /tmp/part4-packages-before.tsv
if systemctl is-active --quiet postgresql; then
echo "PostgreSQL service 稼働中"
else
echo "PostgreSQL service は非稼働"
fi
if sudo ss -lntp | grep -q ':5432 '; then
echo "5432 番の待受あり"
else
echo "5432 番の待受なし"
fi
if sudo test -e /var/lib/postgresql -o -e /etc/postgresql; then
echo "データディレクトリが残っています"
else
echo "データディレクトリなし"
fi
getent passwd postgres || echo "postgres user なし"
getent group postgres || echo "postgres group なし"
getent group ssl-cert || echo "ssl-cert group なし"
stat -c '/etc/ssl/private mode=%a gid=%g' /etc/ssl/private
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2
| 分類 | 検査すること | 合格条件 |
|---|---|---|
| DB | pg_database の transit_lab | 0 行 |
| package / service | 章開始時との差分と PostgreSQL service | 増えた package が 0 件で、service が稼働していない |
| socket | PostgreSQL の 5432 番 | 待受がない |
| データ | cluster(1 つのデータディレクトリで管理される DB のまとまり)のデータと設定(/var/lib/postgresql・/etc/postgresql) | どちらも残っていない |
0 章開始時より増えた package は 0 件 PostgreSQL service は非稼働 5432 番の待受なし
4 分類の外を manifest 差分で確認する
4 分類を自分で列挙して検査したあと、その外側に残る変更を、構築前後の sorted manifest で確認します。差分は 4 分類を置き換えるものではなく、列挙から漏れた残留候補を見つける安全網です。全差分は /var/tmp/part4-chapter-manifest.diff に一時保存し、「残留候補」と「更新日時だけ変わった通常ファイル」に分けて全件を画面に表示してから削除します。途中で切りません。表示に失敗したときは、構築前・章末の manifest と差分ファイルを消さずに残し、理由を表示します。表示された理由を確かめるまで、後片付けへ進まないでください。
次の除外には、それぞれ理由があります。
/var/lib/apt/は apt が索引を更新する場所で、章の作業とは無関係に必ず変わります。/var/lib/dpkg/は package 操作で更新される管理情報で、package 差分として別に判定します。/var/lib/systemd/は service の状態管理で更新される場所で、service・unit の分類として別に判定します。/var/log/は操作の証跡そのもので、コマンドを実行するだけでも変わります。/var/cache/は再生成できる一時的な cache で、章の構築物とは分けて扱います。.bash_historyは操作履歴として更新されるため、章が配置したファイルとは分けて扱います。/etc/passwd-・group-・shadow-・gshadow-は shadow-utils が保持する前世代の自動バックアップです。本体は検査対象に残します。/var/lib/fwupd/と/etc/fwupd/は fwupd が生成・管理する機器メタデータと設定です。/var/lib/ucf/は ucf が生成する設定管理台帳です。/var/lib/landscape/は landscape が生成するシステム情報 cache です。/etc/ld.so.cacheは ldconfig が生成する共有ライブラリ cache です。/var/lib/snapd/state.json・/var/lib/snapd/maintenance.jsonと/var/lib/logrotate/statusは、snapd と logrotate が章と無関係に定期的に書き換える状態ファイルです。親ディレクトリは除外しません。
/var/lib/swcatalog/ は除外しません。既存 source を変更せず、章専用 source だけを指定して package を操作した結果まで検査するためです。
sudo find /etc /var/lib /srv /opt /usr/local "$HOME" \
-not -path '/var/lib/apt/*' \
-not -path '/var/lib/dpkg/*' \
-not -path '/var/lib/systemd/*' \
-not -path '/var/log/*' \
-not -path '/var/cache/*' \
-not -name '.bash_history' \
-not -path '/etc/passwd-' -not -path '/etc/group-' \
-not -path '/etc/shadow-' -not -path '/etc/gshadow-' \
-not -path '/var/lib/fwupd' -not -path '/var/lib/fwupd/*' \
-not -path '/etc/fwupd' -not -path '/etc/fwupd/*' \
-not -path '/var/lib/ucf' -not -path '/var/lib/ucf/*' \
-not -path '/var/lib/landscape' -not -path '/var/lib/landscape/*' \
-not -path '/etc/ld.so.cache' \
-not -path '/var/lib/ubuntu-release-upgrader/release-upgrade-available' \
-not -path '/var/lib/snapd/state.json' \
-not -path '/var/lib/snapd/maintenance.json' \
-not -path '/var/lib/logrotate/status' \
-printf '%p\t%y\t%m\t%U\t%G\t%s\t%T@\n' 2>/dev/null \
| LC_ALL=C sort \
| sudo tee /tmp/part4-manifest-after.tsv >/dev/null
sudo diff -u /tmp/part4-manifest-before.tsv /tmp/part4-manifest-after.tsv > /var/tmp/part4-chapter-manifest.diff
diff_rc=$?
if [ "$diff_rc" -le 1 ]; then
if awk 'BEGIN { FS = "\t"; c = "LC_ALL=C sort" }
/^\+\+\+|^---|^@@/ { next }
/^[+-]/ {
if (NF != 7) { printf "manifest の列数が 7 ではありません: %s\n", $0 > "/dev/stderr"; bad = 1; exit 2 }
t++; s = substr($1, 1, 1); p = substr($1, 2)
k = $2 " " $3 " " $4 " " $5 " " ($2 == "d" ? "-" : $6)
if (s == "-") { if (p in b) { dup = p; exit 2 } b[p] = k; bm[p] = $7; bt[p] = $2 }
else { if (p in a) { dup = p; exit 2 } a[p] = k; am[p] = $7 }
}
END {
if (bad) exit 2
if (dup != "") { printf "同じパスが重複しています: %s\n", dup > "/dev/stderr"; exit 2 }
print "== 残留候補(0 件で合格)=="
n = 0
for (p in b) {
if (!(p in a)) { print "消えた " p | c; n++ }
else if (a[p] != b[p]) { print "変わった " p " (" b[p] " -> " a[p] ")" | c; n++ }
}
for (p in a) if (!(p in b)) { print "増えた " p | c; n++ }
fflush(); close(c)
print "残留候補: " n " 件"
print "== 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)=="
m = 0; d = 0
for (p in b) if ((p in a) && a[p] == b[p] && am[p] != bm[p]) { if (bt[p] == "d") d++; else { print p | c; m++ } }
fflush(); close(c)
print "更新日時だけ変わった通常ファイル: " m " 件 / ディレクトリ: " d " 件"
print "差分行総数: " (t + 0)
}' /var/tmp/part4-chapter-manifest.diff; then
sudo rm -f /tmp/part4-manifest-before.tsv /tmp/part4-manifest-after.tsv
else
echo "差分の表示に失敗しました。構築前・章末の manifest と /var/tmp/part4-chapter-manifest.diff は残してあります。表示された理由を確認してください" >&2
fi
else
echo "差分を採れませんでした(diff の終了コード $diff_rc)。構築前 manifest /tmp/part4-manifest-before.tsv があるか確認してください" >&2
fi
sudo rm -f /var/tmp/part4-chapter-manifest.diff test ! -e /var/tmp/part4-chapter-manifest.diff
表示は 2 つの節に分かれます。合格の条件は、1 つ目の「残留候補」が 0 件であることです。残留候補は、構築前になかったパスが増えた、あったパスが消えた、種別・権限・所有者・サイズが変わった、のいずれかです(ディレクトリのサイズは比べません)。
2 つ目の「更新日時だけ変わった通常ファイル」は、中身の属性は同じで更新日時だけが違うファイルです。ここに出る行は 1 件ずつ、次のどちらかで説明できなければ完了にしません。
- OS の自動処理(package 索引の更新、通知用の記録など)が書いたもの。
- 章の操作の跡で、中身は戻っているもの。
どちらでも説明できない行が 1 件でもあれば、それは自分が挙げた 4 分類の外側にある残留です。何の分類にあたるかを考えて「5 番目の分類」を自分の後片付け手順へ足し、解消後に再確認してください。
(形の例です。M・K・T には実行したときの件数が入ります。更新日時だけ変わった通常ファイルの行と件数は、台・章によって変わります。合格の条件は「残留候補: 0 件」です) == 残留候補(0 件で合格)== 残留候補: 0 件 == 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)== (該当するパスが 1 行ずつ並びます) 更新日時だけ変わった通常ファイル: M 件 / ディレクトリ: K 件 差分行総数: T
この演習環境は、セッションが終わると基盤側の手順でも初期状態へ戻ります。ただし、それを当てにして終えるのではなく、自分が作ったものを自分で消し、DB・package / service・socket・データの 4 分類と manifest 差分で消えたことを確認するところまでが演習です。
つまずきポイント
ハマり #1 · 未 commit のまま接続を切ったら変更が消えた
transaction 中の UPDATE では残高が変わって見えたのに、再接続すると元の値へ戻っています。
確認順序
psqlの prompt が transaction 中を示していたか確認する。COMMITを実行した記録があるか確認する。- 再接続後に同じ
card_idを検索する。
- 原因: 接続終了時点で transaction が未確定だったため、PostgreSQL が自動的に rollback しました。
- 対処: 変更を残す処理では、関連する SQL がすべて成功した後に明示的に
COMMITします。途中失敗ならROLLBACKし、どちらで終えたかを曖昧にしません。
ハマり #2 · event の INSERT が一意制約(同じ値を 2 度入れさせない決まり)で拒否された
同じ event ID を再度 INSERT すると、重複を示すエラーになります。
確認順序
SELECTで同じevent_idがすでに存在するか確認する。\d tap_eventsで主キーまたは一意制約を確認する。- 直前の transaction が確定済みか確認する。
- 原因: 同じ event を二重に記録しない制約が働いています。
- 対処: 別 event なら新しい ID を使います。同じ要求の再送なら、既存行を成功結果として扱うか拒否するかをアプリ側で決めます。制約を削除して通してはいけません。
ハマり #3 · 待機中の接続が解放されない
片方の psql が長時間待ち続ける場合があります。
確認順序
- 先に開いた
psqlの prompt が transaction 中か確認する。 pg_stat_activityで待機中の接続を確認する。- 先行 transaction を
COMMITまたはROLLBACKで明示的に終了する。
- 原因: 先行 transaction が行ロックを保持したままです。
- 対処: 接続を無理に増やさず、先行 transaction の処理結果を確認して明示的に終了します。
AI に相談
修了確認
問 1 · transaction の境界を説明する
残高の UPDATE は成功しましたが、event の INSERT が一意制約で拒否されました。両方を同じ transaction で実行していた場合、残高を中途半端に残さないために何を確認し、どう終了しますか。
問 2 · 行ロックの待ちを切り分ける
右側の SELECT ... FOR UPDATE が返りません。PostgreSQL 停止や通信断と決めつける前に、どの順序で確認しますか。
問 3 · 確定済みと未確定を区別する
UPDATE の結果が画面に表示されました。これだけで残高が確定したと言えるでしょうか。合格に必要な観測を答えてください。
問 4 · 2 種類の rollback を区別する
SQL の ROLLBACK と演習環境の VM rollback は、何を戻す操作ですか。
段階ヒント(問 1〜問 4)
問 1 のヒントと答え
ヒント 1 段 · 軽く方向だけ
一意制約違反が起きた瞬間、その処理全体が確定前か確定後かに注目してください。
ヒント 2 段 · 確認するコマンド
同じ接続で transaction が失敗状態のままかを確認し、ROLLBACK を実行します。その後 SELECT で既存の event ID と残高を確認します。
答えを見る
transaction が失敗状態になっていることを確認し、ROLLBACK で全体を取り消します。残高更新と event 登録を同じ transaction に置くことで、片方だけを確定させません。再実行前に既存の event ID と残高を SELECT で確認します。
問 2 のヒントと答え
ヒント 1 段 · 軽く方向だけ
PostgreSQL の停止や通信断を疑う前に、同じ行を今誰が保持しているかに注目してください。
ヒント 2 段 · 確認する順序
同じ接続で簡単な問い合わせができるか、service と socket の状態を確認します。次に pg_stat_activity で左側の transaction が開いたままかを確認し、左側を COMMIT または ROLLBACK します。
答えを見る
まず同じ接続で単純な問い合わせができるか、service と socket が生きているかを確認します。次に左側で同じ行をロックした transaction が開いたままかを確認します。左側を COMMIT または ROLLBACK し、右側が返るなら行ロック待ちです。
問 3 のヒントと答え
ヒント 1 段 · 軽く方向だけ
画面に表示された結果が、どの接続から見えているかに注目してください。
ヒント 2 段 · 比べる観測点
別の接続を開いて SELECT で残高を確認します。COMMIT の前後、または接続を切断した後で値がどう変わるかを比べます。
答えを見る
言えません。COMMIT 前の変更は未確定です。別接続から確定済みの残高を確認し、COMMIT 後にだけ新しい値が見えること、または切断後に未確定の変更が残らないことを観測します。
問 4 のヒントと答え
ヒント 1 段 · 軽く方向だけ
SQL の ROLLBACK と VM rollback が、それぞれどの範囲を戻すかに注目してください。
ヒント 2 段 · 比べる状態
\d で schema を確認します。SQL の ROLLBACK の前後、および VM rollback の前後で、package・設定・schema・確定済みデータの状態がどこまで戻るかを比べます。
答えを見る
SQL の ROLLBACK は現在の未確定 transaction を取り消します。VM rollback は package、設定、schema、確定済みデータを含む VM 全体を基準状態へ戻します。目的と影響範囲が異なるため、同じ操作として扱いません。
入口とデータ層をつなぐ
Chapter 1 で入口を作り、本章で残高を守るデータ層を作りました。しかし、まだ HTTP の要求は DB の transaction へ届きません。次章では教材アプリを間に置き、ひとつの event ID を入口・アプリ・DB の記録から追えるようにします。
Chapter 3: Web、アプリ、DB をつないで、タップを処理する
- 教材アプリを導入して systemd service として動かす
- アプリから PostgreSQL へ接続する
- 同じ event ID を入口・アプリ・DB で照合する