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

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

2 台の App へ要求を振り分け、片方を停止したときの最初の失敗と後続要求の継続を記録から追います。

教材 ✓ 公開中登録不要 実機演習 ― 提供していない読んで学べます
所要時間約 100 分(概念 15 分+演習 85 分)
前提知識Part 2 の名前解決・port・経路、Part 3 の systemd・journal、Part 4 Chapter 1〜3 の概念
使用機材入口サーバ front01、アプリサーバ app01・app02、DB サーバ db01、操作端末 workstation01、LAN 内の教材取得経路
関連章前: Part 4 / Chapter 3「Web、アプリ、DB をつないで、タップを処理する」/後: Part 4 / Chapter 5「TLS とファイアウォールで、公開範囲を絞る」

1 台の停止を、サービス全体の停止にしない

入口、App(タップを処理するアプリケーション)、DB の 3 層がつながっていても、App が 1 台だけなら、その App の停止で処理経路全体が途切れます。本章では同じ教材アプリを 2 台へ用意し、nginx の入口から要求を振り分けます。どちらの App が処理しても、共有 DB にある残高と event ID(1 回のタップ処理を見分ける識別子)は一貫したままです。

本章も初期状態から独立して成立します。Chapter 3 と同じ手順で 3 層を短縮構築しますが、Chapter 3 の環境は引き継がず、構築物が残っていることも前提にしません。各設定の理由を確認したい場合は Chapter 3 を参照してください。

期待される出力
                         ┌─ app01 ─┐
workstation01 ─ front01 ─┤         ├─ db01
                         └─ app02 ─┘

正常時は 2 台へ要求が渡ります。片方の App を一時停止すると、nginx は失敗した実要求を根拠にその経路を一定時間外し、残る App へ後続要求を渡します。この切り替わりを、応答、入口ログ、App journal、DB の 4 か所から観測します。

この章のゴール: 同じアプリを設定差なく 2 台へ用意し、nginx から振り分けられる。片方の App を停止したとき、最初の失敗要求と後続要求の継続を記録から説明し、最後に両系正常へ戻せる。

この章で作る 2 台の App への振り分けは、街の改札サービスの「複数の App への割り当て」にあたります。街の全体像と、街の言葉・技術の言葉の対応は Part 0(この教材で作る街)にまとめています。

扱わない範囲

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

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

複数の App を同じサービスとして構成し、正常時と、片系停止(2 台のうち 1 台だけを止めること)をした時の経路を証跡から説明できるようになります。本章で身につく 4 動作 は次のとおりです。

01
① 同じアプリを 2 台へそろえる
設定ファイル、環境ファイル、権限を照合し、node 名以外の設定差をなくす。
02
② 入口から 2 台へ振り分ける
nginx の upstream(転送先の一覧)に 2 台を登録し、構文検査と reload を分けて行う。
03
③ 共有状態の一貫性を確かめる
応答、入口ログ、App journal、DB を event ID で結び、どちらが処理しても結果が一貫することを確認する。
04
④ 片系停止と復帰を観測する
受動的な故障検出で最初の失敗要求と後続要求の継続を確認し、停止した App を戻して両系正常を確かめる。

振り分けと状態確認を混同しない

1. upstream は渡し先の一覧であり、死活監視ではない

nginx の upstream は、要求を渡せるバックエンド(入口の先で要求を処理する側。本章では App)の一覧です。複数の server を登録すると、既定では要求ごとに渡し先が順番に選ばれます。しかし、設定ファイルに名前があることは、その App が今も正常に処理できる証明ではありません。

構文検査が確認するのは設定の文法と参照関係です。実際にどの App へ接続し、どの応答を受けたかは access log の upstream address と upstream status で確かめます。App 側の journal と event ID を突き合わせることで、入口の記録だけに頼らず処理先を特定できます。

2. オープンソース版(OSS)の nginx は実要求の失敗から受動的に判断する

OSS の nginx には、バックエンドを定期的に確認する能動的なヘルスチェック機能(health check:稼働の確認)がありません。 本章では max_fails(失敗とみなす回数)と fail_timeout(経路から外しておく時間)を使い、実際の要求で通信に失敗した回数を基に、その経路を一定時間だけ選択対象から外す受動的な方式を構成します。

この方式では、停止した App を入口が事前に知ることはできません。故障の検出には、停止した App へ送られて失敗した実要求が最低 1 件必要です。したがって最初の 1 件は失われます。これは設定の欠陥ではなく、実要求を判断材料にする方式の性質です。

本章では、同じ要求を別の App へその場で再試行する動作を無効にします。これにより、故障を検出した最初の要求はクライアントへ失敗として返り、その後の要求が正常な App へ渡る順序を分けて観測できます。再試行を有効にする設計では利用者から最初の失敗が見えない場合がありますが、本章の観測目的とは異なります。

3. /health は状態を確かめる入口であり、振り分けの判断材料ではない

/health(App 自身が状態を答える確認用の URL)は、受講者と受入試験(章の成果が合格条件を満たすかの確認)が App 自身と DB 接続の状態を確認するために使います。nginx の入口が /health を定期的に要求して、転送先を決める構成ではありません。 upstream の受動的な判断とは役割を分けます。

証跡も混ぜません。/health の応答は「その時点で App と DB 接続がどう見えたか」の記録です。入口の access log に残る upstream address、upstream status、失敗回数による一時除外は「実要求をどこへ渡したか」の記録です。本章では両方を別々に保存し、一方をもう一方の代用にしません。

📔 コラム · 無償版と商用機能を同じ図にしない

バックエンドへ定期的な確認要求を送り、応答を基に経路を外す説明を見かけることがあります。本章で使う OSS の nginx は、その能動的な方式ではありません。実要求の失敗を観測する受動方式として、構成と結果を読みます。

4. 共有状態へ寄せると、処理した App が変わっても結果がそろう

もし各 App のローカルディスクに残高を持たせると、app01 が更新した残高を app02 は知りません。要求の渡し先によって別の残高が返り、振り分けが成功しているほど矛盾が表面化します。

そこで残高と event ID は共通の db01 へ置きます。App のローカルには、2 台で別々に更新される業務状態を持たせません。node 名が異なっても、同じ event ID に対する DB の結果は 1 つです。

期待される出力
App のローカル状態へ残高を置く    → app01 と app02 で食い違う
残高と event ID を DB へ置く       → どちらが処理しても同じ結果を見る
App のローカルへ業務状態を書かない → App ごとの書き込み差分を作らない

演習:2 台への振り分けと片系停止を観測する

演習環境

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

🛟 失敗しても安全: 故障は app01 の App service の一時停止だけで作ります。VM の電源やネットワーク設定は変更しません。STEP 8 で App を再開し、2 台とも正常に処理できることを確認してから章を終えます。

山括弧で囲んだ値は、演習環境または教材の案内に表示された値へ置き換えてください。接続先、nginx の設定ファイルは、演習環境ごとに異なります。

3 層を clean から用意する

演習環境は毎回、clean snapshot(演習の開始時に取っておく、構築前の状態)から始まります。第 3 章の最後で構築物をすべて削除したとおり、前章の完了状態はどこにも残っていません。この STEP では db01、app01、front01 の 3 台を、取得経路の設定から演習の開始状態まで作り直します。本章で新しく追加するサーバは app02 で、STEP 02 で同じ手順を適用します。DB の制約(不正な値を DB が拒否する決まり)、最小権限、event ID の引き継ぎを設計した理由は第 3 章を参照してください。ここでは作る手順だけを示します。

1-1. 各 VM 共通 · 取得経路を設定する

次のブロックを、db01 → app01 → front01 の順に 各 VM の中で 実行します。鍵の fingerprint は使い捨ての GnuPG home で照合し、一致しない場合は test "$fingerprint_status" -eq 0 が失敗します。既存の APT source には触れず、章専用ディレクトリだけを使います。

取得経路を変える前に、各 VM の package 一覧を記録します。終了時はこの一覧との差分で確認します。鍵の fingerprint 照合は使い捨ての GnuPG home(mktemp -d)で行うため、~/.gnupg には触れません。また、8 分類の外側に残る変更を確認するため、各 VM(app02 は STEP 02 の開始時)と workstation01 で、path・type・mode・uid・gid・size・mtime の sorted manifest(ファイルの場所・権限・更新日時などを一覧にしたもの)を最初に記録します。

bash · コマンド
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
bash · コマンド
# ここからが各 VM 共通の取得経路ブロック(既存 source には触れない)
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
grep -nHE '^(Types|URIs|Suites|Components|Signed-By):' /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-part4.log
grep -E '^(Get|Hit):' /tmp/apt-update-part4.log
# 取得経路ブロックここまで

fingerprint の照合先は HTTPS の教材ページ に掲載された正本です。鍵を取得した HTTP 経路だけを信頼の起点にしない、という第 1 章の原則をここでも守ります。照合は使い捨ての GnuPG home(mktemp -d)で行い、照合後に rm -rf で消すため、~/.gnupg は生成されません。apt-get には Dir::Etc::sourcelist=/dev/null と Dir::Etc::sourceparts=/etc/apt/sources.list.d.part4 を渡すため、既存の sources.list や sources.list.d/ には触れず、取得先は配布元 1 本だけになります。

🔴 LC_ALL=C を付けている理由: apt-get は表示言語を環境に合わせて変えます。日本語環境では取得行が 取得:1 … になるため、grep -E '^(Get|Hit):' が 1 行も一致しません。取得自体は成功しているのに、 確認だけが黙って成立しなくなります。表示文字列で判定するときは、表示言語を固定してから判定します。

実機出力は要再採取

取得経路を章専用ディレクトリへ限定し、鍵の fingerprint 照合を使い捨ての GnuPG home で行う形へ変更したため、2026-08-25 の出力(~/.gnupg の生成メッセージを含む)は合格例として再利用しません。test "$fingerprint_status" -eq 0 が成立すること、/etc/apt/sources.list.d.part4/infra-study.sources だけが有効な source であること、apt-get の取得先が http://172.16.10.22/ubuntu だけであることを、この手順で db01・app01・front01・app02 の 4 台から実機で採り直します。

1-2. db01 · DB 層を作る

db01 へ接続し、PostgreSQL を導入します。

bash · コマンド
ssh deployer@db01
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[@]}" install -y postgresql postgresql-client
systemctl status postgresql --no-pager

schema(テーブルや制約などの DB の構造)の正本は教材アプリの配布書庫に同梱された schema.sql です。書庫を取得し、SHA-256 が一致した場合だけ展開します。

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"
if printf '%s  %s\n' "$TAPAPI_SHA256" "$TAPAPI_ARCHIVE" | sha256sum --check -; then
  TAPAPI_TMP="$(mktemp -d)"
  tar -xzf "$TAPAPI_ARCHIVE" -C "$TAPAPI_TMP"
else
  echo 'SHA-256 が一致しません。展開せずに止めます(SSH 接続は維持されます)' >&2
fi

演習用 DB、App 専用ユーザを作り、schema.sql を実行してから、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 -d transit_lab < "$TAPAPI_TMP/tapapi/schema.sql"

sudo -u postgres psql -P pager=off -d transit_lab <<'SQL'
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
rm -rf "$TAPAPI_TMP"

実測(2026-08-25)

期待される出力
● postgresql.service - PostgreSQL RDBMS
     Loaded: loaded (/usr/lib/systemd/system/postgresql.service; enabled; preset: enabled)
     Active: active (exited) since Tue 2026-08-25 00:54:20 UTC; 8s ago
   Main PID: 2739 (code=exited, status=0/SUCCESS)
期待される出力
/tmp/tapapi-1.1.tar.gz: OK
期待される出力
CREATE TABLE
CREATE TABLE
INSERT 0 1
GRANT
GRANT
GRANT
GRANT
                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)

                                 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)

✅ tap_app の権限は cards が rw(SELECT/UPDATE)、tap_events が a(INSERT のみ)。

App からの接続を受ける設定を行います。本章は 2 台の App を使うため、接続許可は app01 と app02 の 2 行です。第 3 章と違い、待受アドレスはドロップイン設定(既存の設定ファイルを書き換えず、別のファイルで設定を足す方式)で、接続許可はガード付きの追記で入れます。再構築は同じ結果を再現できる形で行います。

bash · コマンド
PG_CONF="$(sudo -u postgres psql -Atc 'SHOW config_file;')"
PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')"
PG_CONF_DIR="$(dirname "$PG_CONF")"

sudo install -d -m 0755 "$PG_CONF_DIR/conf.d"

sudo tee "$PG_CONF_DIR/conf.d/part4.conf" > /dev/null <<'EOF'
listen_addresses = '172.16.10.24'
EOF
sudo grep -nE "^[[:space:]]*include_dir[[:space:]]*=[[:space:]]*'conf\.d'" "$PG_CONF"

for APP_ADDR in 172.16.10.20 172.16.10.21; do
  HBA_LINE="host transit_lab tap_app ${APP_ADDR}/32 scram-sha-256"
  sudo grep -qsF "$HBA_LINE" "$PG_HBA" || \
    printf '%s\n' "$HBA_LINE" | sudo tee -a "$PG_HBA" > /dev/null
done

sudo systemctl restart postgresql
sudo -u postgres psql -Atc 'SHOW listen_addresses;'
sudo ss -lntp | grep postgres
sudo tail -n 3 "$PG_HBA"

listen_addresses(DB が接続を待つアドレスの設定)は起動時にだけ読み込まれるため、reload ではなく restart で反映します。SHOW と ss の両方で、待受が期待どおりに変わったことを確認します。

実測(2026-08-25)

期待される出力
817:include_dir = 'conf.d'			# include files ending in '.conf' from
<db01のアドレス>
LISTEN 0      200     <db01のアドレス>:5432      0.0.0.0:*    users:(("postgres",pid=3867,fd=6))
host    replication     all             ::1/128                 scram-sha-256
host transit_lab tap_app <app01のアドレス>/32 scram-sha-256
host transit_lab tap_app <app02のアドレス>/32 scram-sha-256

✅ ドロップイン(conf.d/part4.conf)が読まれ、SHOW と ss の両方で待受が変わっている。pg_hba の末尾に app01・app02 の 2 行。

1-3. app01 · App 層を作る

app01 へ接続します。

bash · コマンド
ssh deployer@<app01の接続名>

依存 package を導入して教材アプリを配置します。取得できたことは、正しいものを取得できたことを意味しません。展開前に SHA-256、サイズ、収録ファイルを確認し、一致しなければその場で中止します。接続コマンドを含まない次の 2 ブロックは、STEP 02 で app02 にもそのまま使います。

bash · コマンド
# ここからが App 層ブロック(前半・取得と照合)
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[@]}" install -y python3-psycopg2

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 ファイルであることを確認してから配置します。service は deployer ではなく、ログインできない専用ユーザ tapapi で動かします。

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"

sudoedit /etc/tapapi/tapapi.env
sudo systemctl daemon-reload
sudo systemctl start tapapi.service
systemctl status tapapi.service --no-pager
# 待受が開くまで待つ(開かなければ 30 秒で諦める)
timeout 30 bash -c 'until curl -sf http://localhost:8080/health > /dev/null; do sleep 1; done'
curl --fail --show-error http://localhost:8080/health
# App 層ブロックここまで

環境ファイルには TAPAPI_NODE=app01 と、PGHOST=172.16.10.24、PGPORT=5432、PGDATABASE=transit_lab、PGUSER=tap_app を設定します。PGPASSWORD には、1-2 の \password tap_app で自分で決めた値を、sudoedit でこの環境ファイルにだけ書きます(ファイルは root と tapapi グループだけが読める権限 0640)。コマンドの引数・端末出力・証跡には貼りません。最後の /health は App 自身と DB 接続の両方を検査します。応答の "db":"ok" まで確認できたら、この層は完成です。

🔴 systemctl start が戻ったことは、待受が開いたことを意味しません。 systemd が「起動した」と言うのは プロセスを起こした時点で、そのプロセスが port を開くのはその後です。start の直後に curl を打つと curl: (7) Failed to connect になります(実機では起動から 25 ミリ秒の時点で再現しました)。 待つなら、時間ではなく状態を待ちます。 上の until は「応答が返るまで」待ち、30 秒で諦めます。

実測(2026-08-25)

期待される出力
/tmp/tapapi-1.1.tar.gz: OK
size=2728 bytes (期待値 2728 と一致)
tapapi/
tapapi/schema.sql
tapapi/tapapi.env.example
tapapi/tapapi.py
tapapi/tapapi.service
期待される出力
TAPAPI_NODE=app01
PGHOST=<db01のアドレス>
PGPORT=5432
PGDATABASE=transit_lab
PGUSER=tap_app
root tapapi 640 /etc/tapapi/tapapi.env
期待される出力
     Active: active (running) since Tue 2026-08-25 09:55:34 JST; 6s ago
{"status":"ok","db":"ok","node":"app01"}

1-4. front01 · 入口の package だけを導入する

front01 へ接続し、nginx を導入します。App への転送設定はまだ作りません。 2 台へ振り分ける upstream の構成が本章の主題であり、STEP 04 で自分の手で作ります。

bash · コマンド
ssh deployer@front01
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[@]}" install -y nginx
systemctl status nginx --no-pager

実測(2026-08-25)

期待される出力
● 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-25 00:55:49 UTC; 2s ago
       Docs: man:nginx(8)
    Process: 1691 ExecStartPre=/usr/sbin/nginx -t -q -g daemon on; master_process on; (code=exited, status=0/SUCCESS)
    Process: 1693 ExecStart=/usr/sbin/nginx -g daemon on; master_process on; (code=exited, status=0/SUCCESS)

この STEP を終えた時点で成立しているのは、①3 台の取得経路、②db01 の DB と 2 台ぶんの接続許可、③app01 の App と DB 接続(/health の db:ok)、④front01 の nginx 単体、の 4 点です。入口から App への経路はまだ存在しません。

同じ手順を app02 へ適用し、設定差をなくす

app02 へ接続し、まず STEP 01 の 1-1 と同じ 各 VM 共通の取得経路ブロックを実行します。test "$fingerprint_status" -eq 0 が成立してから先へ進みます。

bash · コマンド
ssh deployer@<app02の接続名>
# STEP 01 の 1-1「各 VM 共通の取得経路ブロック」をここで実行する

実機出力は要再採取

db01・app01・front01 と同じ理由で、この出力も改訂後の手順で採り直します。

続いて STEP 01 の 1-3 と同じ App 層の手順(依存 package の導入、書庫の取得と照合、配置、起動)を実行します。変えるのは環境ファイルの TAPAPI_NODE=app02 だけです。PGHOST、PGPORT、PGDATABASE、PGUSER、PGPASSWORD は app01 と同じ値にします。配布 URL、SHA-256、配置先、所有者、権限、service も app01 と同じです。

bash · コマンド
# STEP 01 の 1-3 の全ブロックを app02 で実行する(TAPAPI_NODE だけ app02 にする)
curl --fail --show-error http://localhost:8080/health

実測(2026-08-25)

期待される出力
/tmp/tapapi-1.1.tar.gz: OK
size=2728 bytes (期待値 2728 と一致)
TAPAPI_NODE=app02
PGHOST=<db01のアドレス>
PGPORT=5432
PGDATABASE=transit_lab
PGUSER=tap_app
root tapapi 640 /etc/tapapi/tapapi.env
期待される出力
{"status":"ok","db":"ok","node":"app02"}

2 台の設定差を、node 名以外ゼロにします。両台で同じ照合コマンドを実行し、結果をファイルへ保存してから見比べます。秘密値と node 名は比較対象から除外し、除外した 2 項目は別途目で確認します。

bash · コマンド
# app01 と app02 の両方で実行する
{
  sha256sum /opt/tapapi/tapapi.py /opt/tapapi/schema.sql \
    /etc/systemd/system/tapapi.service
  sudo grep -vE '^(PGPASSWORD|TAPAPI_NODE)=' /etc/tapapi/tapapi.env | sort | sha256sum
  stat -c '%U %G %a %n' /opt/tapapi /opt/tapapi/tapapi.py \
    /etc/tapapi /etc/tapapi/tapapi.env /etc/systemd/system/tapapi.service
  sudo grep -E '^TAPAPI_NODE=' /etc/tapapi/tapapi.env
} | tee "/tmp/ch4-parity-$(hostname).txt"

2 台の /tmp/ch4-parity-*.txt を並べ、TAPAPI_NODE の行以外がすべて一致することを確認します。ハッシュが 1 つでも違えば、同じ手順のどこで結果が分かれたかを直してから先へ進みます。2 台を用意する目的は単なる複製ではなく、node 名以外の意図しない設定差をなくすことです。

実測(2026-08-25)

app01

期待される出力
08431c5c34240e2a5c1a3f07315c1f8cee9bae76bf1f4e360e2a7cf464174e52  /opt/tapapi/tapapi.py
731b8b72f71d9de4f41b003a2ecc43ff0db10c9d9e672604330e2a0fa52cefc1  /opt/tapapi/schema.sql
db0cbb60d177232ed97fb9aa4d69feb30ed8162884aa30fd53a993bd32193b86  /etc/systemd/system/tapapi.service
946b5db6bf2a6fdc2e5480320b6df6cb6827a16836f75cc32a39e358aa2426f8  -
root root 755 /opt/tapapi
root root 755 /opt/tapapi/tapapi.py
root root 755 /etc/tapapi
root tapapi 640 /etc/tapapi/tapapi.env
root root 644 /etc/systemd/system/tapapi.service
TAPAPI_NODE=app01

app02

期待される出力
08431c5c34240e2a5c1a3f07315c1f8cee9bae76bf1f4e360e2a7cf464174e52  /opt/tapapi/tapapi.py
731b8b72f71d9de4f41b003a2ecc43ff0db10c9d9e672604330e2a0fa52cefc1  /opt/tapapi/schema.sql
db0cbb60d177232ed97fb9aa4d69feb30ed8162884aa30fd53a993bd32193b86  /etc/systemd/system/tapapi.service
946b5db6bf2a6fdc2e5480320b6df6cb6827a16836f75cc32a39e358aa2426f8  -
root root 755 /opt/tapapi
root root 755 /opt/tapapi/tapapi.py
root root 755 /etc/tapapi
root tapapi 640 /etc/tapapi/tapapi.env
root root 644 /etc/systemd/system/tapapi.service
TAPAPI_NODE=app02

✅ TAPAPI_NODE の行以外は全行一致。 秘密値を除いた env のハッシュ(4 行目)も一致している。

共有 DB と App の状態を確認する

両 App の環境設定が同じ db01 を参照することを確認します。ローカルディスクへ残高を書き込む設定が無いことも、教材の確定した設定項目で照合します。

bash · コマンド
curl --fail --show-error --include "http://localhost:8080/health"

実測(2026-08-25)

期待される出力
HTTP/1.0 200 OK
Server: tapapi 
Content-Type: application/json; charset=utf-8
Content-Length: 40

{"status":"ok","db":"ok","node":"app01"}
期待される出力
HTTP/1.0 200 OK
Server: tapapi 
Content-Type: application/json; charset=utf-8
Content-Length: 40

{"status":"ok","db":"ok","node":"app02"}

db01 では App 専用ユーザの接続元を確認します。

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';"

🔴 1 回実行しただけでは 0 行が返ります。失敗ではありません。 教材アプリは要求を 1 件処理するたびに DB へ接続し、 処理が終わると閉じます(Chapter 3 の確認 3 で見たとおりです)。要求が流れていない瞬間に覗けば、接続は存在しません。

両 App へ要求を流し続けながら、DB 側の観測を繰り返します。 片方だけでは足りません。

bash · コマンド
# app01 と app02 の両方で: 要求を流し続ける
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, application_name, client_addr, state
       FROM pg_stat_activity
      WHERE usename = 'tap_app';"
done | grep -m 10 tap_app

実測(2026-08-25)

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

🔴 application_name は空です。 そのため、どちらの App から来た接続かは client_addr でしか区別できません。 2 台の接続元アドレスを控えてから観測してください。

この STEP の状態確認は、2 台の App と DB 接続が演習開始時に正常であることを確かめるためのものです。入口の振り分け判断には使いません。

front01 の upstream に 2 台を登録する

front01 で章専用の nginx 設定を作ります。まずは正常時の振り分けだけを構成し、受動的な故障検出の値は STEP 6 で追加します。access log には event ID、upstream address、upstream status、要求全体の status を残します。

この STEP 以降で使う <入口の待受port> は、第 3 章の環境を引き継ぎません。値だけ第 3 章の listen と同じ 8081 にして、本章で front01 の nginx に設定し直します(新しく決める値ではありません)。

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

upstream tap_app_backend {
    server <app01の接続名>:8080;
    server <app02の接続名>:8080;
}

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

    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 "<nginxの章専用設定ファイル>"
sudo ln -s "<nginxの章専用設定ファイル>" "<nginxの有効化先>"
sudo nginx -t
sudo systemctl reload nginx
systemctl status nginx --no-pager

実測(2026-08-25)

期待される出力
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-25 00:55:49 UTC; 1min 36s ago
lrwxrwxrwx 1 root root 34 Aug 25 00:55 default -> /etc/nginx/sites-available/default
lrwxrwxrwx 1 root root 36 Aug 25 00:57 part4-ch4 -> /etc/nginx/sites-available/part4-ch4

nginx -t は構文を検査し、reload は設定を動作中の nginx へ反映します。どちらも、2 台の App が要求を正常処理したことまでは証明しません。

3 つの記録で振り分け先を特定する

workstation01 から、毎回異なる event ID を付けた同じ種類の要求を複数回送ります。API(プログラム同士が要求と応答をやり取りする窓口)の path、header、body は教材資産の確定仕様を使います。path は URL のうち場所を示す部分、header は要求に付ける追加情報、body は要求に付けて送るデータ本体です。

bash · コマンド
for N in 1 2 3 4 5 6; do
  EVENT_ID="event-balance-${N}"
  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\":10}" \
    "http://<front01の接続名>:<入口の待受port>/tap"
done

実測(2026-08-25)

期待される出力
{"ok":true,"card_id":"card-demo","balance":990,"event_id":"event-balance-1","node":"app01"}
{"ok":true,"card_id":"card-demo","balance":980,"event_id":"event-balance-2","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":970,"event_id":"event-balance-3","node":"app01"}
{"ok":true,"card_id":"card-demo","balance":960,"event_id":"event-balance-4","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":950,"event_id":"event-balance-5","node":"app01"}
{"ok":true,"card_id":"card-demo","balance":940,"event_id":"event-balance-6","node":"app02"}

✅ node が app01 と app02 を交互に返し、残高は 1 本の履歴として 1000 → 940 へ減っている。

各要求について、次の 3 つの記録を同じ event ID で検索します。

bash · コマンド
# front01
sudo grep -F 'event_id=event-balance-' /var/log/nginx/tap_access.log

# app01 と app02 のそれぞれ
sudo journalctl -u tapapi.service --no-pager \
  | grep -F 'event-balance-'

実測(2026-08-25)

front01

期待される出力
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-1 upstream=<app01のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-2 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-3 upstream=<app01のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-4 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-5 upstream=<app01のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-balance-6 upstream=<app02のアドレス>:8080 upstream_status=200

app01

期待される出力
{"event_id":"event-balance-1","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}
{"event_id":"event-balance-3","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}
{"event_id":"event-balance-5","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}

app02

期待される出力
{"event_id":"event-balance-2","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-balance-4","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-balance-6","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}

db01 では、送った event ID と残高を確認します。

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 LIKE 'event-balance-%'
    ORDER BY created_at;"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"

実測(2026-08-25)

期待される出力
    event_id     |  card_id  | amount |          created_at           
-----------------+-----------+--------+-------------------------------
 event-balance-1 | card-demo |     10 | 2026-08-25 00:57:36.571777+00
 event-balance-2 | card-demo |     10 | 2026-08-25 00:57:36.599873+00
 event-balance-3 | card-demo |     10 | 2026-08-25 00:57:36.624258+00
 event-balance-4 | card-demo |     10 | 2026-08-25 00:57:36.648303+00
 event-balance-5 | card-demo |     10 | 2026-08-25 00:57:36.676865+00
 event-balance-6 | card-demo |     10 | 2026-08-25 00:57:36.705683+00
(6 rows)

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

✅ 異なる App が処理した event が同じ表に並び、残高が 1 本の履歴として更新されている。

異なる App が処理した event が同じ表に並び、残高が 1 本の履歴として更新されれば、処理先が変わっても共有状態が一貫する条件を確認できています。

受動的な故障検出を構成する

front01 の upstream に、失敗回数と経路から外す時間を追加します。値は教材の確定値を使います。また、最初の失敗要求を隠さず観測するため、同じ要求を別の upstream へその場で再試行しない設定を location(nginx で URL ごとの処理を書く区画)に加えます。

nginx · コマンド
upstream tap_app_backend {
    server <app01の接続名>:8080 max_fails=1 fail_timeout=30s;
    server <app02の接続名>:8080 max_fails=1 fail_timeout=30s;
}

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

max_fails は、fail_timeout が示す期間内に何回の通信失敗を数えたら対象を一時的に利用不可とみなすかを設定します。fail_timeout は失敗を数える期間と、利用不可とみなす期間の両方に関係します。HTTP status のどこまでを失敗として数えるかは別の設定にも左右されるため、本章では App service 停止による接続失敗を観測対象にします。

この章では max_fails=1、fail_timeout=30s を使います。max_fails=1 にするのは、1 件の失敗で経路から外れるという受動方式の最小の形をそのまま観測するためです。fail_timeout を既定の 10 秒より長い 30 秒にするのは、外れている間に後続要求をまとめて送りきるためです。短すぎると、後続要求の途中で app01 が再試行の対象へ戻り、2 件目の失敗が混ざって観測が濁ります。🔴 この 3 つの値(max_fails・fail_timeout・proxy_next_upstream)は、第 6 章の受入試験 6 が同じ挙動を前提にしています。値を変えるときは両方の章をそろえて変えてください。

変更後は、構文検査と反映を分けて行います。

bash · コマンド
sudoedit "<nginxの章専用設定ファイル>"
sudo nginx -t
sudo systemctl reload nginx
systemctl status nginx --no-pager

実測(2026-08-25)

期待される出力
6:    server <app01のアドレス>:8080 max_fails=1 fail_timeout=30s;
7:    server <app02のアドレス>:8080 max_fails=1 fail_timeout=30s;
17:        proxy_next_upstream off;
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
     Active: active (running) since Tue 2026-08-25 00:55:49 UTC; 2min 23s ago

構成の証跡とは別に、両 App で次の確認を行い、停止前の状態を保存します。

bash · コマンド
curl --fail --show-error --include "http://localhost:8080/health"

実測(2026-08-25)

期待される出力
HTTP/1.0 200 OK
{"status":"ok","db":"ok","node":"app01"}
期待される出力
HTTP/1.0 200 OK
{"status":"ok","db":"ok","node":"app02"}

ここで得た /health の結果は App 自身と DB 接続の状態確認です。nginx がこの結果を読んで経路を選ぶことはありません。振り分けの判断は、次の STEP で送る実要求の通信結果と access log から観測します。

app01 を停止し、最初の失敗と後続の継続を観測する + GOAL 🎯

⚠️ 最初の 1 件は失敗します: 受動方式では、故障の検出に失敗した実要求が最低 1 件必要です。これから送る要求の 1 件が失われるのは予期された挙動です。設定を間違えたと判断して途中で値を変えず、まず入口ログを保存してください。

🔴 要求はまとめて送り、記録の照合は後でまとめて行います。 fail_timeout=30s の間だけ app01 が経路から外れているので、要求を送る途中で別のホストへログを見に行くと、その 30 秒が過ぎて app01 が再試行の対象へ戻り、観測が濁ります。

正常時の最後の記録を保存し、app01 で App service だけを一時停止します。/tmp/ch4-app01-stop-since は app01 にしか残らないため、次のコマンドが表示する時刻を控えておいてください。後で app02 側の確認にも同じ値を使います。

bash · コマンド
date --iso-8601=seconds | tee /tmp/ch4-app01-stop-since
sudo systemctl stop tapapi.service
systemctl status tapapi.service --no-pager || true

実測(2026-08-25)

期待される出力
2026-08-25T09:58:27+09:00
○ tapapi.service - Part 4 transit-card tap API
     Loaded: loaded (/etc/systemd/system/tapapi.service; disabled; preset: enabled)
     Active: inactive (dead)

状態確認の記録を振り分けの証跡と分けるため、app01 自身で停止後の確認を 1 回だけ行います。失敗は予期された結果なので curl の終了 status も残します。

bash · コマンド
curl --show-error --include "http://localhost:8080/health"
printf 'curl_exit=%s\n' "$?"

実測(2026-08-25)

期待される出力
curl: (7) Failed to connect to localhost port 8080 after 0 ms: Couldn't connect to server
curl_exit=7

7-1. 停止した側に当たるまで、1 件ずつ送る

workstation01 から、毎回新しい event ID で実要求を送ります。入口は 2 台へ順番に振り分けるので、停止した app01 に当たるまで最大 2 件です。1 件ごとに応答と終了 status を保存し、どの event ID で失敗したかを記録します。

bash · コマンド
FAILED_EVENT_ID=
for N in 1 2; do
  EVENT_ID="event-fail-try-${N}"
  HTTP_STATUS="$(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\":10}" \
    --output "/tmp/ch4-try-${N}.txt" --write-out '%{http_code}' \
    "http://<front01の接続名>:<入口の待受port>/tap")"
  cat "/tmp/ch4-try-${N}.txt"
  printf 'try=%s event_id=%s http_status=%s\n' "$N" "$EVENT_ID" "$HTTP_STATUS"
  if [ "$HTTP_STATUS" -ge 500 ]; then FAILED_EVENT_ID="$EVENT_ID"; break; fi
done
printf 'failed_event_id=%s\n' "$FAILED_EVENT_ID" | tee /tmp/ch4-failed-event-id

🔴 失敗したかどうかを、curl の終了 status で判定してはいけません。 curl は --fail を付けないかぎり、 サーバが 502 Bad Gateway を返しても終了 status 0 を返します。「通信は成立した。返ってきた中身がエラーだった」 という扱いだからです。ここで見たいのは通信が成立したかどうかではなく、要求が処理されたかどうかなので、 判定には HTTP status(--write-out '%{http_code}')を使います。上のブロックは、その値が 500 以上なら失敗と数えます。

failed_event_id が空のまま終わったら、2 件とも 200 で返っています。その場合は次の順に確認してください。

  1. app01 で systemctl status tapapi.service が inactive (dead) になっているか(停止できていない)
  2. 入口の access log で、2 件の upstream が同じ 1 台を指していないか(もう 1 台へ届いていない)
  3. STEP 06 の proxy_next_upstream off が反映されているか(入口が別の系へ黙って送り直している)
期待される出力
HTTP/1.1 502 Bad Gateway
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html
Content-Length: 166
Connection: keep-alive

<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.24.0 (Ubuntu)</center>
</body>
</html>
try=1 event_id=event-fail-try-1 http_status=502
failed_event_id=event-fail-try-1

1 件目で HTTP 502 を検出し、failed_event_id に記録できました。

7-2. 続けて後続要求を送る

ログを見に行く前に、新しい event ID で後続要求を送ります。ここまでが fail_timeout の 30 秒以内に収まります。

bash · コマンド
for N in 1 2 3 4; do
  EVENT_ID="event-after-fail-${N}"
  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\":10}" \
    "http://<front01の接続名>:<入口の待受port>/tap"
done

実測(2026-08-25)

期待される出力
{"ok":true,"card_id":"card-demo","balance":920,"event_id":"event-after-fail-1","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":910,"event_id":"event-after-fail-2","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":900,"event_id":"event-after-fail-3","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":890,"event_id":"event-after-fail-4","node":"app02"}

✅ 4 件とも app02 が処理して継続している(app01 は除外時間の 30 秒間、経路から外れている)。

7-3. 4 つの記録を照合する

要求を送り終えてから、入口・両 App・DB の記録を突き合わせます。どの試行が失敗したかは決め打ちにせず、event-fail-try- で始まる行をすべて出して読みます。 成功した試行と失敗した試行が並ぶので、入口がどちらの App を選んだかが同じ表で見えます。

bash · コマンド
# front01
sudo grep -E 'event_id=event-(fail-try-|after-fail-)[0-9]+' \
  /var/log/nginx/tap_access.log

実測(2026-08-25)

期待される出力
<workstation01のアドレス> POST /tap HTTP/1.1 status=502 event_id=event-fail-try-1 upstream=<app01のアドレス>:8080 upstream_status=502
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-fail-try-2 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-after-fail-1 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-after-fail-2 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-after-fail-3 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-after-fail-4 upstream=<app02のアドレス>:8080 upstream_status=200

✅ 1 件目は入口が app01(.20)を選んで通信に失敗し、upstream 欄が 1 つだけ (=別の系へ再試行していない)。以降はすべて app02(.21)。

bash · コマンド
# app01 と app02 のそれぞれ
STOP_SINCE="<app01で記録した故障開始時刻>"
sudo journalctl -u tapapi.service --no-pager \
  --since "$STOP_SINCE" \
  | grep -E 'event-(fail-try-|after-fail-)[0-9]+'

実測(2026-08-25)

app01(停止時刻以降)

期待される出力
(該当なし)

app02

期待される出力
{"event_id":"event-fail-try-2","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-after-fail-1","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-after-fail-2","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-after-fail-3","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-after-fail-4","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}

✅ 失敗した event に app01 の処理完了記録が無い。 入口は app01 を選んだが、App まで届いていない。

最後に db01 で event ID と残高を確認します。

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 LIKE 'event-fail-try-%'
       OR event_id LIKE 'event-after-fail-%'
    ORDER BY created_at;"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"

実測(2026-08-25)

期待される出力
      event_id      |  card_id  | amount |          created_at           
--------------------+-----------+--------+-------------------------------
 event-fail-try-2   | card-demo |     10 | 2026-08-25 00:58:43.711194+00
 event-after-fail-1 | card-demo |     10 | 2026-08-25 00:58:43.743078+00
 event-after-fail-2 | card-demo |     10 | 2026-08-25 00:58:43.767964+00
 event-after-fail-3 | card-demo |     10 | 2026-08-25 00:58:43.792161+00
 event-after-fail-4 | card-demo |     10 | 2026-08-25 00:58:43.816349+00
(5 rows)

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

✅ 失敗した event-fail-try-1 だけが tap_events に無い。 5 行しかなく、残高も 5 件ぶんしか減っていない (940 → 890)。「失敗した 1 件は本当に失われた」ことが DB 側でも確認できる。

次の表に 4 種の記録をまとめます。行は event ID ごとに作り、失敗した試行がどれだったかを実測で埋めます。

event IDクライアント結果入口の upstreamApp journalDB の確定
失敗した試行(event-fail-try-N)失敗停止した App への通信失敗処理完了なしevent 行なし
成功した試行(あれば)成功正常な App同じ event ID ありevent 行と残高あり
後続要求(event-after-fail-N)成功正常な App同じ event ID ありevent 行と残高あり

ゴール到達 ✓ ① 同じアプリを 2 台へ設定差なく用意した / ② 3 つの記録から正常時の振り分け先を特定した / ③ 停止した側に当たった要求が失敗したことを入口ログで確認した / ④ 後続要求が残る App で継続し、共有 DB の event ID と残高が一貫することを確認した。

app01 を再開し、両系正常へ戻す

app01 の App service を再開します。service 状態と App 自身の状態を確認した後、受動方式で設定した除外時間を考慮しながら、新しい event ID の要求を複数回送ります。

bash · コマンド
sudo systemctl start tapapi.service
systemctl status tapapi.service --no-pager
# 待受が開くまで待つ(開かなければ 30 秒で諦める)
timeout 30 bash -c 'until curl -sf http://localhost:8080/health > /dev/null; do sleep 1; done'
curl --fail --show-error --include "http://localhost:8080/health"

実測(2026-08-25)

期待される出力
● 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-25 09:59:25 JST; 2s ago
   Main PID: 4555 (python3)
HTTP/1.0 200 OK
{"status":"ok","db":"ok","node":"app01"}

app01 の App が動いていても、入口はまだ app01 を経路から外したままです。fail_timeout=30s が経過するまで待ってから要求を送ります。待たずに送ると、すべて app02 が応答します。

bash · コマンド
# 除外時間(fail_timeout=30s)の経過を待つ
sleep 35
bash · コマンド
for N in 1 2 3 4 5 6; do
  EVENT_ID="event-recovered-${N}"
  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\":10}" \
    "http://<front01の接続名>:<入口の待受port>/tap"
done

実測(2026-08-25)

期待される出力
{"ok":true,"card_id":"card-demo","balance":880,"event_id":"event-recovered-1","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":870,"event_id":"event-recovered-2","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":860,"event_id":"event-recovered-3","node":"app01"}
{"ok":true,"card_id":"card-demo","balance":850,"event_id":"event-recovered-4","node":"app02"}
{"ok":true,"card_id":"card-demo","balance":840,"event_id":"event-recovered-5","node":"app01"}
{"ok":true,"card_id":"card-demo","balance":830,"event_id":"event-recovered-6","node":"app02"}

✅ app01 の node 名が再び現れた。 ただし 厳密な交互ではない(app02・app02・app01・app02・app01・app02)。

入口ログと両 App の journal を照合し、復帰後の event ID が app01 と app02 の両方へ届いたことを確認します。DB でも同じ event ID と残高を確認します。

bash · コマンド
# front01
sudo grep -F 'event_id=event-recovered-' /var/log/nginx/tap_access.log

# app01 と app02 のそれぞれ
sudo journalctl -u tapapi.service --no-pager \
  | grep -F 'event-recovered-'

# db01
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 LIKE 'event-recovered-%'
    ORDER BY created_at;"
sudo -u postgres psql -P pager=off -d transit_lab -c \
  "SELECT card_id, balance FROM cards WHERE card_id = 'card-demo';"

実測(2026-08-25)

front01

期待される出力
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-1 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-2 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-3 upstream=<app01のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-4 upstream=<app02のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-5 upstream=<app01のアドレス>:8080 upstream_status=200
<workstation01のアドレス> POST /tap HTTP/1.1 status=200 event_id=event-recovered-6 upstream=<app02のアドレス>:8080 upstream_status=200

app01

期待される出力
{"event_id":"event-recovered-3","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}
{"event_id":"event-recovered-5","card_id":"card-demo","amount":10,"node":"app01","status":200,"result":"ok"}

app02

期待される出力
{"event_id":"event-recovered-1","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-recovered-2","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-recovered-4","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}
{"event_id":"event-recovered-6","card_id":"card-demo","amount":10,"node":"app02","status":200,"result":"ok"}

db01

期待される出力
     event_id      |  card_id  | amount |          created_at           
-------------------+-----------+--------+-------------------------------
 event-recovered-1 | card-demo |     10 | 2026-08-25 01:00:13.450054+00
 event-recovered-2 | card-demo |     10 | 2026-08-25 01:00:13.476982+00
 event-recovered-3 | card-demo |     10 | 2026-08-25 01:00:13.502812+00
 event-recovered-4 | card-demo |     10 | 2026-08-25 01:00:13.529724+00
 event-recovered-5 | card-demo |     10 | 2026-08-25 01:00:13.555179+00
 event-recovered-6 | card-demo |     10 | 2026-08-25 01:00:13.581179+00
(6 rows)

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

✅ 入口ログ・両 App の journal・DB の 3 つが同じ 6 件で一致し、残高は 890 → 830。GOAL の状態に到達している。

app01 と app02 の両方が正常に応答し、入口から再び両系へ要求が届くことを確認してから、この章を終えてください。 停止したまま終了手順へ進んではいけません。

演習の証跡を保存した後、章が作ったものを台ごとに削除します。これは第 3 章の再学習ではなく、自分が作ったものは毎章自分で消すという運用上の規律です。削除コマンドが成功しただけでは終わりにせず、最後に 8 分類で不在を確認します。

8-1. 消す前の状態を記録する

front01、db01、app01、app02 の各ターミナルで hostname を記録し、操作する台を取り違えていないことを確認します。db01 では PostgreSQL が動いているうちに設定パスを取得し、同じターミナルで後続手順を続けます。

bash · コマンド
hostname
test -s /tmp/part4-packages-before.tsv
sudo ufw status verbose

# db01 だけで実行する
PG_CONF="$(sudo -u postgres psql -Atc 'SHOW config_file;')"
PG_HBA="$(sudo -u postgres psql -Atc 'SHOW hba_file;')"
PG_CONF_DIR="$(dirname "$PG_CONF")"
printf 'config=%s\nhba=%s\n' "$PG_CONF" "$PG_HBA"

8-2. app01 と app02 の service を止める

次のブロックを app01 で実行し、終了したら新しく app02 へ接続してもう一度実行します。先頭の hostname を証跡に残し、同じ台で 2 回実行していないことを確認します。

bash · コマンド
hostname
sudo systemctl disable --now tapapi.service
systemctl is-active tapapi.service || true
systemctl is-enabled tapapi.service || true

8-3. front01 の章専用 nginx 設定を外す

certificate(サーバ証明書)を使わない本章でも、章専用設定と有効化先は別物です。先に有効化先、次に設定本体を外し、nginx 本体がまだある状態で構文検査と reload を通します。その後、章専用 access log を削除します。

bash · コマンド
hostname
sudo rm -f "<nginxの有効化先>"
sudo rm -f "<nginxの章専用設定ファイル>"
sudo nginx -t
sudo systemctl reload nginx
sudo rm -f /var/log/nginx/tap_access.log /var/log/nginx/tap_access.log.*
test ! -e "<nginxの有効化先>"
test ! -e "<nginxの章専用設定ファイル>"

8-4. db01 の DB、role、設定を戻してから package を外す

App service が止まった後で DB、role(DB 内でユーザや権限を表すもの)の順に削除します。続いて動的に取得した設定パスを使い、章専用 drop-in と pg_hba.conf の 2 行を除去します。PostgreSQL を再起動して DB と role が 0 行、追記が 0 行であることを確認してから package を削除します。package の削除は章開始時の一覧との差分(comm -13)を取り、許可リストに無い package が 1 つでもあれば exit 1 で停止します。dpkg-query の ${binary:Package} は package によって :amd64 付きで返るため、比較は case "${pkg%:amd64}" in で接尾辞を外して行います。exit 1 はブロック全体を ( ... ) のサブシェルで包んであるため、対話 SSH は切断されません。

bash · コマンド
(
sudo -u postgres dropdb --if-exists transit_lab
sudo -u postgres dropuser --if-exists tap_app

sudo rm -f "$PG_CONF_DIR/conf.d/part4.conf"
sudo sed -i \
  -e '\|^host transit_lab tap_app 172\.16\.10\.20/32 scram-sha-256$|d' \
  -e '\|^host transit_lab tap_app 172\.16\.10\.21/32 scram-sha-256$|d' \
  "$PG_HBA"
sudo systemctl restart postgresql

test ! -e "$PG_CONF_DIR/conf.d/part4.conf"
test "$(sudo grep -cE '^host transit_lab tap_app (172\.16\.10\.20|172\.16\.10\.21)/32 scram-sha-256$' "$PG_HBA" || true)" -eq 0
test "$(sudo -u postgres psql -Atc "SELECT count(*) FROM pg_database WHERE datname='transit_lab';")" -eq 0
test "$(sudo -u postgres psql -Atc "SELECT count(*) FROM pg_roles WHERE rolname='tap_app';")" -eq 0

# 基準状態の db01 に PostgreSQL は無い。ここに出る cluster は本章が作ったもの。
# 見覚えのない 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

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
    postgresql|postgresql-16|postgresql-client|postgresql-client-16|postgresql-client-common|postgresql-common|libcommon-sense-perl|libjson-perl|libjson-xs-perl|libllvm17t64|libpq5|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
)
((${#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

8-5. App、nginx、APT 取得経路を削除する

app01 と app02 では unit、配置物、専用 user と同名 group、依存 package の順に削除します。front01 では nginx package を削除します。台ごとに別ブロックとし、どちらも章開始時の package 一覧との差分に許可リストを適用してから purge します。想定外の package を検出した exit 1 はブロックを包む ( ... ) の中だけを止め、対話 SSH は切断しません。

bash · コマンド
# app01 と app02 の両方で、hostname を確認して実行する
(
hostname
sudo rm -f /etc/systemd/system/tapapi.service
sudo systemctl daemon-reload
sudo rm -rf /opt/tapapi /etc/tapapi
sudo userdel tapapi 2>/dev/null || true
getent group tapapi >/dev/null && sudo groupdel 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%:amd64}" in
    python3-psycopg2|libpq5) ;;
    *) 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

# front01 で実行する
(
hostname

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
    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

次のブロックは 4 台すべてで実行します。既存の APT source には最初から触れていないため、退避や復元は不要です。章専用ディレクトリと keyring を削除し、不在を確認するだけで済みます。

bash · コマンド
sudo rm -rf /etc/apt/sources.list.d.part4
sudo rm -f /etc/apt/keyrings/infra-study-archive-keyring.asc
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc

8-6. 一時物を片付ける

4 台では取得鍵、APT log、取得書庫と、残っていれば現在のシェルが保持する展開先を削除します。鍵の fingerprint 照合は使い捨ての GnuPG home(mktemp -d)で行い、照合直後に rm -rf で削除しているため、~/.gnupg には最初から触れていません。

bash · コマンド
rm -f /tmp/infra-study-archive-keyring.asc /tmp/apt-update-part4.log /tmp/tapapi-1.1.tar.gz
[ -z "${TAPAPI_TMP:-}" ] || rm -rf -- "$TAPAPI_TMP"

# app01 と app02
rm -f /tmp/ch4-parity-*.txt
# app01
rm -f /tmp/ch4-app01-stop-since
# workstation01
rm -f /tmp/ch4-try-*.txt /tmp/ch4-failed-event-id

8-7. 8 分類で残留が無いことを確認する

各台で章開始前の package 一覧と最終的に比較し、差分が 0 行であることを確認します。依存 package は 8-4・8-5 の許可リスト付き purge で、章開始時との差分に含まれる package だけを名指しで消しているため、想定外の package が残っていればそこで停止しています。ここでは取り残しが無いことの最終確認だけを行います。

bash · 検査
dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort > /tmp/part4-packages-after.tsv
diff -u /tmp/part4-packages-before.tsv /tmp/part4-packages-after.tsv

systemctl status tapapi.service --no-pager || true
systemctl status nginx --no-pager || true
systemctl status postgresql --no-pager || true
sudo ss -lntp | grep -E ':(5432|8080)([[:space:]]|$)' || true

test ! -e /opt/tapapi
test ! -e /etc/tapapi
test ! -e /etc/systemd/system/tapapi.service
test ! -e "<nginxの章専用設定ファイル>"
test ! -e "<nginxの有効化先>"
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
getent passwd tapapi || true
getent group tapapi || true
getent passwd postgres || true
getent group postgres || true
getent group ssl-cert || true
sudo ufw status numbered

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

8 分類を自分で列挙して検査したあと、その外側に残る変更を、構築前後の sorted manifest で確認します。差分は列挙の代わりではなく、列挙から漏れた分類を見つける安全網です。全差分は /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 は操作履歴として更新されるため、章が配置したファイルとは分けて扱います。
  • /var/lib/snapd/state.json・/var/lib/snapd/maintenance.json と /var/lib/logrotate/status は、snapd と logrotate が章と無関係に定期的に書き換える状態ファイルです。親ディレクトリは除外しません。
bash · manifest 差分
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
    echo "差分の表示に失敗しました。構築前・章末の manifest と /var/tmp/part4-chapter-manifest.diff は残してあります。表示された理由を確認してください" >&2
  fi
else
  echo "差分を採れませんでした(diff の終了コード $diff_rc)。構築前 manifest /tmp/part4-manifest-before.tsv があるか確認してください" >&2
fi

表示は 2 つの節に分かれます。合格の条件は、1 つ目の「残留候補」が 0 件であることです。残留候補は、構築前になかったパスが増えた、あったパスが消えた、種別・権限・所有者・サイズが変わった、のいずれかです(ディレクトリのサイズは比べません)。

2 つ目の「更新日時だけ変わった通常ファイル」は、中身の属性は同じで更新日時だけが違うファイルです。ここに出る行は 1 件ずつ、次のどちらかで説明できなければ完了にしません。

  • OS の自動処理(package 索引の更新、通知用の記録など)が書いたもの。
  • 章の操作の跡で、中身は戻っているもの(例: ユーザを作って消したあとの /etc/passwd)。

どちらでも説明できない行が 1 件でもあれば、それは自分が挙げた 8 分類の外側にある残留です。何の分類にあたるかを考えて「9 番目の分類」を自分の後片付け手順へ足し、解消後に再確認してください。

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

合格条件は、package 差分 0 行、章専用 service なし、章の port 待受なし、DB と role 0 行(8-4 で確認)、章専用設定なし、tapapi user・group なし、公開ルールが章開始時と同じ、章専用 TLS 秘密鍵なし(本章では作成 0 件)、ファイル差分に未解消の残留候補なしです。実機出力はこの改訂後の手順で採り直してから掲載します。なお TLS(通信を暗号化し、相手を証明書で確かめる仕組み)は Chapter 5 で扱います。

検査結果を証跡へ保存した後、検査用の一時一覧も削除します。

bash · 最後の一時物
rm -f /tmp/part4-packages-before.tsv /tmp/part4-packages-after.tsv
sudo rm -f /tmp/part4-manifest-before.tsv /tmp/part4-manifest-after.tsv /var/tmp/part4-chapter-manifest.diff
test ! -e /var/tmp/part4-chapter-manifest.diff

つまずきポイント

ハマり #1 · 片方にしか要求が行かない

2 台とも正常なのに、複数の要求が片方の App にしか記録されません。

確認順序

  1. nginx が実際に読み込んだ設定に 2 台の server があるか確認する。
  2. nginx -t の成功後に reload したか確認する。
  3. 入口ログの upstream address を複数要求分並べる。
  4. 両 App の service 状態、待受、journal を確認する。
  5. 直前の失敗で一方が一時除外されていないか、設定した時間とログを確認する。
  • 原因: 設定ファイルを編集しただけで reload していない、片方の接続名・port が誤っている、過去の通信失敗による一時除外中などが考えられます。
  • 対処: 構文、反映、接続、実際の選択結果の順に調べます。応答の node 名だけで判断せず、入口ログと App journal を照合します。
ハマり #2 · 2 台で結果が食い違う

処理した node によって残高や event の結果が変わります。

確認順序

  1. 両 App の DB host、DB 名、接続ユーザが同じか、安全な方法で照合する。
  2. 同じ event ID がどちらの App journal にあるか確認する。
  3. db01 の tap_events と cards を同じ ID で検索する。
  4. App のローカルディスクへ業務状態を書き込む設定が無いか確認する。
  5. App のローカルへ業務状態を書き込む処理がないか確認する。
  • 原因: 片方だけ別の DB を参照している、設定差が残っている、またはローカルへ残高を保持している可能性があります。
  • 対処: node 固有値以外の設定をそろえ、残高とイベントの記録先を共有 DB だけにします。値を手で合わせるのではなく、異なる接続先や書き込み元を取り除きます。
ハマり #3 · App を停止したのに失敗が続く、または復旧後に戻らない

最初の失敗後も 502 が返り続ける場合と、App を再開したのに要求が戻らない場合を分けて調べます。

確認順序

  1. 入口ログで各失敗の upstream address と upstream status を確認する。
  2. 正常な App の service、待受、journal を確認する。
  3. upstream の 2 台に設定した失敗回数と除外時間を確認する。
  4. proxy_next_upstream off が対象の location に反映されているか確認する。
  5. 復旧時は App 自身と DB 接続の状態を確認し、除外時間を過ぎた後の新しい要求を観測する。
  • 原因: 正常側も失敗している、2 台の指定が同じ接続先になっている、意図した設定が reload されていない、一時除外の期間中である可能性があります。
  • 対処: 値を短く変えて結果を合わせる前に、構文、反映、両 App の状態、入口ログの順で事実をそろえます。復旧後は 1 件の成功だけで終えず、両 App の journal に新しい event ID が現れるまで確認します。
ハマり #4 · 停止したのに最初の要求が成功する

受動方式では、次にどちらの App が選ばれるかは、それまでの要求順序に左右されます。停止後の 1 件目が正常な app02 へ渡れば、その要求は成功します。

確認順序

  1. 入口ログの upstream address を見て、どちらが選ばれたか確認する。
  2. event ID を変え、1 件ずつ要求を送る。
  3. 停止した app01 への最初の通信失敗が記録されるまで、要求ごとの結果を保存する。
  • 原因: 故障検出前でも、停止側が毎回最初に選ばれるとは限りません。
  • 対処: 「停止後の最初の送信」と「停止側へ送られた最初の実要求」を区別します。後者が故障検出に必要な失敗要求です。

修了確認

問 1 · upstream と状態確認の役割を分ける

upstream に 2 台を登録したことと、2 台が正常であることはなぜ同じ意味ではありませんか。また、/health は本章で何を確認し、何には使いませんか。

問 2 · 最初の失敗要求が必要な理由を説明する

app01 を停止した直後、なぜ nginx は要求を送る前からその経路を外せないのでしょうか。最初の失敗と後続要求について、どの記録を確認しますか。

問 3 · 処理した App が違っても残高が一貫する条件を答える

app01 と app02 が交互に要求を処理しても、残高と event ID を一貫させるには、状態をどこへ置き、何を避ける必要がありますか。

問 4 · 復旧を 1 件の成功だけで終えない

停止した App を再開した後、1 件の要求が成功しただけでは両系正常と言えないのはなぜですか。章を終える前に何を確認しますか。

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

問 1 のヒントと答え

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

「登録されていること」と「今、正常であること」を分けて考えてください。

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

nginx の章専用ファイルの upstream 設定と nginx -t を確認し、各 App の /health が何のために使われ、入口の振り分け判断には使われないことを確かめます。実際の振り分けは access log の upstream address と App journal を event ID で照合します。

答えを見る

upstream は要求の渡し先一覧であり、登録時点の App の正常性を証明しません。/health は受講者と受入試験が App 自身と DB 接続の状態を確認するために使います。入口がそれを定期確認して振り分け先を決める材料には使いません。実際の振り分けは access log の upstream address と App journal を event ID で照合します。

問 2 のヒントと答え

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

nginx が実際に要求を送ってみるまで、何が分からないままかに注目してください。

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

app01 停止直後、入口の access log で upstream address と status を確認し、最初に失敗した実要求を特定します。後続要求は正常側の App journal と DB の event 行・残高を event ID で照合します。

答えを見る

OSS の nginx を本章で構成する受動方式は、実要求の通信失敗を基に経路を一時除外します。定期的な能動確認を行わないため、停止側へ送られて失敗した実要求が最低 1 件必要です。最初の失敗は入口の access log で upstream address と status を確認し、後続要求は正常側の App journal と DB の event 行・残高まで event ID で照合します。

問 3 のヒントと答え

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

残高と event ID という業務の状態を、どこに 1 つだけ置くかに注目してください。

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

tap_events と cards を、app01 と app02 のどちらが処理した要求でも同じ場所として確認し、各 App のローカルディスクに別々の残高が無いことを確かめます。

答えを見る

残高と event ID は両 App が使う同じ DB へ置きます。各 App のローカルディスクへ別々の残高を持たせず、記録先を共有 DB だけにします。

問 4 のヒントと答え

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

1 件の成功が、本当に再開した App を通ったのかに注目してください。

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

再開した App の service と /health で今の状態を確認し、除外時間を考慮して複数の新しい要求を送ります。入口ログと両 App の journal を event ID で照合し、tap_events と cards の一貫性を確認します。

答えを見る

その 1 件を、停止していなかった App が処理した可能性があるためです。再開した App 自身と DB 接続の状態を確認し、除外時間を考慮して複数の新しい要求を送ります。入口ログと両 App の journal を event ID で照合し、両方へ要求が届き、DB の event と残高が一貫していることを確認してから終えます。

止まりにくい入口を、公開してよい入口へ

本章で App が 1 台停止しても後続要求を継続できる形になりました。しかし、入口の通信はまだ暗号化されず、接続できる範囲も絞っていません。次章では TLS とファイアウォールを加え、誰がどの入口へ到達できるかを制御します。

Chapter 5: TLS とファイアウォールで公開範囲を絞る

  • 入口の通信を TLS で保護する
  • 必要な送信元と port だけを許可する
  • 保護後も振り分けと App の処理が続くことを確認する