演習進捗0 / 5未完了
⏱ 約 90 分
Part 4Chapter 03/サーバ構築

Web、アプリ、DB をつないで、タップを処理する

Web、アプリ、DB を接続し、1 件のタップ要求を同じ event ID で 3 層の記録から追跡します。

教材 ✓ 公開中登録不要 実機演習 ― 提供していない読んで学べます
所要時間約 90 分(概念 15 分+演習 75 分)
前提知識Part 1 の設定ファイルと権限、Part 2 の名前解決・port・経路、Part 3 の systemd・journal、Part 4 Chapter 1・2 の概念
使用機材Web サーバ front01、アプリサーバ app01、DB サーバ db01、操作端末 workstation01、LAN 内の教材取得経路
関連章前: Part 4 / Chapter 2「PostgreSQL を用意し、残高を安全に更新する」/後: Part 4 / Chapter 4「2 台へ振り分け、故障した 1 台を経路から外す」

3 台を 1 件の要求で貫く

入口となる nginx と、残高を守る PostgreSQL を個別に理解しても、まだ利用者の要求はデータまで届きません。本章では Web、アプリ、DB を別々の VM に用意し、タップ要求が 3 層を通って処理される経路を組み立てます。

各章は初期状態から独立して成立します。そのため、本章でも nginx と PostgreSQL を短縮版の手順で導入します。Chapter 1 と同じ入口の詳しい理由、Chapter 2 と同じ DB 制約(不正な値を DB が拒否する決まり)の詳しい理由は、それぞれの章を参照してください。

期待される出力
workstation01
      │ event ID 付き HTTP 要求
      ▼
   front01                Web 層
      │ proxy
      ▼
    app01                 App 層
      │ transaction
      ▼
     db01                 DB 層
      ├─ cards
      └─ tap_events

3 台では、同じ要求が同じ形で記録されるとは限りません。入口では HTTP access log、アプリでは journal、DB では行と残高として残ります。そこで event ID(1 回のタップ処理を見分ける識別子)を共通の検索キーにし、離れた記録を 1 件の処理として結びます。

この章のゴール: Web、アプリ、DB を接続し、1 件のタップ要求を同じ event ID で 3 層にわたって追跡できる。再送と接続障害を起こし、DB の制約と各層の記録から結果を説明できる。

この章でつなぐ入口・App(タップを処理するアプリケーション)・DB は、街の改札サービスの 3 つの役割にあたります。街の全体像と、街の言葉・技術の言葉の対応は Part 0(この教材で作る街)にまとめています。

扱わない範囲

本章では、次の範囲は扱いません。

この章を終えるとできること

サービスを構成する 3 層を接続し、正常時と故障時の両方を記録から説明できるようになります。本章で身につく 4 動作 は次のとおりです。

01
① 3 層を用意してつなぐ
nginx、教材アプリ、PostgreSQL を導入し、接続先を設定ファイルで結ぶ。
02
② 1 件の要求を追う
event ID を検索キーにして、access log、app journal、DB の行と残高を照合する。
03
③ 同じ要求を再送する
同じ event ID を再度送り、残高が二重に減らないことと、拒否した層を記録で確かめる。
04
④ 故障した層を切り分ける
入口から DB までを順に観測し、到達記録が無いことも証拠として使う。

1 件の要求を層ごとに観測する

1. 3 層は別のプロセスとして動く

Web、アプリ、DB は、役割だけでなくプロセスも VM も分かれています。Web 層は HTTP 要求を受けて App 層へ渡し、App 層は入力を解釈して DB transaction(複数の更新を一体として確定する仕組み)を実行し、DB 層は制約を守りながら状態を確定します。

この分割により、入口が応答したことだけでは DB 更新の成功を証明できません。反対に、DB が動作中でも App 層の接続設定が誤っていれば、要求は届きません。「どのプロセスまで届いたか」を層ごとに確かめます。

期待される出力
要求の進行       主な観測場所
──────────       ──────────
Web が受信       nginx access log
App が処理       systemd journal
DB が確定        tap_events と cards

2. event ID は層をまたぐ検索キー

時刻だけで 3 台の記録を照合すると、近い時刻に届いた別の要求を取り違えることがあります。event ID を要求へ付け、Web 層から App 層へ引き継ぎ、DB の tap_events に保存すれば、同じ ID で記録を串刺しにできます。

event ID は「何時ごろの要求か」ではなく、「どの 1 件か」を示します。本章では正常時、再送時、故障時に同じ検索方法を使い、記録がある層と無い層を比較します。

3. upstream は転送先であり、死活監視ではない

nginx の upstream(転送先の一覧)と proxy_pass(要求を転送先へ渡す指示)は、受け取った要求をどこへ渡すかを決める設定です。設定ファイルに転送先を書いたことは、その App が現在正常である証明にはなりません。

構文検査に成功しても、転送先のプロセス停止、待受 port の不一致、App から DB への接続失敗は起こり得ます。nginx -t、HTTP 応答、App journal、DB の記録は、それぞれ異なる問いへの確認です。

4. 二重処理を最後に止めるのは DB の制約

同じ event ID を登録前に検索する実装もありますが、同時に 2 件が到着すると、両方が「まだ無い」と判断する隙間が残ります。本章の教材アプリは事前検索を持たず、tap_events への INSERT が一意制約(同じ値を 2 度入れさせない決まり)違反になった時点で再送と判定し、HTTP 409(要求が今の状態と衝突したことを示す応答番号)を返します。tap_events.event_id の主キー(各行を見分けるための列)は、同時要求でも 2 件目の確定を拒否します。

事前検索を追加しても同時要求の隙間は閉じられず、最後に閉じられるのは DB の一意制約だけです。そのため本章では防波堤を 2 つ置かず、重複判定を DB の制約へ集約しています。再送後は HTTP 409 と、DB の行数・残高が変わらないことを組み合わせて確認します。

5. HTTP status は、要求が止まった層を絞る手掛かり

教材アプリが返す主な HTTP status は次のとおりです。入口が App の応答を受け取れた場合、その status は入口を経由して受講者へ返ります。

status教材アプリでの意味
400body(要求に付けて送るデータ本体)の必須項目や型が正しくない
402残高が不足している。App の事前判定に加え、DB の CHECK 制約も負の残高を防ぐ
404card が存在しない、または要求した path(URL のうち場所を示す部分)が存在しない
409event ID が重複し、DB の一意制約が再送を拒否した
503App が DB へ到達できず、処理を継続できない

status だけで原因を断定せず、nginx access log、App journal、DB の記録と組み合わせて、最後に応答した層を確認します。

📔 コラム · 演習環境の初期化

この演習環境は、セッションが終わると基盤側の仕組みで初期状態へ戻ります。受講者が管理基盤を操作する必要はありません。本章の最後では、構築内容を理解した証拠として、自分で追加した service、package、DB を削除し、消えたことまで確認します。

演習:Web、アプリ、DB を接続する

演習環境

項目値
構築対象front01、app01、db01
操作端末workstation01
接続方法workstation01 から各 VM の接続名を使って SSH 接続
必要権限sudo。package 導入、設定ファイル配置、service 制御、DB 管理に使う
取得経路演習環境で指定された LAN 内ミラーと教材配布元
所要時間約 75 分(STEP 1〜8 の実行)

🛟 失敗しても安全: STEP 7 で変更するのは App の DB 接続先だけです。STEP 8 で元の値へ戻して正常応答を確認し、その後に本章で追加したものを削除します。セッション終了時には演習環境も初期状態へ戻ります。

コマンド中の山括弧で囲んだ値は、演習環境または教材の案内に表示された値へ置き換えてください。接続先、service 名、設定ファイル、API(プログラム同士が要求と応答をやり取りする窓口)の形式は、演習環境ごとに異なります。

ほかの操作を始める前に、db01・app01・front01 の各 VM へ順に接続し、構築前の manifest(ファイルの場所・権限・更新日時などを一覧にしたもの)を採ります。対象は path・type・mode・uid・gid・size・mtime です。各 VM では、次のブロックを本章のほかの操作より先に実行してください。

bash · 構築前 manifest
# db01・app01・front01 の各 VM で、章のほかの操作より先に実行する
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

DB 層を用意して最小権限を与える

db01 へ接続します。既存 source は変更せず、章専用ディレクトリへ署名済み内部 APT リポジトリを設定します。鍵の fingerprint を使い捨ての GnuPG home で照合し、apt-get には章専用 source と索引を掃除しない指定を渡して PostgreSQL を導入します。

bash · コマンド
ssh deployer@db01
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
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"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
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
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 apt-get "${APT_ONLY_PART4[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y postgresql postgresql-client
systemctl status postgresql --no-pager
期待される出力
  % 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
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]

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 02:01:31 UTC; 8s ago
   Main PID: 2638 (code=exited, status=0/SUCCESS)
        CPU: 2ms

以降の期待出力にある日付・時刻・PID・経過時間は一例です。実行環境によって変わります。

演習用 DB、アプリ専用ユーザ、残高表、タップ履歴表を作ります。管理用ユーザを App の接続へ流用せず、App に必要な操作だけを許可します。

bash · コマンド
sudo -u postgres createuser --login tap_app
sudo -u postgres psql -c '\password tap_app'
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);

GRANT CONNECT ON DATABASE transit_lab TO tap_app;
GRANT USAGE ON SCHEMA public TO tap_app;
GRANT SELECT, UPDATE ON cards TO tap_app;
GRANT INSERT ON tap_events TO tap_app;

\d cards
\d tap_events
\dp cards
\dp tap_events
SQL
期待される出力
role password: 設定済み(値は表示しない)
CREATE TABLE
CREATE TABLE
INSERT 0 1
GRANT
GRANT
GRANT
GRANT
                                 Access privileges
 Schema | Name  | Type  |     Access privileges     | Column privileges | Policies
--------+-------+-------+---------------------------+-------------------+----------
 public | cards | table | postgres=arwdDxt/postgres+|                   |
        |       |       | tap_app=rw/postgres       |                   |
(1 row)

                                   Access privileges
 Schema |    Name    | Type  |     Access privileges     | Column privileges | Policies
--------+------------+-------+---------------------------+-------------------+----------
 public | tap_events | table | postgres=arwdDxt/postgres+|                   |
        |            |       | tap_app=a/postgres        |                   |
(1 row)

  card_id  | balance
-----------+---------
 card-demo |    1000
(1 row)

App からの接続を受ける設定を行います。まず PostgreSQL が実際に参照する設定ファイルを取得し、編集対象を推測しないようにします。接続元は app01 だけに絞ります。

bash · コマンド
PG_CONFIG="$(sudo -u postgres psql -Atc 'SHOW config_file;')"
PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')"
printf '%s\n' "$PG_CONFIG" "$PG_HBA"
sudoedit "$PG_CONFIG"
sudoedit "$PG_HBA"

PG_CONFIG が示すファイルでは listen_addresses(DB が接続を待つアドレスの設定)を db01 の接続用アドレスへ変更し、log_connections = on(接続を記録する設定)を設定します。後の故障切り分けで「DB への接続記録が無い」ことを証拠にするため、正常時の接続が記録される状態を先に作ります。PG_HBA が示すファイル(pg_hba.conf。接続を許可する条件を書いた行が入る)には、app01 の接続元アドレスから tap_app が transit_lab へ接続する 1 行だけを追加し、認証方式は演習環境の指定に従います。

bash · コマンド
sudo -u postgres psql -c 'SELECT pg_reload_conf();'
sudo -u postgres psql -Atc 'SHOW listen_addresses;'
sudo -u postgres psql -Atc 'SHOW log_connections;'
sudo ss -lntp | grep postgres

pg_reload_conf() が t を返すのは、設定の再読み込み要求を受け付けたという意味です。すべての設定が反映されたことまでは示しません。多くの設定は reload で反映できますが、listen_addresses は PostgreSQL の起動時にだけ読み込まれます。上の SHOW と ss で期待するアドレスへ変わっていなければ、restart が必要だと判定します。

reload は既存の接続を保ったまま反映できる一方、restart は接続を切ります。運用中に両者を使い分けるため、まず表示を確認してから restart し、listen_addresses、log_connections、待受 socket の 3 つの観測で反映を確かめます。

bash · コマンド
sudo systemctl restart postgresql
sudo -u postgres psql -Atc 'SHOW listen_addresses;'
sudo -u postgres psql -Atc 'SHOW log_connections;'
sudo ss -lntp | grep postgres
期待される出力
/etc/postgresql/16/main/postgresql.conf
/etc/postgresql/16/main/pg_hba.conf
60:listen_addresses = '172.16.10.24'
host    replication     all             ::1/128                 scram-sha-256
host transit_lab tap_app 172.16.10.20/32 scram-sha-256
 pg_reload_conf
----------------
 t
(1 row)

localhost
on
LISTEN 0      200        127.0.0.1:5432      0.0.0.0:*    users:(("postgres",pid=3553,fd=7))
LISTEN 0      200            [::1]:5432         [::]:*    users:(("postgres",pid=3553,fd=6))
172.16.10.24
on
LISTEN 0      200     172.16.10.24:5432      0.0.0.0:*    users:(("postgres",pid=4282,fd=6))

tap_app は schema(テーブルや制約などの DB の構造)を変更できません。cards は参照と更新、tap_events は追加だけに絞っています。教材アプリは tap_events を参照せず、再送を INSERT の一意制約違反で判定するためです。アプリが使わない権限を与えないことが、「アプリが使うユーザ」と「管理用ユーザ」を分ける理由です。

App 層へ教材資産を配置する

別のターミナルから app01(このラボでの接続名は learner01)へ接続します。この接続だけで完結するように、公開鍵の取得、使い捨て GnuPG home での fingerprint 照合、章専用 source の作成、限定した apt-get の実行までを同じブロックに含めます。

bash · コマンド
ssh deployer@learner01
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
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"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
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
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-app01.log
grep -E '^(Get|Hit):' /tmp/apt-update-app01.log
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y python3-psycopg2

過去の実測(2026-08-24〜25・旧 remove 手順)

期待される出力
keyring: あり
--- 配置物 ---
-rw-r--r-- 1 root root  400  8月 24 23:02 /etc/systemd/system/tapapi.service

/etc/tapapi/:
合計 4
-rw-r----- 1 root tapapi 200  8月 24 23:02 tapapi.env

/opt/tapapi/:
合計 12
-rw-r--r-- 1 root root  373  8月 24 23:02 schema.sql
-rwxr-xr-x 1 root root 7320  8月 24 23:02 tapapi.py
--- 実行ユーザ ---
uid=996(tapapi) gid=986(tapapi) groups=986(tapapi)

配置前は keyring: 無い。導入後に あり へ変わる。tapapi.env だけが root:tapapi 0640=秘密を持つファイルだけ権限が違う。

📔 コラム · なぜ pip を使わないのか Python のライブラリは pip install で入れる方法もあります。しかし pip は外部のパッケージ配布サイトへ取りに行くため、この環境では成立しません。ここで使う python3-psycopg2 は OS の package として LAN 内ミラーから取得できるので、apt で入れます。 「手元で動いた手順が、閉じたネットワークでは動かない」ことは実務でもよく起きます。取得元がどこかを意識するのが、この STEP の裏側の主題です。

導入できたことを、アプリを動かす前に確認します。

bash · コマンド
python3 -c 'import psycopg2; print(psycopg2.__version__)'
期待される出力
import psycopg2: OK

続いて、教材が配布するアプリケーション本体を内部 HTTP から取得します。取得できたことは、正しいものを取得できたことを意味しません。第 1 章の公開鍵 fingerprint 照合と同じように、展開前に別経路で提示された SHA-256(内容から計算する固定長の値。1 バイト違えば別の値になる)と照合します。

bash · コマンド
TAPAPI_ARCHIVE=/tmp/tapapi-1.1.tar.gz
TAPAPI_SHA256=8a4e518211fc661f6fdcc0b2dd45be50eb1a199b36eaeb8db1b0a985a4eb2a9e
curl --fail --show-error --location \
  --output "$TAPAPI_ARCHIVE" \
  "http://172.16.10.22/assets/part4/tapapi-1.1.tar.gz"
printf '%s  %s\n' "$TAPAPI_SHA256" "$TAPAPI_ARCHIVE" | sha256sum --check -
test "$(stat -c %s "$TAPAPI_ARCHIVE")" -eq 2728
tar -tzf "$TAPAPI_ARCHIVE"

sha256sum が OK を返し、サイズが 2728 bytes、一覧が tapapi.py、tapapi.service、tapapi.env.example、schema.sql の 4 ファイルであることを確認してから配置します。

bash · コマンド
TAPAPI_TMP="$(mktemp -d)"
tar -xzf "$TAPAPI_ARCHIVE" -C "$TAPAPI_TMP"
id tapapi >/dev/null 2>&1 || \
  sudo useradd --system --home-dir /opt/tapapi --shell /usr/sbin/nologin tapapi
sudo install -d -o root -g root -m 0755 /opt/tapapi /etc/tapapi
sudo install -o root -g root -m 0755 "$TAPAPI_TMP/tapapi/tapapi.py" /opt/tapapi/tapapi.py
sudo install -o root -g root -m 0644 "$TAPAPI_TMP/tapapi/schema.sql" /opt/tapapi/schema.sql
sudo install -o root -g root -m 0644 "$TAPAPI_TMP/tapapi/tapapi.service" /etc/systemd/system/tapapi.service
sudo install -o root -g tapapi -m 0640 "$TAPAPI_TMP/tapapi/tapapi.env.example" /etc/tapapi/tapapi.env
rm -rf "$TAPAPI_TMP"

配置後、/etc/tapapi/tapapi.env を開きます。DB の host、port、DB 名、接続ユーザを設定し、接続のパスワード(PGPASSWORD)には、前の手順の \password tap_app で自分で決めた値を書きます。このファイルは root と tapapi グループだけが読める権限(0640)で置いてあるので、パスワードはこのファイルにだけ書き、コマンドの引数や画面・証跡には出しません。アプリコードは編集しません。

bash · コマンド
sudoedit /etc/tapapi/tapapi.env
sudo systemctl daemon-reload
sudo systemctl start tapapi.service
systemctl status tapapi.service --no-pager
sudo journalctl -u tapapi.service --no-pager -n 30
期待される出力
# Copy to /etc/tapapi/tapapi.env and replace the placeholders on each host.
TAPAPI_NODE=app01
PGHOST=172.16.10.24
PGPORT=5432
PGDATABASE=transit_lab
PGUSER=tap_app
PGPASSWORD=***
PGCONNECT_TIMEOUT=3
● tapapi.service - Part 4 transit-card tap API
     Loaded: loaded (/etc/systemd/system/tapapi.service; disabled; preset: enabled)
     Active: active (running) since Tue 2026-08-18 11:05:01 JST; 2s ago
   Main PID: 2160 (python3)
      Tasks: 1 (limit: 2255)
     Memory: 10.9M (peak: 11.2M)
        CPU: 64ms
     CGroup: /system.slice/tapapi.service

DB 接続先をコードへ埋め込まず、設定ファイルへ分離した点を確認してください。PGCONNECT_TIMEOUT=3 は、後の STEP で到達不能な接続先を試したとき、入口側の timeout より先に App が DB 接続失敗を検出して 503 を返すための上限です。後の STEP では接続先だけを変え、この値は維持します。

App から DB までを 4 つに分けて確認する

App と DB の接続を、1 回の「成功」で済ませず 4 つの問いに分けます。

確認 1 · DB port へ到達できるか

app01 から db01 の PostgreSQL port へ TCP 接続できるかを確認します。

bash · コマンド
nc -vz "<db01の接続名>" "<PostgreSQLの待受port>"

実測(2026-08-24〜25)

期待される出力
Connection to <db01のアドレス> 5432 port [tcp/postgresql] succeeded!

ここで分かるのは TCP の到達だけです。DB ユーザの認証、schema の存在、App の処理成功はまだ証明していません。

確認 2 · App の health が正常か

app01 で、App 自身の待受へ /health(App 自身が状態を答える確認用の URL)を要求し、プロセスと教材が定める health 判定を確認します。

bash · コマンド
curl --fail --show-error --include "http://localhost:8080/health"
期待される出力
{"status":"ok","db":"ok","node":"app01"}

応答が 503 だったとき

db01 で sudo tail -n 20 /var/log/postgresql/postgresql-16-main.log を見ます。password authentication failed for user "tap_app" が出ていれば、app01 の /etc/tapapi/tapapi.env の PGPASSWORD が 1 行だけで、STEP 1 の \password tap_app で決めた値と同じかを確かめます。

確認 3 · DB 側から接続元が見えるか

db01 に戻り、PostgreSQL が現在認識している接続を確認します。

bash · コマンド
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT usename, datname, application_name, client_addr, state
     FROM pg_stat_activity
    WHERE usename = 'tap_app';"
期待される出力
 usename | datname | application_name | client_addr | state
---------+---------+------------------+-------------+-------
(0 rows)

0 行が返ります。失敗ではありません。 教材アプリは要求を 1 件処理するたびに DB へ接続し、処理が終わると閉じます。要求が流れていない間、tap_app の接続は存在しません。pg_stat_activity が映すのは「いまこの瞬間」の接続なので、何も流れていないときに覗けば 0 行になります。

接続元を見るには、App へ要求を流し続けながら、DB 側の観測を繰り返します。 片方だけでは足りません。要求を流していても、観測が 1 回きりなら、その 1 回が接続の無い瞬間に当たります。

bash · コマンド
# app01 側: 要求を流し続ける
timeout 20 bash -c 'while :; do curl -sS -m5 "http://localhost:8080/health" > /dev/null; done'
bash · コマンド
# db01 側: 上と並行して、観測を繰り返す
for i in $(seq 1 300); do
  sudo -u postgres psql -d transit_lab -Atc \
    "SELECT usename, datname, application_name, client_addr, state
       FROM pg_stat_activity
      WHERE usename = 'tap_app';"
done | grep -m 5 tap_app

実測(2026-08-24〜25)

app01 側で timeout 20 bash -c 'while :; do curl -sS -m5 "http://localhost:8080/health" > /dev/null; done' を流しながら、db01 側で観測を繰り返した。

期待される出力
tap_app|transit_lab||<app01のアドレス>|idle in transaction
tap_app|transit_lab||<app01のアドレス>|idle
tap_app|transit_lab||<app01のアドレス>|idle in transaction
tap_app|transit_lab||<app01のアドレス>|idle
tap_app|transit_lab||<app01のアドレス>|idle

client_addr に App の接続元が、state に idle と idle in transaction の行き来が出ます。application_name は空です。アプリが名乗っていないためで、App を 2 台に増やす Chapter 4 では、ここに名前が入っていると「どちらの台からの接続か」を DB 側だけで判別できます。

短命な接続は「見えない」のではなく「見える時間が短い」だけです。 観測できなかったことを、接続していないことの証拠にはできません。

確認 4 · タップ API が 1 transaction を完了するか

app01 から POST /tap(POST は値を送信する要求)を 1 回だけ呼びます。body の card_id と event_id は非空文字列、amount は正の整数が必須です。X-Event-ID は nginx と App の記録を同じ ID で追うためにも付け、body の event_id と同じ値にします。応答 JSON には要求を処理した node が入ります。App と DB の接続確認には GET /health(GET は読み取るだけの要求)も使えます。

bash · コマンド
EVENT_ID="event-tap-first"
curl --fail --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":120}" \
  "http://localhost:8080/tap"
期待される出力
HTTP/1.0 200 OK
Server: tapapi
Date: Tue, 18 Aug 2026 02:05:22 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 91

{"ok":true,"card_id":"card-demo","balance":880,"event_id":"event-tap-first","node":"app01"}

必須項目を欠くと処理は始まりません。たとえば amount を省いた要求では HTTP 400 と error: bad_request、detail: amount must be a positive integer を含む JSON が返ります。--fail を付けると body の確認前に失敗扱いになるため、この否定試験では付けません。

bash · コマンド
EVENT_ID="event-tap-missing-amount"
curl --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\"}" \
  "http://localhost:8080/tap"

db01 で event と残高が同じ transaction の結果として確定したことを確認します。

bash · コマンド
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT event_id, card_id, amount FROM tap_events WHERE event_id = 'event-tap-first';"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"

実測(2026-08-24〜25)

期待される出力
    event_id     |  card_id  | amount
-----------------+-----------+--------
 event-tap-first | card-demo |    120
(1 row)

  card_id  | balance
-----------+---------
 card-demo |     880
(1 row)

この時点の残高は、直前までに実行した操作の結果を引き継いでいます。 card-demo は本章を通して使う共有カードのため、表示される値は実行順序によって変わります。

port、health、接続元、transaction 結果は別の確認です。1 つ通ったから全部通るのではなく、接続経路を 1 区間ずつ延ばしています。

Web 層から App 層へ要求を渡す

front01 へ接続し、nginx の入口を用意します。第 1 章は章末に source と keyring を削除するため、本章でも公開鍵の取得から章専用 source の作成までを明示し、この接続だけで完結させます。

bash · コマンド
ssh deployer@front01
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" -o /tmp/infra-study-archive-keyring.asc
GPG_HOME="$(mktemp -d)"
chmod 700 "$GPG_HOME"
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"
test "$fingerprint_status" -eq 0
sudo install -d -m 0755 /etc/apt/keyrings /etc/apt/sources.list.d.part4
sudo install -o root -g root -m 0644 /tmp/infra-study-archive-keyring.asc /etc/apt/keyrings/infra-study-archive-keyring.asc
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
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 apt-get "${APT_ONLY_PART4[@]}" update
sudo DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" install -y nginx
systemctl status nginx --no-pager
期待される出力
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]
No user sessions are running outdated binaries.

No VM guests are running outdated hypervisor (qemu) binaries on this host.
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-08-18 02:05:59 UTC; 2s ago
       Docs: man:nginx(8)
    Process: 1499 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS)

nginx の章専用設定を作り、App の転送先と event ID の引き継ぎを設定します。access log に event ID を含め、後で同じ要求を検索できるようにします。<入口の待受port> は 8081 を使います(nginx の既定サイトが使う 80 と重ならない値です。第 4 章でも同じ値を使います)。

nginx · コマンド
log_format tap_with_event
    '$remote_addr $request status=$status event_id=$http_x_event_id '
    'upstream=$upstream_addr upstream_status=$upstream_status';

upstream tap_app_backend {
    server learner01:8080;  # app01 の実体(このラボでの接続名)
}

server {
    listen <入口の待受port>;
    access_log /var/log/nginx/tap_access.log tap_with_event;

    location / {
        proxy_set_header X-Event-ID $http_x_event_id;
        proxy_set_header Host $host;
        proxy_pass http://tap_app_backend;
    }
}

設定を教材指定の章専用ファイルへ保存し、有効化します。構文検査に成功してから reload してください。

bash · コマンド
sudoedit /etc/nginx/sites-available/part4-ch3
sudo ln -s /etc/nginx/sites-available/part4-ch3 /etc/nginx/sites-enabled/part4-ch3
sudo nginx -t
sudo systemctl reload nginx
systemctl status nginx --no-pager
期待される出力
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
● nginx.service - A high performance web server and a reverse proxy server
     Loaded: loaded (/usr/lib/systemd/system/nginx.service; enabled; preset: enabled)
     Active: active (running) since Tue 2026-08-18 02:05:59 UTC; 15s ago
       Docs: man:nginx(8)
LISTEN 0      511          0.0.0.0:80        0.0.0.0:*    users:(("nginx",pid=1684,fd=5),("nginx",pid=1535,fd=5),("nginx",pid=1532,fd=5))
LISTEN 0      511          0.0.0.0:8081      0.0.0.0:*    users:(("nginx",pid=1684,fd=12),("nginx",pid=1532,fd=12))
LISTEN 0      511             [::]:80           [::]:*    users:(("nginx",pid=1684,fd=6),("nginx",pid=1535,fd=6),("nginx",pid=1532,fd=6))

nginx -t は構文と参照関係を検査し、reload は動作中の nginx へ設定を反映します。検査と反映は別の操作です。また、どちらも App の正常性までは保証しません。

event ID で 3 層の証跡を横断する

workstation01 から入口へ、新しい event ID を付けたタップ要求を送ります。ここからは 1 件の要求を 3 台で追います。

bash · コマンド
EVENT_ID="event-tap-goal"
curl --fail --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":120}" \
  "http://<front01の接続名>:<入口の待受port>/tap"
期待される出力
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Tue, 18 Aug 2026 02:06:27 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 90
Connection: keep-alive

{"ok":true,"card_id":"card-demo","balance":760,"event_id":"event-tap-goal","node":"app01"}

最初に front01 の access log を検索します。

bash · コマンド
sudo grep -F 'event_id=event-tap-goal' /var/log/nginx/tap_access.log
期待される出力
172.16.10.22 POST /tap HTTP/1.1 status=200 event_id=event-tap-goal upstream=172.16.10.20:8080 upstream_status=200

次に app01 の journal から同じ ID を検索します。

bash · コマンド
sudo journalctl -u tapapi.service --no-pager \
  | grep -F 'event-tap-goal'
期待される出力
 8月 18 11:06:27 learner01 python3[2160]: {"event_id":"event-tap-goal","card_id":"card-demo","amount":120,"node":"app01","status":200,"result":"ok"}

最後に db01 で event 行と残高を検索します。

bash · コマンド
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT event_id, card_id, amount, created_at
     FROM tap_events
    WHERE event_id = 'event-tap-goal';"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"
期待される出力
    event_id    |  card_id  | amount
----------------+-----------+--------
 event-tap-goal | card-demo |    120
(1 row)

  card_id  | balance
-----------+---------
 card-demo |     760
(1 row)

log_connections の陽性対照もここで確認します。正常な要求では、App が tap_app として transit_lab へ接続した記録が PostgreSQL の log に残ります。後で「記録が無い」ことを判断するときは、この正常時の 1 行と比較します。

bash · コマンド
sudo grep 'connection authorized: user=tap_app database=transit_lab' /var/log/postgresql/postgresql-16-main.log | tail -n 1
期待される出力
... connection authorized: user=tap_app database=transit_lab ...

3 つの出力を並べてください。nginx では要求と upstream の結果、App では処理判断、DB では確定した event と残高という別々の形で残ります。形が違っても、同じ event ID によって 1 件の要求として結びつきます。

ここまで確認 ✓ ① Web、App、DB の 3 層を接続できた / ② 入口から送った 1 件を同じ event ID で 3 層の記録から追えた / ③ HTTP 応答だけでなく DB の確定結果まで確認できた。

同じ event ID を再送する

STEP 5 とまったく同じ event ID と要求をもう一度送ります。まず db01 で、再送前の残高を記録します。

bash · コマンド
sudo -u postgres psql -d transit_lab -Atc \
  "SELECT balance FROM cards WHERE card_id = 'card-demo';"
期待される出力
760
bash · コマンド
EVENT_ID="event-tap-goal"
curl --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":120}" \
  "http://<front01の接続名>:<入口の待受port>/tap"
期待される出力
HTTP/1.1 409 Conflict
Server: nginx/1.24.0 (Ubuntu)
Date: Tue, 18 Aug 2026 02:06:50 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 81
Connection: keep-alive

{"ok":false,"error":"duplicate_event","event_id":"event-tap-goal","node":"app01"}

再送後に、まず db01 で同じ event ID の行数と残高を確認します。

bash · コマンド
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT event_id, count(*) FROM tap_events
    WHERE event_id = 'event-tap-goal' GROUP BY event_id;"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"
期待される出力
    event_id    | count
----------------+-------
 event-tap-goal |     1
(1 row)

  card_id  | balance
-----------+---------
 card-demo |     760
(1 row)

続けて app01 の journal で、再送時の応答を確認します。

bash · コマンド
sudo journalctl -u tapapi.service --no-pager \
  | grep -F 'POST /tap' | tail -n 2
期待される出力
 8月 18 11:06:27 learner01 python3[2160]: {"event_id":"event-tap-goal","card_id":"card-demo","amount":120,"node":"app01","status":200,"result":"ok"}
 8月 18 11:06:49 learner01 python3[2160]: {"event_id":"event-tap-goal","card_id":"card-demo","amount":120,"node":"app01","status":409,"result":"duplicate_event"}

教材アプリは事前検索を行いません。2 回目も残高行を lock して更新を試みますが、同じ event ID の INSERT が一意制約違反になり、transaction 全体を rollback して HTTP 409 を返します。App journal では POST /tap の 409 を確認し、event ID は直前の HTTP 応答と nginx access log で対応づけます。DB の event 行が 1 件のままで、残高が再送前から変わらないことが rollback の最終結果です。

DB 接続先を誤らせて、無い記録を証拠にする + GOAL 🎯

正常時の記録を保存した後、app01 の DB host だけを一時的に到達不能な値へ変えます。変更前ファイルを退避し、戻せる状態を作ってから進めます。STEP 03 で設定した PGCONNECT_TIMEOUT=3 は変えません。

bash · コマンド
APP_ENV="/etc/tapapi/tapapi.env"
APP_SERVICE="tapapi.service"
sudo cp "$APP_ENV" /tmp/ch3-app-env.before-failure
date -u '+%Y-%m-%d %H:%M:%S UTC' | tee /tmp/ch3-failure-since
sudoedit "$APP_ENV"
sudo grep -E '^(PGHOST|PGCONNECT_TIMEOUT)=' "$APP_ENV"
sudo systemctl restart "$APP_SERVICE"
期待される出力
PGHOST=192.0.2.10
PGCONNECT_TIMEOUT=3

環境ファイルでは PGHOST=192.0.2.10 だけを故障用の到達不能値へ変更し、PGCONNECT_TIMEOUT=3 を維持します。これにより、nginx の待受 timeout より先に App が DB 接続失敗を検出し、503 を返せます。restart 後、workstation01 から新しい event ID で入口へ要求を送ります。

bash · コマンド
EVENT_ID="event-tap-broken"
curl --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":120}" \
  "http://<front01の接続名>:<入口の待受port>/tap"
期待される出力
HTTP/1.1 503 Service Unavailable
Server: nginx/1.24.0 (Ubuntu)
Date: Tue, 18 Aug 2026 02:07:17 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 52
Connection: keep-alive

{"ok":false,"error":"db_unavailable","node":"app01"}

入口から奥へ向かって記録を確認します。まず front01 では、要求を受けたことと App から受け取った status を見ます。この演習では App が 3 秒以内に DB 接続失敗を検出して 503 を返し、nginx が同じ 503 を中継することを確認します。502 なら nginx から App へ接続できていません。PGCONNECT_TIMEOUT を入れ忘れた場合は504になる可能性もあるため、その場合は先へ進まず App の環境ファイルと再起動結果を確認します。

bash · コマンド
sudo grep -F 'event_id=event-tap-broken' /var/log/nginx/tap_access.log
期待される出力
172.16.10.22 POST /tap HTTP/1.1 status=503 event_id=event-tap-broken upstream=172.16.10.20:8080 upstream_status=503

次に app01 で service 状態と DB 接続エラーを確認します。

bash · コマンド
systemctl status tapapi.service --no-pager
sudo journalctl -u tapapi.service --no-pager \
  --since "$(cat /tmp/ch3-failure-since)"
期待される出力
 8月 18 11:07:16 learner01 python3[2623]: {"event_id":"event-tap-broken","card_id":"card-demo","amount":120,"node":"app01","status":503,"result":"db_unavailable"}

最後に db01 で、故障を起こした時刻以降の接続記録と event 行を確認します。FAILURE_SINCE には、故障を起こしたときに app01 で表示した時刻(/tmp/ch3-failure-since の中身。例: 2026-09-25 10:27:12 UTC)を、UTC まで含めてそのまま貼ります。

bash · コマンド
FAILURE_SINCE="<app01で記録した故障開始時刻>"
printf '故障開始: %s\n' "$FAILURE_SINCE"
LOG=/var/log/postgresql/postgresql-16-main.log
SINCE="${FAILURE_SINCE% UTC}"
sudo awk -v s="$SINCE" 'substr($0,1,19) >= s && /connection authorized: user=tap_app database=transit_lab/' "$LOG"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT count(*) AS rows_for_broken_event FROM tap_events WHERE event_id = 'event-tap-broken';"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"
期待される出力
 rows_for_broken_event
-----------------------
                     0
(1 row)

  card_id  | balance
-----------+---------
 card-demo |     760
(1 row)

接続 log の末尾が故障開始時刻より前で止まり、故障開始後の tap_app@transit_lab が無いことを確認します。DB に event 行が無いだけなら、transaction が取り消された可能性もあります。今回は正常時には残った接続記録も故障開始後には無く、入口には要求があり、App には DB 接続エラーがあります。この組み合わせにより、要求は Web 層へ到着し、App 層で DB 接続を試みたものの、DB 層へ届く前に止まったと消去法で特定できます。

「記録が無い」は、観測期間、検索対象、正常時には記録されることの 3 点をそろえたときに証拠になります。単にログを見つけられなかったこととは区別してください。

ゴール到達 ✓ ① nginx、教材アプリ、PostgreSQL を導入し、設定ファイルで 3 層を接続できた(STEP 01〜04)/ ② event ID event-tap-goal を検索キーに、access log・App journal・DB の行と残高を 3 層で照合できた(STEP 05)/ ③ 同じ event ID を再送し、HTTP 409 と DB の行数・残高が変わらないことから拒否した層を確かめた(STEP 06)/ ④ DB host を到達不能にし、入口の 503、App journal のエラー、DB に接続到達記録も event 行も無いことを組み合わせて、故障した層を切り分けた(STEP 07)。

設定を戻し、作ったものを消す

まず app01 で退避した環境ファイルを戻し、service を再起動します。

bash · コマンド
sudo cp /tmp/ch3-app-env.before-failure /etc/tapapi/tapapi.env
sudo systemctl restart tapapi.service
systemctl status tapapi.service --no-pager
期待される出力
(2026-10-12 実測・抜粋)
● tapapi.service - Part 4 transit-card tap API
     Loaded: loaded (/etc/systemd/system/tapapi.service; disabled; ...)
     Active: active (running) since ...

続けて workstation01 から新しい event ID で要求を送り、入口から DB まで復旧したことを確認します。

bash · コマンド
EVENT_ID="event-tap-recovered"
curl --fail --show-error --include \
  -H "X-Event-ID: ${EVENT_ID}" \
  -H 'Content-Type: application/json' \
  --data "{\"card_id\":\"card-demo\",\"event_id\":\"${EVENT_ID}\",\"amount\":120}" \
  "http://<front01の接続名>:<入口の待受port>/tap"
期待される出力
{"ok":true,"card_id":"card-demo","balance":640,"event_id":"event-tap-recovered","node":"app01"}
bash · コマンド
sudo grep -F 'event_id=event-tap-recovered' /var/log/nginx/tap_access.log

実測(2026-08-24〜25)

期待される出力
--- ① Web 層(front01): nginx access log ---
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-tap-recovered upstream=<app01のアドレス>:8080 upstream_status=200
bash · コマンド
sudo journalctl -u tapapi.service --no-pager \
  | grep -F 'event-tap-recovered'
期待される出力
--- ② App 層(app01): journal ---
 8月 24 23:06:50 learner01 python3[4575]: {"event_id":"event-tap-recovered","card_id":"card-demo","amount":120,"node":"app01","status":200,"result":"ok"}
bash · コマンド
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT event_id FROM tap_events WHERE event_id = 'event-tap-recovered';"
期待される出力
--- ③ DB 層(db01): tap_events ---
      event_id
---------------------
 event-tap-recovered
(1 row)

復旧確認の次に、自分で追加した構築物を削除します。App service、配置物、依存 package の 3 分類について、削除した後に不在を確認します。

次の後片付けブロックは subshell ( ... ) の中で実行します。想定外の package を検出した exit 1 はそのブロックだけを停止し、対話 SSH 自体は終了しません。中止メッセージが出た場合は、削除対象を増やさず運営へ連絡してください。

bash · コマンド
(
sudo systemctl disable --now tapapi.service
sudo rm /etc/systemd/system/tapapi.service
sudo systemctl daemon-reload
systemctl status tapapi.service --no-pager || true
systemctl is-enabled tapapi.service || true

sudo rm -rf /opt/tapapi
sudo rm -rf /etc/tapapi
sudo userdel tapapi
sudo groupdel tapapi 2>/dev/null || true
rm -f /tmp/tapapi-1.1.tar.gz
test ! -e /opt/tapapi
test ! -e /etc/tapapi
test ! -e /etc/systemd/system/tapapi.service
getent passwd tapapi || true
getent group tapapi || true

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" in
    python3-psycopg2|libpq5|libpq5:amd64) ;;
    *) unexpected+=("$pkg") ;;
  esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
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[@]}"
dpkg-query -W -f='${binary:Package} ${Status}\n' python3-psycopg2 2>/dev/null | grep 'install ok installed' || echo 'App 用 package は導入済みとして残っていない'
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2

app01 で取得・障害切り分けに使った一時ファイルも、復旧確認後に削除して不在を確認します。

bash · 一時ファイルの後片付け
sudo rm -f /tmp/apt-update-app01.log /tmp/ch3-app-env.before-failure /tmp/ch3-failure-since
test ! -e /tmp/apt-update-app01.log
test ! -e /tmp/ch3-app-env.before-failure
test ! -e /tmp/ch3-failure-since

front01 では章専用設定を無効化してから nginx を削除します。削除対象が章専用ファイルであることを確認して進めます。

bash · コマンド
(
sudo rm -f /etc/nginx/sites-enabled/part4-ch3
sudo rm -f /etc/nginx/sites-available/part4-ch3
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" in
    nginx|nginx-common) ;;
    *) unexpected+=("$pkg") ;;
  esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }
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[@]}"
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2

実測(2026-08-24〜25)

期待される出力
--- 削除対象が章専用ファイルであることを確認する ---
-rw-r--r-- 1 root root 468 Aug 24 14:03 /etc/nginx/sites-available/part4-ch3
lrwxrwxrwx 1 root root  36 Aug 24 14:03 /etc/nginx/sites-enabled/part4-ch3 -> /etc/nginx/sites-available/part4-ch3
有効化先の実体: /etc/nginx/sites-available/part4-ch3

--- 削除する ---
  nginx
0 upgraded, 0 newly installed, 1 to remove and 0 not upgraded.
After this operation, 1355 kB disk space will be freed.
Removing nginx (1.24.0-2ubuntu7.15) ...

--- 不在を確認する ---
sites-enabled/part4-ch3: 不在
sites-available/part4-ch3: 不在
nginx	
inactive

🔴 最後の nginx の行は dpkg-query -W nginx の出力(バージョン欄が空=rc 状態で設定が残っている)。「不在」ではない。

現在の手順は章開始時の package 差分を名指しで purge するため、この出力を合格例には使いません。現在手順の出力は実機で再採取します。

db01 では App の接続許可として追加した 1 行、待受設定、接続記録設定の 3 箇所を元に戻し、演習用 DB と App 専用ユーザを削除してから PostgreSQL を削除します。

bash · コマンド
PG_CONFIG="$(sudo -u postgres psql -Atc 'SHOW config_file;')"
PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')"
sudoedit "$PG_CONFIG"
sudoedit "$PG_HBA"
sudo -u postgres psql -c 'SELECT pg_reload_conf();'
sudo -u postgres psql -Atc 'SHOW log_connections;'
sudo -u postgres dropdb transit_lab
sudo -u postgres psql -Atc \
  "SELECT datname FROM pg_database WHERE datname = 'transit_lab';"
sudo -u postgres dropuser tap_app
sudo -u postgres psql -Atc \
  "SELECT rolname FROM pg_roles WHERE rolname = 'tap_app';"

実測(2026-08-24〜25)

復元前(章の STEP 04 で加えた 3 箇所)

期待される出力
=== 復元前 ===
60:listen_addresses = '<db01のアドレス>'
65:log_connections = on
133:host transit_lab tap_app <app01のアドレス>/32 scram-sha-256

復元後

復元する 3 箇所は、listen_addresses を localhost へ戻すこと、log_connections = off へ戻すこと、host transit_lab … の 1 行を削除することです。上のコマンドで設定を reload し、SHOW log_connections; が off を返すことも確認します。

期待される出力
=== 復元後 ===
60:listen_addresses = 'localhost'
65:log_connections = off
transit_lab の行数: 0
off

演習 DB と App 専用ユーザの削除と不在確認

期待される出力
=== 演習 DB を消して不在を確認 ===
(空)
(上が空なら DB は存在しない)
=== role を消して不在を確認 ===
(空)
(上が空なら role は存在しない)

✅ dropdb / dropuser の直後に同じ名前を pg_database / pg_roles へ問い合わせて、どちらも 0 行。

🔴 postgresql と postgresql-client は、中身を持たない取りまとめ用の package です。 これだけを消しても実体の postgresql-16 は残り、service は動いたままになります。 解放される容量が、消えたものの小ささを示します。

期待される出力
0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.
After this operation, 34.8 kB disk space will be freed.
Removing postgresql (16+257build1.1) ...
Removing postgresql-client (16+257build1.1) ...
bash · コマンド
systemctl is-active postgresql
期待される出力
active

実体と設定まで消します。まず章開始時の package 一覧との差分を取り、PostgreSQL 導入で確認済みの名前だけかを検査します。想定外の package が 1 つでもあれば、削除せずに停止します。メタ package と実体を名指しで purge した後、autoremove --purge は比較用の予測表示だけにとどめ、残った差分 package をもう一度名指しで purge します。

確認待ちと無限定削除を避ける

-y のない purge は [Y/n] で止まり、その間 package 管理の lock を保持します。本手順は DEBIAN_FRONTEND=noninteractive と -y を使い、削除対象は章開始時との差分と allowlist の両方で限定します。apt autoremove は clean snapshot の状態によって対象が変わるため実行しません。

bash · コマンド
(
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" in
    postgresql|postgresql-16|postgresql-client|postgresql-client-16|postgresql-client-common|postgresql-common|libcommon-sense-perl|libcommon-sense-perl:amd64|libjson-perl|libjson-xs-perl|libllvm17t64|libllvm17t64:amd64|libpq5|libpq5:amd64|libtypes-serialiser-perl|ssl-cert) ;;
    *) unexpected+=("$pkg") ;;
  esac
done
((${#unexpected[@]} == 0)) || { printf '想定外の package: %s\n' "${unexpected[@]}" >&2; exit 1; }

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 DEBIAN_FRONTEND=noninteractive apt-get "${APT_ONLY_PART4[@]}" purge -y postgresql postgresql-16 postgresql-client postgresql-client-16 postgresql-client-common postgresql-common

# 比較教材として予測だけを見る。実行はしない
sudo apt-get "${APT_ONLY_PART4[@]}" -s autoremove --purge

mapfile -t chapter_packages < <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort | comm -13 /tmp/part4-packages-before.tsv -)
((${#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
getent passwd postgres || true
getent group postgres || true
getent group ssl-cert || true
stat -c '%a %u %g %n' /etc/ssl/private
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2

実測(2026-08-24〜25)

① 過去の実測:apt remove(メタパッケージだけが消える)

期待される出力
  postgresql postgresql-client
0 upgraded, 0 newly installed, 2 to remove and 0 not upgraded.
After this operation, 34.8 kB disk space will be freed.
Removing postgresql (16+257build1.1) ...
Removing postgresql-client (16+257build1.1) ...
--- systemctl is-active postgresql ---
active

🔴 過去の実測で再現した。 解放されるのは 34.8 kB。削除コマンドが正常終了した直後に systemctl is-active postgresql が active を返したため、現在の手順では実行しません。

② 名指し purge と autoremove の予測表示

期待される出力
The following packages were automatically installed and are no longer required:
  libcommon-sense-perl libjson-perl libjson-xs-perl libllvm17t64 libpq5
  libtypes-serialiser-perl ssl-cert
Use 'sudo apt autoremove' to remove them.
The following packages will be REMOVED:
  postgresql-16* postgresql-client-16* postgresql-client-common*
  postgresql-common*
0 upgraded, 0 newly installed, 4 to remove and 0 not upgraded.
After this operation, 50.5 MB disk space will be freed.
Removing postgresql-16 (16.14-0ubuntu0.24.04.1) ...
update-alternatives: warning: forcing reinstallation of alternative /usr/share/postgresql/16/man/man1/psql.1.gz because link group psql.1.gz is broken
Removing postgresql-client-16 (16.14-0ubuntu0.24.04.1) ...
Removing postgresql-common (257build1.1) ...
Removing 'diversion of /usr/bin/pg_config to /usr/bin/pg_config.libpq-dev by postgresql-common'
Removing postgresql-client-common (257build1.1) ...
Processing triggers for man-db (2.12.0-4build2) ...
Purging configuration files for postgresql-client-common (257build1.1) ...
Purging configuration files for postgresql-16 (16.14-0ubuntu0.24.04.1) ...
Dropping cluster main...
Purging configuration files for postgresql-common (257build1.1) ...
期待される出力
=== autoremove --purge ===
0 upgraded, 0 newly installed, 7 to remove and 0 not upgraded.
After this operation, 125 MB disk space will be freed.
Removing libjson-xs-perl (4.040-0ubuntu0.24.04.1) ...
Removing libtypes-serialiser-perl (1.01-1) ...
Removing libcommon-sense-perl:amd64 (3.75-3build3) ...
Removing libjson-perl (4.10000-1) ...
Removing libllvm17t64:amd64 (1:17.0.6-9ubuntu1) ...
Removing libpq5:amd64 (16.14-0ubuntu0.24.04.1) ...
Removing ssl-cert (1.1.2ubuntu1) ...
Processing triggers for man-db (2.12.0-4build2) ...
Processing triggers for libc-bin (2.39-0ubuntu8.8) ...
Purging configuration files for ssl-cert (1.1.2ubuntu1) ...

🔴 数字が対比になっている。 remove が 34.8 kB、purge が 50.5 MB、autoremove --purge の予測がさらに 125 MB。Dropping cluster main... の 1 行が、データ本体(cluster:1 つのデータディレクトリで管理される DB のまとまり)が消えたのはここだということを示している。現在の手順は autoremove を実行せず、章開始時との差分に含まれる確認済み package だけを名指しします。

最後に、package、service、DB が残っていないことを個別に確認します。削除コマンドが終了したことと、対象が消えたことは別の確認です。

🔴 dpkg-query -W <名前> が行を返すだけでは不在確認になりません。 rc 状態も行を返すため、章開始時に保存した package 一覧と現在の一覧を比較し、増えた package が 0 件であることを確認します。次のブロックは各 VM で実行します。

bash · 不在確認
(
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
systemctl is-active nginx postgresql || true
systemctl status tapapi.service --no-pager || true
getent passwd tapapi postgres || true
getent group tapapi postgres ssl-cert || true
stat -c '%a %u %g %n' /etc/ssl/private
)
rc=$?
((rc == 0)) || echo 'SSH 接続は維持したまま処理を中止しました' >&2

実機出力は要再採取

purge と OS user / group の削除手順を変更したため、2026-08-24〜25 の旧出力は合格例として再利用しません。各 VM で章開始時より増えた package が 0 件、章専用 service が停止または不在、章が作った user / group が 0 件、db01 の /etc/ssl/private が 700 0 0 であることを実機で採り直します。

ここまで app01・front01・db01 の 3 台を順に片付けてきました。 台ごとに見ると 3 分類でも、章全体では作ったものの種類がもっと多くなります。ここまでの削除と不在確認を、台をまたいで次の 8 分類で整理します。分類を先に列挙することで、どの構築物をどの証拠で消したと判断したかを明確にします。

最後に、STEP 01・02・04 で取得経路を設定した各 VM で、章専用 source、keyring、一時取得鍵、package 一覧を削除します。既存 APT source は最初から変更していないため、復元ループと終了時の無限定 apt update は不要です。削除後は各 path の不在を確認してから構築後 manifest を採ります。

bash · APT 取得経路の後片付け
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/part4-packages-before.tsv
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
test ! -e /tmp/infra-study-archive-keyring.asc
test ! -e /tmp/part4-packages-before.tsv
分類本章で作るもの合格条件
DB / role(DB 内でユーザや権限を表すもの) / PostgreSQL 設定STEP 01 の transit_lab、tap_app、接続許可と待受設定DB・role が 0 行で、設定が章開始前へ戻っている
package / 取得経路STEP 01・02・04 の PostgreSQL、python3-psycopg2、nginx、専用 source と keyring章で追加した package が導入済みでなく、取得経路が章開始前と同じ
service / unitSTEP 01・02・04 の PostgreSQL、tapapi.service、nginx章専用 service が稼働せず、章が配置した unit がない
socketSTEP 01・02・04 の 5432、8080、入口の待受 port章で開いた待受がない
App 配置物 / 設定STEP 02 の /opt/tapapi と /etc/tapapi配置先と App 設定が残っていない
OS user / group・権限STEP 01 の postgres・ssl-cert、STEP 02 の tapapi、package が変更した /etc/ssl/private章が追加した user・group が残らず、/etc/ssl/private が 700 root:root に戻っている
nginx 設定 / symlinkSTEP 04 の章専用設定ファイルと有効化先どちらも残っていない
一時物 / GnuPG homeSTEP 01・02・07 の取得鍵、APT log、配布書庫、障害再現用ファイル、章が新設した GnuPG home章開始前に無かったものが残っていない

8 分類の外を manifest 差分で確認する

8 分類を自分で列挙して検査したあと、その外側に残る変更を、構築前後の sorted manifest で確認します。差分は 8 分類を置き換えるものではなく、列挙から漏れた残留候補を見つける安全網です。全差分は /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/ は除外しません。限定した全 apt-get に APT::Get::List-Cleanup=0 を付け、既存カタログを壊していないことを manifest 差分で確認します。

bash · manifest 差分
# db01・app01・front01 の各 VM で実行する
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
bash · 差分証跡の後片付け
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 件でもあれば、それは自分が挙げた 8 分類の外側にある残留です。何の分類にあたるかを考えて「9 番目の分類」を自分の後片付け手順へ足し、解消後に再確認してください。

期待される出力
(形の例です。M・K・T には実行したときの件数が入ります。更新日時だけ変わった通常ファイルの行と件数は、台・章によって変わります。合格の条件は「残留候補: 0 件」です)
== 残留候補(0 件で合格)==
残留候補: 0 件
== 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)==
(該当するパスが 1 行ずつ並びます)
更新日時だけ変わった通常ファイル: M 件 / ディレクトリ: K 件
差分行総数: T

つまずきポイント

ハマり #1 · 入口が 502 を返す

workstation01 から入口へ要求すると 502 が返ります。

確認順序

  1. front01 の access log で event ID と upstream status を確認する。
  2. front01 から App の /health へ到達できるか確認する。
  3. app01 の service 状態と journal を確認する。
  4. App が動いていれば、DB port と DB 接続エラーへ進む。
  • 原因: upstream の接続名・port の誤り、App service の停止、App の起動失敗などが考えられます。
  • 対処: nginx -t の成功だけで終えず、転送先の待受と App journal を照合します。設定を変更したら構文検査と reload を分けて行います。

502 は入口が App へ到達できない場合の応答です。App が動いていて DB 接続失敗を 503 などで応答した場合、入口はその status を中継するため 502 にはなりません。

ハマり #2 · App が起動しない、または DB に接続できない

App service が失敗する場合、起動そのものと DB 接続を分けて見ます。

確認順序

  1. systemctl status tapapi.service で終了状態を確認する。
  2. journalctl -u tapapi.service で最初のエラーを読む。
  3. 環境ファイルの DB host、port、DB 名、接続ユーザを確認する。
  4. nc -vz で DB port への到達を確認する。
  5. db01 の hba_file が示すファイルの接続許可の行と role 権限を確認する。
  • 原因: 接続先の誤り、DB の待受範囲、接続許可、秘密値の渡し方、最小権限の不足を順に切り分けます。
  • 対処: エラーに合わせて 1 か所だけ直し、App の再起動、health、DB 側の接続元確認をやり直します。管理用ユーザへ切り替えて回避しないでください。
ハマり #3 · 要求は通るが DB に記録が無い

HTTP 応答だけを見て成功と判断せず、同じ event ID を 3 層で検索します。

確認順序

  1. nginx access log に event ID があるか確認する。
  2. App journal に同じ ID と処理結果があるか確認する。
  3. tap_events に同じ ID があるか確認する。
  4. cards の残高が変化したか確認する。
  5. DB の接続記録が観測期間内にあるか確認する。
  • 原因: App が入力を拒否した、DB transaction が取り消された、別の DB や schema を参照した、DB へ到達する前に接続が失敗した可能性があります。
  • 対処: 記録が最後に存在する層と、最初に存在しない層の間へ調査範囲を絞ります。DB に接続記録も event 行も無ければ、App より後、DB より前の接続経路を優先します。

修了確認

問 1 · 3 層の証拠を対応づける

nginx access log、App journal、tap_events の行は、それぞれ何を証明しますか。また、3 つを 1 件の要求として結ぶキーは何ですか。

問 2 · 再送時の残高を守る境界を説明する

教材アプリは同じ event ID を事前検索していません。それでも同じ要求の再送で残高が二重に減らないのはなぜですか。また、事前検索を追加しても DB の一意制約が必要な理由と、再送後に観測するものを答えてください。

問 3 · 502 の調査順序を組み立てる

入口が 502 を返しました。設定を手当たり次第に変更せず、Web、App、DB のどの記録をどの順序で確認しますか。

問 4 · 「記録が無い」を証拠にする

DB journal と tap_events に対象 event の記録がありません。この事実だけで「App より手前で止まった」と断定できますか。証拠にするために追加で必要な条件を答えてください。

段階ヒント(問 1〜問 4)

問 1 のヒントと答え

ヒント 1 段 · 軽く方向だけ

3 つの記録が、それぞれどの層で何を証明しているかに注目してください。

ヒント 2 段 · 確認する場所

/var/log/nginx/tap_access.log、journalctl -u tapapi.service、tap_events を、同じ event ID で突き合わせます。

答えを見る

access log は Web 層が要求を受けて upstream 応答を扱ったこと、App journal は App 層の処理判断、tap_events は DB transaction で event が確定したことを証明します。3 つを結ぶキーは、要求から DB まで引き継いだ同じ event ID です。

問 2 のヒントと答え

ヒント 1 段 · 軽く方向だけ

事前に検索していなくても、同じ event ID の 2 回目の処理がどこで止まるかに注目してください。

ヒント 2 段 · 確認する場所

再送前後で tap_events の該当 event ID の行数と残高を比べ、journalctl -u tapapi.service で 409 応答を確認します。

答えを見る

教材アプリは残高更新と event 登録を 1 transaction で行います。同じ event ID の INSERT が一意制約違反になると transaction 全体を rollback して HTTP 409 を返すため、その直前の残高更新も確定しません。事前検索を追加しても同時要求がどちらも「まだ存在しない」と判断する隙間は残るので、DB の一意制約が最後の境界です。再送後は 409、event ID ごとの行数が 1 件のままであること、残高が再送前から変わらないことを確認します。

問 3 のヒントと答え

ヒント 1 段 · 軽く方向だけ

要求が最後に見える層と、最初に見えなくなる層の境目を探してください。

ヒント 2 段 · 確認する順序

/var/log/nginx/tap_access.log の upstream status → journalctl -u tapapi.service → DB への到達記録の順に確認します。

答えを見る

入口に近い側から、nginx access log の event ID と upstream status、App の service・待受・journal、App から DB port への到達、DB の接続記録と event 行を確認します。最後に記録がある層と、最初に記録が無い層の間へ原因を絞ります。502 は入口から App へ届かなかった場合を示します。App が動いていて DB 接続失敗を 503 などで応答した場合は入口がその status を中継するため、502 の調査とは分けて考えます。

問 4 のヒントと答え

ヒント 1 段 · 軽く方向だけ

記録が無いことをそのまま断定に使わず、比べる基準がそろっているかに注目してください。

ヒント 2 段 · 確認する場所

同じ観測期間・検索条件で /var/log/nginx/tap_access.log、journalctl -u tapapi.service、tap_events を確認し、正常時に同じ操作で記録が残ることを先に確かめます。

答えを見る

断定できません。観測した時間範囲と検索条件を固定し、正常時には同じ操作で DB の接続記録と event 行が残ることを先に確かめます。そのうえで、入口には要求、App には DB 接続エラーがあり、同じ期間の DB には接続到達記録も event 行も無いことを組み合わせると、DB へ届く前に止まった証拠になります。

1 台の App から、止まりにくい入口へ

本章で Web、App、DB はつながり、1 件の要求を 3 層で追えるようになりました。しかし App はまだ 1 台だけです。この 1 台が停止すれば、入口から DB への経路も止まります。次章では App を 2 台へ増やし、要求を振り分け、故障した 1 台を経路から外す動作を観測します。

Chapter 4: 2 台へ振り分け、故障した 1 台を経路から外す

  • 2 台の App へ要求を振り分ける
  • 応答した node を記録から見分ける
  • 故障した 1 台を経路から外し、残りの 1 台で継続する