演習進捗0 / 1未完了
⏱ 約 120 分
Part 4Chapter 06/サーバ構築

改札サービスを建て直し、運用へ引き渡す 🎓

入口、App 2 台、DB を構築前の初期状態(clean)の VM から組み立て、受入試験、故障比較、後始末までを、自分で取った実行記録で確かめます。

教材 ✓ 公開中登録不要 実機演習 ― 提供していない読んで学べます
所要時間約 120 分(概念 20 分+演習 100 分・見積もり)
前提知識Part 1〜Part 3 の既習事項、Part 4 Chapter 1〜5 の概念と操作
使用機材入口サーバ front01、アプリサーバ app01・app02、DB サーバ db01、操作端末 workstation01、LAN 内の教材取得経路
関連章前: Part 4 / Chapter 5「TLS とファイアウォールで、公開範囲を絞る」/後: Part 5(建設中)

部品を覚えたあとに、系を建て直す

ここまでに、入口の導入、DB の整合性、Web・App(タップを処理するアプリケーション)・DB の接続、2 台の App への振り分け、TLS(通信を暗号化し、相手を証明書で確かめる仕組み)と公開範囲を扱いました。本章では新しい技術を増やしません。4 台を clean な状態から使い、これらの部品を 1 本の改札サービスとして自分の計画で組み立て直します。

手順をなぞってコマンドが成功しただけでは、修了にはなりません。最初に構成図、port 表、package 一覧、作業順、確認項目、戻し方を決めます。構築後は 7 項目の受入試験(章の成果が合格条件を満たすかの確認)を行い、期待結果と証跡が一致したことを示します。最後に作業票を完成させ、故障の影響範囲を比較し、自分で作ったものを後始末します。

期待される出力
                         受入試験を行う 1 サービス

workstation01 ── TLS ──> front01 ──┬──> app01 ──┐
                                    └──> app02 ──┴──> db01

計画する → 建てる → 7 項目で試験する → 作業票へ引き渡す → 壊れ方を比べる → 消す

この章のゴール: clean な状態から入口、App 2 台、DB を 1 本の系として組み立て、受入試験 7 項目の期待結果を証跡で示せる。さらに、運用へ渡す作業票を作り、片系故障と入口障害の影響範囲の差を説明し、8 分類の後始末まで完了できる。

この章で建て直す 4 台の構成は、街の改札サービス全体にあたります。街の全体像は Part 0(この教材で作る街)にまとめています。

扱わない範囲

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

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

構築の計画、受入試験、運用引渡し、後始末を、自分で取った実行記録で確かめながら最後までやり切れるようになります。本章で身につく 4 動作 は次のとおりです。

01
① 計画して系を建てる
構成図・port 表・package 一覧・作業順・確認項目・戻し方を先に決め、4 台へ入口・App・DB を構築する。
02
② 受入条件を証明する
正常系・制約(不正な値を DB が拒否する決まり)・再送・振り分け・App 自身の状態・片系停止(2 台のうち 1 台だけを止めること)・到達拒否の 7 項目を、期待結果と証跡で判定する。
03
③ 運用へ引き渡す
構成・service・ログ・状態確認・既知の制約・戻し方を 1 枚の作業票へまとめ、監視候補を列挙する。
04
④ 壊れ方を比べて片付ける
App 片系故障と入口障害の影響範囲を比較し、作ったものが 8 分類で消えたことと、意図的に残すものを分けて記録する。

合格、状態の正本、影響範囲、引渡しを言葉にする

1. 受入試験は「成功したコマンドの一覧」ではない

受入試験では、操作より先に期待結果を定義します。たとえば HTTP 応答が成功しても、DB に event と残高が意図どおり確定していなければ、正常タップは合格ではありません。逆に拒否されるべき要求は、失敗したことに加えて、どの境界が拒否したかを証拠から説明できて初めて合格です。なお、見出しの「状態の正本」とは、最終的に正しい状態とみなす保存先のことです。

各試験は、次の 4 列で記録します。

受入項目期待結果採る証跡判定
何を試すか成功・拒否・継続など、試験前に決めた結果応答、ログ、journal、DB 行、到達表PASS / FAIL

「コマンドの終了 status が 0」だけを判定にしません。異なる層の証拠を対応づけ、期待結果と一致した理由を一文で書きます。

2. 命名は隔離ではない

共有領域へセッション名を付けると、どのデータかは見分けやすくなります。しかし、名前を分けただけでは、別のセッションから読めない・書けないことは保証されません。命名は隔離ではないため、アクセス境界を正負テスト(許可されるべき操作と拒否されるべき操作の両方を試すこと)で確認する必要があります。

共有領域は VM の外側にあります。VM 内の変更を戻しても、共有領域のファイルは残り得ます。本章では共有領域へ書き込まず、この隔離(VM の外に影響を出さないこと)、作成から削除までの扱い(lifecycle)、後始末のあとに何も残っていないかの検査(残留検査)を、Part 6 が成り立つための前提として引き渡します。Part 4 の業務状態は PostgreSQL に限定し、DB schema(テーブルや制約などの DB の構造)を後始末の対象にします。

3. 冗長化した層と、していない層では壊れ方が違う

App は 2 台あり、入口は 1 台です。App の片方を停止すると、最初の実要求は失敗し得ますが、その失敗を入口が観測した後は、残る App へ後続要求を渡せます。一方、入口そのものが要求を受けられなければ、後ろの App と DB が正常でもサービス全体へ入る経路がありません。

期待される出力
App 片系故障 : front01 ──X app01
                       └──> app02 ──> 継続

入口障害     : workstation01 ──X front01     App と DB が正常でも全体停止

この差は、障害の大きさを感覚で比べたものではありません。冗長化してある層には代替経路があり、冗長化していない入口には代替経路がないため、影響範囲が変わります。STEP 8 では 2 つを同時に壊さず、1 つずつ作って正常へ戻してから次へ進みます。

4. 引渡しは「次の人が確認と復旧を始められる情報」にする

構築した本人だけが service 名やログの場所を知っている状態は、運用へ渡せた状態ではありません。作業票には、次の担当者が現在状態を確認し、既知の制約を理解し、異常時の切り分けと戻し作業を始められる情報をまとめます。

本章は監視基盤を導入しません。監視対象、確認方法、正常の目安、異常時に見る証跡を作業票へ渡すところまでを担当します。

演習:改札サービスを建て直し、証跡で引き渡す

演習環境

項目値
構築対象front01、app01、app02、db01
操作・観測端末workstation01
接続方法workstation01 から各 VM の接続名を使って SSH 接続
必要権限sudo。package 導入、設定配置、service 操作、演習用の公開ルール設定、後始末に使う
取得経路演習環境で指定された LAN 内ミラーと教材配布元
所要時間約 100 分(STEP 1〜8 の実行・見積もり)

🛟 失敗しても安全: 変更対象は演習用 VM だけです。構築前に戻し方を計画し、故障実験は 1 要因ずつ行います。管理接続が不安定になった場合は先へ進まず、演習環境の終了手順に従ってください。

山括弧で囲んだ値は、演習環境または教材の案内に表示された値へ置き換えてください。教材資産の取得元と検証値は確定しています。コマンドの実出力は、実行時刻や割り当てられたアドレスによって変わります。FW(ufw)と interface は Chapter 5 と同じもので確定しています。

手を動かす前に構築計画を作る

最初の STEP では、まだ package を導入しません。まず作業票の雛形を取得し、SHA-256 が一致したことを雛形の「取得した教材資産の照合記録」へ記録します。不一致ならそこで中止し、記入や構築へ進みません。この作業票は STEP 7 の運用引渡しへ育てるため、書き捨てにしません。

作業票を取得する前に、db01、app01、app02、front01、workstation01 の各台で、path・type・mode・uid・gid・size・mtime の sorted manifest(ファイルの場所・権限・更新日時などを一覧にしたもの)を記録します。STEP 8 では、同じ条件で採った章末 manifest と比較します。

さらに db01、app01、app02、front01 の 4 台では構築前の package 一覧も採り、章末に現在の一覧との差分が空であることを確認します。

bash · コマンド
# 5 台の各ターミナルで、章のほかの操作より先に実行する
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

# db01・app01・app02・front01 の 4 台で実行する
dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort > /tmp/part4-packages-before.tsv
bash · コマンド
WORKSHEET_FILE="$HOME/handover-worksheet-1.0.md"
WORKSHEET_SHA256=54f09f326972789242bf9be132ae7987ffecaef1bf3679692c09d233a39ff0de
curl --fail --show-error --location \
  --output "$WORKSHEET_FILE" \
  "http://172.16.10.22/assets/part4/handover-worksheet-1.0.md"
printf '%s  %s\n' "$WORKSHEET_SHA256" "$WORKSHEET_FILE" \
  | sha256sum --check -
sha256_status=$?
((sha256_status == 0)) || echo 'SHA-256 mismatch: 以降の記入・構築へ進まないこと' >&2
test "$sha256_status" -eq 0
test "$(stat -c %s "$WORKSHEET_FILE")" -eq 9980

sha256sum が OK、サイズが 9980 bytes であることを確認してから記入を始めます。取得元、期待する SHA-256、照合結果、照合日時も同じ作業票へ残してください。

1-1. 構成図と port 表を作る

次の論理経路を図にし、各矢印に protocol、接続先 port、送信元、待受 service を記入します。

期待される出力
workstation01 → front01 → app01 → db01
                          app02 → db01

作業票の「1. 構成と経路」へ、次の 5 経路を記入します。本文には記入欄を重ねず、ここでは決める値と確認方法を示します。

  • workstation01 → front01: <指定値>、<入口service名>、TLS 要求と入口ログでの確認方法
  • front01 → app01: <指定値>、<App service名>、応答 node・upstream(転送先の一覧)・journal での確認方法
  • front01 → app02: <指定値>、<App service名>、応答 node・upstream・journal での確認方法
  • app01 → db01: <指定値>、<DB service名>、App journal と DB 接続での確認方法
  • app02 → db01: <指定値>、<DB service名>、App journal と DB 接続での確認方法

service network(サービスを構成する VM 同士が通信するネットワーク。管理経路とは別)側の interface は Chapter 5 で使ったものと同じです。各 port は教材の指定値を転記し、推測で埋めません。

期待される出力
(2026-09-02 実測) 実アドレスは VLAN10 側だけを示します。
--- front01 ---
eth0             UP    <管理LANのアドレス>
ens19.10@ens19   UP    172.16.10.23/24
--- app01 ---
ens18            UP    <管理LANのアドレス>
ens20.10@ens20   UP    172.16.10.20/24
--- app02 ---
ens18            UP    <管理LANのアドレス>
ens20.10@ens20   UP    172.16.10.21/24
--- db01 ---
eth0             UP    <管理LANのアドレス>
ens19.10@ens19   UP    172.16.10.24/24

port の指定元は次のとおりです。推測ではなく、それぞれの根拠を書きます。

対象port指定元
入口 HTTPS8443自分で決めて作業票に記入(Chapter 5 と同じ値を使うと比較しやすい)
App service8080教材アプリ tapapi.service の待受
DB5432PostgreSQL の既定
管理接続22sshd の既定

1-2. package 一覧と作業順を作る

ホストごとに、必要な package、教材資産、設定ファイル、service を分けて書きます。package 名を推測せず、教材の指定値を転記してください。

作業票の「2. package・教材資産・作業順」へ、次の内容を記入します。db01 は <DB package名> と <初期SQL>、app01 は <Appに必要なpackage名> と <教材アプリ>、app02 は <Appに必要なpackage名> と <教材アプリ>、front01 は <入口package名> と自分で作る <入口設定素材> を記録します。各行には主な設定、構築後の確認方法、完了判定も残してください。

作業順は、db01 → app01・app02 → front01 とします。依存先から先に作ることで、接続失敗時に「まだ相手がない」のか「設定が誤っている」のかを分けやすくします。

1-3. 期待結果と証跡欄を先に作る

各 STEP について、コマンド、期待結果、証跡の保存先、失敗時に戻す対象を記入します。

STEP期待結果証跡戻す対象完了
取得全 VM が LAN 内の指定経路を利用できる取得元の確認結果取得設定☐
データ層制約を持つ schema と両 App からの DB 接続が成立schema、制約、接続の記録DB☐
App 層2 台が同じ設定で DB へ接続できるservice、socket、journalApp 設定☐
入口TLS 入口から 2 台へ転送できる構文、TLS、入口ログ入口設定☐
受入7 項目が期待結果と一致する受入証跡一式不合格の層☐
後始末8 分類に残留がなく、残すものを記録できている検査記録残留した構築物☐

コマンドの成功だけを完了欄へ書かないでください。「期待結果と証跡が一致した」ときだけ完了にします。

LAN 内の取得経路を確認して必要 package を導入する

4 台へ同じ取得経路設定を適用します。接続の順序は、STEP 01 で決めた作業順と同じ db01 → app01 → app02 → front01 です。計画と実行の順序を一致させることで、作業票のホスト別証跡と端末の記録が同じ並びになります。以下は 1 台分の共通ブロックです。各サーバの中で同じブロックを実行します。手順の意味は第 1 章の STEP 01を参照してください。

bash · コマンド
# db01 → app01 → app02 → front01 の順で 1 台ずつ接続し、各 VM 内で共通ブロックを実行する
ssh deployer@db01
# 完了後に exit し、app01、app02、front01 で繰り返す
bash · コマンド
# ここからが各 VM 共通の取得経路ブロック(既存 source には触れず、章専用ディレクトリだけを使う)
REPO_URI='http://172.16.10.22/ubuntu'
curl --fail --show-error --head "$REPO_URI/dists/noble/InRelease"
curl --fail --show-error "$REPO_URI/infra-study-archive-keyring.asc" \
  -o /tmp/infra-study-archive-keyring.asc

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"
echo "fingerprint match=$fingerprint_status"
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/*.sources 2>/dev/null || true
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
# 取得経路ブロックここまで
期待される出力
🔴 実機で採り直しが必要(章専用の取得経路へ変更後は未検証)。以下は 2026-09-02 実測の byte 数・package 版数はそのまま、パスだけを新しい章専用ディレクトリへ機械的に置き換えた参考値。4 台とも同一になる想定。
fingerprint match=0
/etc/apt/sources.list.d.part4/infra-study.sources:1:Types: deb
/etc/apt/sources.list.d.part4/infra-study.sources:2:URIs: http://172.16.10.22/ubuntu
/etc/apt/sources.list.d.part4/infra-study.sources:3:Suites: noble
/etc/apt/sources.list.d.part4/infra-study.sources:4:Components: part4
/etc/apt/sources.list.d.part4/infra-study.sources:6:Signed-By: /etc/apt/keyrings/infra-study-archive-keyring.asc

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]

Signed-By が指すのは、直前に fingerprint を照合した鍵です。照合していない鍵を指していれば、 署名検証は通っても「誰の署名か」を確かめたことになりません。

計画表の package 一覧と実際の導入対象を照合してから、各 VM に必要なものだけを導入します。package 名は STEP 01 の 1-2 で自分の作業票に転記した値を使います。

bash · コマンド
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 <このVMに必要なpackage名>
期待される出力
(2026-09-02 実測) `apt-cache policy` で取得元まで確認します。
--- db01 ---
postgresql:
  Installed: 16+257build1.1
  Candidate: 16+257build1.1
        500 http://172.16.10.22/ubuntu noble/part4 amd64 Packages
postgresql-client:
  Installed: 16+257build1.1

--- app01 / app02 ---
python3-psycopg2:
  Installed: 2.9.9-1build1
        500 http://172.16.10.22/ubuntu noble/part4 amd64 Packages

--- front01 ---
nginx:
  Installed: 1.24.0-2ubuntu7.15
        500 http://172.16.10.22/ubuntu noble/part4 amd64 Packages

4 台とも取得元は内部リポジトリだけです。Installed と Candidate が一致していることも、 「入れたものが、これから入るものと同じ」ことの確認になります。

導入後は package 一覧を作業票へ転記します。この時点では service の起動成功だけを系の完成と判定しません。

データ層を用意する

3-1. DB service・socket・接続を分けて確認する

db01 で DB service を起動し、service 状態、待受 socket、ローカル接続を別々に確認します。

bash · コマンド
sudo systemctl start <DB service名>
systemctl status <DB service名> --no-pager
ss -lntp
sudo -u <DB管理ユーザ> psql -Atc "SELECT current_database();"
期待される出力
(2026-09-02 実測)
=== service ===
* postgresql.service - PostgreSQL RDBMS
     Loaded: loaded (/usr/lib/systemd/system/postgresql.service; enabled; preset: enabled)
     Active: active (exited) since Wed 2026-09-02 02:10:11 UTC; 50s ago

=== socket(起動直後・設定を入れる前)===
LISTEN 0  200   127.0.0.1:5432   0.0.0.0:*
LISTEN 0  200       [::1]:5432      [::]:*

=== ローカル接続 ===
postgres

🔴 3 つを分けて見る意味がここに出ます。service は active ですが、 待受は loopback だけで、他の VM からは 1 件も届きません。 「起動した」と「使える」は別だ、という判断がこの 3 行から立ちます。

待受アドレスを設定して再起動すると、次のように変わります。

期待される出力
172.16.10.24
LISTEN 0  200   172.16.10.24:5432   0.0.0.0:*   users:(("postgres",...))

3-2. schema と制約を作る

初期 SQL は単独では配布されません。教材アプリの書庫を取得し、書庫の SHA-256 が一致した場合だけ一時ディレクトリへ展開します。不一致ならそこで中止し、SQL の確認や実行へ進みません。

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 - \
  && test "$(stat -c %s "$TAPAPI_ARCHIVE")" -eq 2728; then
  tar -tzf "$TAPAPI_ARCHIVE"
  TAPAPI_TMP="$(mktemp -d)"
  tar -xzf "$TAPAPI_ARCHIVE" -C "$TAPAPI_TMP"
  INITIAL_SQL="$TAPAPI_TMP/tapapi/schema.sql"
  test -f "$INITIAL_SQL"
  sed -n '1,240p' "$INITIAL_SQL"
else
  echo 'SHA-256 が一致しません。展開せずに止めます(SSH 接続は維持されます)' >&2
fi

一覧に tapapi/schema.sql があることを確かめ、実行前に schema.sql の内容を読みます。残高が負にならない制約、event ID(1 回のタップ処理を見分ける識別子)の一意性、残高更新と event 記録を同じ transaction(複数の更新を一体として確定する仕組み)で確定するための表が含まれていることを確認します。初期 SQL の照合欄には、教材アプリ書庫へ同梱され、書庫の SHA-256 で担保したことを記録します。SQL は展開したユーザが "$INITIAL_SQL" を開き、標準入力から DB 管理ユーザの psql へ渡します。

bash · コマンド
sudo -u <DB管理ユーザ> psql -d <演習DB名> < "$INITIAL_SQL"
sudo -u <DB管理ユーザ> psql -d <演習DB名> -c "\dt"
sudo -u <DB管理ユーザ> psql -d <演習DB名> -c "\d+ <残高表名>"
sudo -u <DB管理ユーザ> psql -d <演習DB名> -c "\d+ <event表名>"

# 一時展開はここで消す(あとから場所を追えなくなるため)
rm -rf "$TAPAPI_TMP"
test ! -e "$TAPAPI_TMP" && echo "tmp: removed"
期待される出力
(2026-09-02 実測)
=== schema.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);

=== 実行結果 ===
CREATE TABLE
CREATE TABLE
INSERT 0 1

=== \dt ===
 Schema |    Name    | Type  |  Owner
--------+------------+-------+----------
 public | cards      | table | postgres
 public | tap_events | table | postgres
(2 rows)

=== 残高が負にならない制約 ===
Check constraints:
    "cards_balance_check" CHECK (balance >= 0)

=== event ID の一意制約 ===
Indexes:
    "tap_events_pkey" PRIMARY KEY, btree (event_id)

tmp: removed

この 2 つの制約が、そのまま受入試験 2 と受入試験 3 の合否を決めます。

制約どの受入試験で効くか
cards_balance_check CHECK (balance >= 0)受入試験 2(残高不足が拒否される)
tap_events_pkey PRIMARY KEY (event_id)受入試験 3(同一 event ID の再送が二重処理されない)

制約を作る手順と、それを確かめる試験が 1 対 1 で対応していることを、ここで結び付けてください。

3-3. 2 台の App から DB へ到達できることを、DB 側から確認する

App 用の接続権限を教材の指定どおりに作り、app01 と app02 の両方が同じ DB へ接続できていることを確認します。認証情報そのものは作業票や証跡へ貼りません。

🔴 確かめる場所を、App ではなく DB にします。 App に「DB へ繋がっているか」を聞くと、返ってくるのは App の自己申告です。 App が起動時の 1 回だけ接続して以降その結果を持ち回っていても、接続が既に切れていても、同じ答えが返り得ます。 観測点は、測る対象から離してください。DB 側の接続一覧に App が並んでいれば、 それは App の実装に関係なく成立している事実です。

これは入口の振り分けを応答の node ではなく入口ログの upstream= で見るのと同じ考え方です。

bash · コマンド
# db01 で実行する(App の応答に依存しない観測)
sudo -u <DB管理ユーザ> psql -d <演習DB名> -c \
  "SELECT usename, client_addr, application_name, state
     FROM pg_stat_activity
    WHERE datname = '<演習DB名>';"
期待される出力
 usename | client_addr  | application_name |        state
---------+--------------+------------------+---------------------
 tap_app | 172.16.10.20 | app01            | idle in transaction
 tap_app | 172.16.10.21 | app02            | idle in transaction
(2 rows)

📔 なぜ観測する側を工夫するのか。 教材アプリは要求ごとに DB へ接続し、応答したらすぐ閉じます。そのため、 ただ後から pg_stat_activity を見るだけでは、2 台が同時に並ぶとは限りません。 「並ばなかった」が到達できないのか、たまたま両方とも閉じていたのかを区別できないからです。 測りたいのは「2 台が DB へ到達できること」なのに、そのままではタイミングを測ってしまいます。

そこで、App の 2 台で python3-psycopg2 を使い、別々の application_name で十数秒だけ接続を保持します。 その間に db01 から pg_stat_activity を見れば、2 台が同時に並ぶことを確実に観測できます。 保持をやめれば接続は消えます(本番のアプリと同じ挙動)。追加の package は要りません。

client_addr に app01 と app02 の両方が並ぶことが、この STEP の完了条件です。 片方しか出ていなければ、その App は DB へ届いていません(設定・経路・権限のどれか)。

参考として、App 側の応答も見ておきます。こちらは補助で、単独では完了条件になりません。

bash · コマンド
# app01 と app02 のそれぞれで実行する
curl --fail --show-error "http://localhost:<Appの待受port>/health"
期待される出力
(2026-09-02 実測)
[app01] {"status":"ok","db":"ok","node":"app01"}
[app02] {"status":"ok","db":"ok","node":"app02"}

⚠️ psql は App には入っていません。STEP 02 で App へ導入するのは python3-psycopg2 だけで、 psql は postgresql-client(db01 側)に入っています。App の中で psql を打たないでください。 確認のためだけに package を増やすかどうかも、運用の判断のひとつです。

2 台の App を同じ設定で起動する

4-1. 教材アプリを同じ方法で配置する

app01 と app02 でも STEP 03 と同じ URL から tapapi-1.1.tar.gz を取得し、同じ SHA-256 と 2728 bytes のサイズを照合します。sha256sum が不一致なら配置へ進みません。書庫には tapapi.py、tapapi.service、tapapi.env.example、schema.sql の 4 ファイルが含まれます。

配置先、設定ファイル、service 名、実行ユーザは、Chapter 3・4 を参照して受講者が決め、作業票へ記録します。app01 と app02 では node 名だけを各ホストの指定値にし、アプリ本体、DB 接続、待受 port、service 定義には設定差を作りません。

bash · コマンド
# app01 と app02 で同じ配置手順を実行する
sudo install -d -o root -g root -m 0755 <教材アプリの配置先>
<教材で指定された配置コマンド>
sudo install -m 0644 <service定義の元ファイル> <service定義の配置先>
sudo systemctl daemon-reload
sudo systemctl start <App service名>

<教材で指定された配置コマンド> も配置先(/opt 配下など)へ書き込むため、Chapter 3・4 と同じく sudo を付けて実行します。

4-2. service・socket・DB 接続を観測する

2 台それぞれで service、待受 socket、直近の journal を採ります。設定ファイルは秘密情報を除外して比較し、node 名以外に意図しない差がないことを確認します。

bash · コマンド
systemctl status <App service名> --no-pager
ss -lntp
journalctl -u <App service名> --since "<演習開始時刻>" --no-pager
diff -u <app01の秘密情報を除いた設定> <app02の秘密情報を除いた設定>
期待される出力
(2026-09-02 実測)
=== service ===
app01: active(MainPID 2599) / app02: active(MainPID 3410)

=== socket ===
LISTEN 0  5   0.0.0.0:8080   0.0.0.0:*     ← 2 台とも同じ

=== journal ===
Sep 02 11:11:30 learner01 systemd[1]: Started tapapi.service - Part 4 transit-card tap API.
Sep 02 11:11:32 learner01 python3[2599]: {"event_id":"-","card_id":"-","amount":"-","node":"app01","status":200,"result":"ok"}

=== 設定差(2 台で違うのは 1 項目だけ)===
TAPAPI_NODE=app01     ← app02 では app02
PGHOST=172.16.10.24
PGPORT=5432
PGDATABASE=transit_lab
PGUSER=tap_app
/etc/tapapi/tapapi.env owner=root group=tapapi mode=640

差が 1 項目だけであることを、目で確かめてください。2 台を同じ設定で作るのがこの STEP の目的で、 TAPAPI_NODE 以外に差があれば、それは後で「どちらに当たったか」を読み違える原因になります。

4-3. App 自身と DB 接続の状態を明示的に確認する

受講者が各 App のローカルな状態確認用 endpoint(URL ごとの要求の受け口)を直接実行します。この確認は、App のプロセスと DB 接続が応答に反映されることを見るためのものです。振り分け先を決める仕組みではありません。

bash · コマンド
# app01 と app02 のそれぞれで実行する
curl --fail --show-error --include "http://localhost:<Appの待受port>/health"
期待される出力
(2026-09-02 実測)
[app01] {"status":"ok","db":"ok","node":"app01"}
[app02] {"status":"ok","db":"ok","node":"app02"}

受講者が自分で 2 台へ順に取りに行った結果です。入口経由で 1 回叩いて node を見るのとは 意味が違います。あちらは「どちらかが応えた」ことしか示しません。

入口へ upstream、TLS、最小の公開ルールを設定する

5-1. 2 台の upstream を設定する

front01 に app01 と app02 の 2 台を upstream として登録します。入口の構文を確認してから service を再起動し(package の導入直後から動いているため、起動だけでは新しい設定が読まれません)、待受 socket とローカル応答を採ります。

bash · コマンド
sudo install -m 0644 <入口設定の元ファイル> <入口設定の配置先>
sudo <入口serviceの構文確認コマンド>
sudo systemctl restart <入口service名>
systemctl status <入口service名> --no-pager
ss -lntp

入口設定素材は配布されません。Chapter 1・4・5 を参照し、2 台の upstream、TLS、公開範囲を満たす設定を受講者が自分で書いて配置します。設定値と確認方法は STEP 01 の作業票へ記録してください。

🔴 受入試験 6 が成立するために必要な 3 つの設定があります。入口が最初の失敗を隠してしまうと、「最初の 1 件が失敗し、その後は継続する」という期待結果そのものが観測できません。Chapter 4 の STEP 06 で使った次の 3 つを、値まで作業票へ書き写してください。

設定Chapter 4 での値これが無いとどうなるか
max_fails(失敗とみなす回数)1何件失敗しても経路から外れず、失敗が続く
fail_timeout(経路から外しておく時間)30s外れている時間が短すぎ、後続要求の途中で失敗が混ざる
proxy_next_upstreamoff入口が別の系へ黙って送り直し、最初の失敗が観測できなくなる
期待される出力
(2026-09-02 実測)
=== 構文 ===
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

=== service ===
active

=== socket ===
LISTEN 0  511   0.0.0.0:8443   0.0.0.0:*   users:(("nginx",...))
LISTEN 0  511   0.0.0.0:8080   0.0.0.0:*   users:(("nginx",...))

🔴 受入試験 6 の成立条件を、ここで入れ忘れないでください。 max_fails / fail_timeout / proxy_next_upstream の 3 つが入っていないと、 受入試験 6 は失敗するのではなく、空振りします(全部成功して見えます)。 入れたことを、稼働中の構成で確かめてください。

sudo nginx -T | grep -E 'max_fails|fail_timeout|proxy_next_upstream'

(2026-09-02 実測)
server <app01の接続名>:8080 max_fails=1 fail_timeout=30s;
server <app02の接続名>:8080 max_fails=1 fail_timeout=30s;
proxy_next_upstream off;

設定ファイルを書いた結果ではなく、稼働中の構成を見ます。書けていても反映されていないことがあります。

5-2. TLS の提示内容を確認する

入口で server の秘密鍵と CSR(署名要求)を作ります。演習用 CA(認証局)で署名された certificate(サーバ証明書)を受け取って配置します。この流れは Chapter 5 の STEP 3・4 と同じです。🔴 秘密鍵は入口の外へ出しません。 運ぶのは CSR と certificate だけで、どちらも公開してよい情報です。配置後は certificate の SAN(証明書に書く「接続に使える名前」の欄)、発行者、期限を読み、接続名と一致することを確認します。秘密鍵の内容は表示せず、証跡にも含めません。

bash · コマンド
sudo openssl x509 -in <server-certificateの配置先> -noout -subject -issuer -dates -ext subjectAltName
sudo openssl s_client -connect "<入口の接続名>:<TLSのport>" -servername <入口の接続名> -CAfile <演習用CAの配置先> </dev/null
期待される出力
(2026-09-02 実測)
=== certificate ===
subject=CN = front01
issuer=CN = infra-study-lab-ca
notBefore=Sep  2 02:12:27 2026 GMT
notAfter=Sep  9 02:12:27 2026 GMT
X509v3 Subject Alternative Name:
    DNS:front01

=== TLS 検証成功 ===
Verify return code: 0 (ok)

SAN・発行者・期限の 3 点を読んでから使います。notBefore / notAfter は UTC です。 各 VM の date 表示がローカル時刻だと、そのままでは比べられません。

5-3. 必要最小限のサービス経路を設定する

STEP 1 の port 表を基準に、操作端末から入口、入口から 2 台の App、2 台の App から DB というサービス経路だけを許可します。管理接続と受講者が触れない層の保護は変更しません。この環境で使う FW は ufw で、interface は Chapter 5 と同じです。本章はコマンドを示しません。 適用の形を確認したい場合は Chapter 5 の STEP 6 を自分で見返してください。何をどの順で許可するかを決めるのが、この受入試験の対象です。

  1. 管理接続の退路が確保されていることを確認する。
  2. workstation01 から front01 の TLS 入口を許可する。
  3. front01 から app01・app02 の App port を許可する。
  4. app01・app02 から db01 の DB port を許可する。
  5. 必要経路以外の service network 側の直接到達を拒否する。
  6. 変更を 1 つ適用するたびに、別ターミナルから新しい管理接続を確認する。
期待される出力
(2026-09-02 実測)
=== 各ルール適用後の管理接続(9 ルールすべて)===
  front01: SSH許可+有効化    mgmt-ok host=front01
  app01:   SSH許可+有効化    mgmt-ok host=app01
  app02:   SSH許可+有効化    mgmt-ok host=app02
  db01:    SSH許可+有効化    mgmt-ok host=db01
  front01: HTTPS許可         mgmt-ok host=front01
  app01:   App許可           mgmt-ok host=app01
  app02:   App許可           mgmt-ok host=app02
  db01:    app01からDB許可   mgmt-ok host=db01
  db01:    app02からDB許可   mgmt-ok host=db01

=== 完成した到達性の表 ===
workstation01  front-https ALLOW / app01-svc DENY  / app02-svc DENY  / db-svc DENY
front01        front-https ALLOW / app01-svc ALLOW / app02-svc ALLOW / db-svc DENY
app01          front-https DENY  / app01-svc ALLOW / app02-svc DENY  / db-svc ALLOW
app02          front-https DENY  / app01-svc DENY  / app02-svc ALLOW / db-svc ALLOW
db01           front-https DENY  / app01-svc DENY  / app02-svc DENY  / db-svc DENY

9 ルールすべてで、適用直後の新しい管理接続が成功しました。 まとめて適用してから確認すると、どのルールで落ちたのか分からなくなります。

受入試験 7 項目を証跡で判定する + GOAL 🎯

ここからが本章の核心です。試験ごとに新しい event ID を使い、実行前の期待結果、実行操作、採る証跡、判定を 1 組にします。7 項目を連続して実行し、途中で FAIL が出たら後続結果を合格扱いにせず、§ 05 の順序で切り分けます。

受入試験 1 · 正常タップが処理される

由来する章: Chapter 3。 操作端末から入口へ正常なタップ要求を 1 件送ります。HTTP 応答だけで終えず、入口ログ、処理した App の journal、DB の event 行と確定後残高を同じ event ID で照合します。

項目記録
期待結果要求が成功し、event が 1 行だけ確定し、残高が指定額だけ更新される
証跡応答、入口ログ、App journal、DB event 行、確定後残高
判定☐ PASS / ☐ FAIL
bash · コマンド
curl --fail --show-error --include \
  --cacert <演習用CAの配置先> \
  -H "X-Event-ID: <この試験のevent ID>"   -H 'Content-Type: application/json' \
  --data '<教材指定の正常タップ要求>' \
  "https://<入口の接続名>:<TLSのport>/<タップAPIのpath>"
期待される出力
(2026-09-02 実測)
(事前)balance=1000 events=0

=== 応答 ===
HTTP/1.1 200 OK
{"ok":true,"card_id":"card-demo","balance":900,"event_id":"at1-normal","node":"app01"}

=== 入口ログ ===
POST /tap HTTP/1.1 status=200 event_id=at1-normal upstream=<app01のアドレス>:8080 upstream_status=200

=== App journal(app01)===
{"event_id":"at1-normal","card_id":"card-demo","amount":100,"node":"app01","status":200,"result":"ok"}

=== DB の event 行と確定後残高 ===
  event_id  |  card_id  | amount
------------+-----------+--------
 at1-normal | card-demo |    100
(1 row)
  card_id  | balance
-----------+---------
 card-demo |     900

判定 = PASS。event が 1 行だけ増え、残高がちょうど 100 減りました。

🔴 入口ログで照合するには X-Event-ID ヘッダが要ります。event ID は要求本文の中にあり、 入口は本文を読みません。ヘッダを付けないと入口ログには event_id=- としか残らず、 3 つの記録を突き合わせられません。

受入試験 2 · 残高不足が拒否される

由来する章: Chapter 2。 残高より大きな引き落としになる要求を送り、DB の残高制約によって拒否されることを確認します。アプリの都合で先に拒否したのではなく、DB の制約が異常値を入れなかった証跡を採ります。

項目記録
期待結果要求が拒否され、残高と event の確定結果が変更されない
証跡応答、App journal の DB エラー種別、DB の制約定義、拒否前後の残高と event 件数
判定☐ PASS / ☐ FAIL
bash · コマンド
curl --show-error --include \
  --cacert <演習用CAの配置先> \
  -H "X-Event-ID: <この試験のevent ID>"   -H 'Content-Type: application/json' \
  --data '<教材指定の残高不足要求>' \
  "https://<入口の接続名>:<TLSのport>/<タップAPIのpath>"
期待される出力
(2026-09-02 実測)
(事前)balance=900 events=1

=== 拒否応答 ===
HTTP/1.1 402 Payment Required
{"ok":false,"error":"insufficient_balance","balance":900,"node":"app02"}

=== App journal(制約由来と分かる)===
{"event_id":"at2-insufficient","card_id":"card-demo","amount":999999,"node":"app02","status":402,"result":"insufficient_balance"}

=== DB の制約定義 ===
cards_balance_check|CHECK ((balance >= 0))

(事後)balance=900 events=1     ← 残高も件数も変わっていない

判定 = PASS。確かめるのは応答だけではありません。拒否の前後で DB が 1 ミリも動いていないことまでを 見て、初めて「異常値が入らなかった」と言えます。

受入試験 3 · 同一 event ID の再送が二重処理されない

由来する章: Chapter 3。 同じ event ID と同じ内容の要求を 2 回送ります。1 回目と 2 回目の間で残高と event 件数を記録し、2 回目に二重の確定が起きないことを確認します。

どの境界が再送を止めたかも記録します。DB の一意制約(同じ値を 2 度入れさせない決まり)に到達して拒否された場合は DB エラーと journal を、アプリが既存 event を判定して応答した場合はその分岐を示す journal と DB の不変結果を採り、両者を混同しません。

項目記録
期待結果2 回送っても event は 1 行、残高更新も 1 回だけ
証跡2 回の応答、App journal の判定経路、DB の一意制約、再送前後の event 件数と残高
判定☐ PASS / ☐ FAIL
bash · コマンド
# 同じ要求を 2 回送る
curl --show-error --include --cacert <演習用CAの配置先> \
  -H "X-Event-ID: <この試験のevent ID>"   -H "X-Event-ID: <この試験のevent ID>"   -H 'Content-Type: application/json' --data '<同一event IDの要求>' \
  "https://<入口の接続名>:<TLSのport>/<タップAPIのpath>"
curl --show-error --include --cacert <演習用CAの配置先> \
  -H "X-Event-ID: <この試験のevent ID>"   -H "X-Event-ID: <この試験のevent ID>"   -H 'Content-Type: application/json' --data '<同一event IDの要求>' \
  "https://<入口の接続名>:<TLSのport>/<タップAPIのpath>"
期待される出力
(2026-09-02 実測)
(事前)balance=900 events=1

=== 1 回目 ===
HTTP/1.1 200 OK
{"ok":true,"card_id":"card-demo","balance":850,"event_id":"at3-retry","node":"app01"}
(中間)balance=850 events=2

=== 2 回目(同じ event ID・同じ内容)===
HTTP/1.1 409 Conflict
{"ok":false,"error":"duplicate_event","event_id":"at3-retry","node":"app02"}

=== App journal(判定経路)===
app01 ... {"event_id":"at3-retry",...,"node":"app01","status":200,"result":"ok"}
app02 ... {"event_id":"at3-retry",...,"node":"app02","status":409,"result":"duplicate_event"}

=== DB の一意制約 ===
Indexes:
    "tap_events_pkey" PRIMARY KEY, btree (event_id)

(事後)balance=850 events=2 / event_id='at3-retry' の件数=1

判定 = PASS。

🔴 この結果のいちばん大事なところ:1 回目と 2 回目を処理したのは別の App です (node が app01 → app02)。App 側は互いの処理を知りません。 それでも二重確定が起きなかったのは、止めたのが DB の一意制約だからです。

もしアプリが「さっき処理した」と覚えていて止めたのなら、別の App に当たった瞬間に二重処理が起きます。 どちらの境界が止めたのかを取り違えないでください。

受入試験 4 · 2 台へ振り分けられる

由来する章: Chapter 4。 異なる event ID の要求を複数回送り、app01 と app02 の両方が処理した証拠を採ります。応答に含まれる node 名、入口ログの upstream、各 App の journal の 3 つを同じ event ID で対応づけます。

項目記録
期待結果2 台それぞれが少なくとも 1 件を処理し、3 種類の証跡が一致する
証跡応答 node 名、入口ログの upstream、app01・app02 の journal
判定☐ PASS / ☐ FAIL
bash · コマンド
for event in <教材が指定する複数のevent ID>; do
  curl --fail --show-error --cacert <演習用CAの配置先> \
    -H "X-Event-ID: <この試験のevent ID>"   -H 'Content-Type: application/json' --data "<eventごとの要求>" \
    "https://<入口の接続名>:<TLSのport>/<タップAPIのpath>"
done
期待される出力
(2026-09-02 実測)
=== 応答の node 名 ===
  at4-lb-1 -> {"ok":true,...,"node":"app01"}
  at4-lb-2 -> {"ok":true,...,"node":"app02"}
  at4-lb-3 -> {"ok":true,...,"node":"app01"}
  at4-lb-4 -> {"ok":true,...,"node":"app02"}

=== 入口ログの upstream(event ID ごと)===
event_id=at4-lb-1 upstream=<app01のアドレス>:8080 upstream_status=200
event_id=at4-lb-2 upstream=<app02のアドレス>:8080 upstream_status=200
event_id=at4-lb-3 upstream=<app01のアドレス>:8080 upstream_status=200
event_id=at4-lb-4 upstream=<app02のアドレス>:8080 upstream_status=200

=== 両 App の journal 件数 ===
app01=2 件 / app02=2 件

判定 = PASS。応答の node、入口ログの upstream、各 App の journal の 3 つが、 event ID ごとに同じ相手を指していることを確認します。

受入試験 5 · /health を明示的に確認する

由来する章: Chapter 4。 受講者が app01 と app02 のそれぞれで /health(App 自身が状態を答える確認用の URL)を直接実行し、App 自身と DB 接続の状態を確認します。この試験の証跡は受入試験 4 の振り分け証跡とは別欄に保存します。

項目記録
期待結果2 台それぞれが App 自身と DB 接続の正常状態を返す
証跡受講者が各 App で明示的に取得した /health 応答、同時刻の App journal
判定☐ PASS / ☐ FAIL
bash · コマンド
# app01 と app02 のそれぞれで、受講者が明示的に実行する
curl --fail --show-error --include "http://localhost:<Appの待受port>/health"

/health は App 自身と DB 接続の状態を確認する endpoint です。upstream の判断材料ではなく、受講者が明示的に確認する観測点です。

期待される出力
(2026-09-02 実測)
[app01] {"status":"ok","db":"ok","node":"app01"}
[app02] {"status":"ok","db":"ok","node":"app02"}

判定 = PASS。受入試験 4 が「どちらかが応えた」を 4 回集めたのに対し、 こちらは2 台それぞれが単独で健全であることを直接確かめています。

受入試験 6 · App 1 台停止後、最初の失敗要求と後続の継続を確認する

由来する章: Chapter 4。 app01 の App service だけを一時停止し、新しい event ID の要求を順に送ります。最初の 1 件は失われ得ることを期待結果へ明記し、入口が実要求の失敗を観測した証拠と、後続要求が app02 で継続した証拠を分けて採ります。

項目記録
期待結果停止した App に当たった 1 件は失敗する。その後は残る App で要求が継続する
成立条件入口に max_fails=1 / fail_timeout=30s / proxy_next_upstream off が入っていること(STEP 05-1)
証跡停止 service、最初の失敗応答、入口ログ、後続の応答 node、app02 journal、DB の確定結果
判定☐ PASS / ☐ FAIL
bash · コマンド
# app01
sudo systemctl stop <App service名>
systemctl status <App service名> --no-pager

workstation01 から、停止した側に当たるまで 1 件ずつ送り、続けて後続要求を送ります。要求を送り終えるまで、記録を見に別のホストへ移動しないでください。fail_timeout が経過して停止側が再試行の対象へ戻ると、観測が濁ります(Chapter 4 STEP 07 と同じ理由)。

bash · コマンド
# workstation01
# ① 停止した側に当たるまで最大 2 件(失敗した event ID を記録する)
# ② 続けて後続要求を 4 件
# 要求の form・path・header は Chapter 4 STEP 07 と同じものを使う
期待される出力
(2026-09-02 実測)
=== 停止状態(app01)===
Active: inactive (dead)
待受 8080 の件数: 0

(事前)balance=829

=== ① 停止側に当たるまで ===
  at6c-hit-1 -> 502 Bad Gateway(nginx/1.24.0)  http=502     ← 失われた 1 件
  at6c-hit-2 -> {"ok":true,...,"node":"app02"}   http=200

=== ② 後続要求 4 件 ===
  at6c-next-1..4 -> すべて {"ok":true,...,"node":"app02"}  http=200

=== 入口ログ ===
status=502
status=200 ×5

=== DB の確定結果(失敗した 1 件は残っていない)===
at6c-hit-2 / at6c-next-1 / at6c-next-2 / at6c-next-3 / at6c-next-4

(事後)balance=824     ← 5 件ぶんだけ減った

判定 = PASS。期待結果どおり、停止側に当たった 1 件だけが失われ、後続は残る App で継続しました。 失敗した at6c-hit-1 は DB にも残っていません。「失われた」とはこのことです。

🔴 6 件とも 200 になったら、それは合格ではなく空振りです。 入口が別の系へ送り直していると、入口ログの upstream= にアドレスが 2 つ並びます。 その場合は成立条件(STEP 05-1 の 3 設定)が入っていません。設定を入れてから測り直してください。

(空振りしている例)
event_id=at6-hit-2 upstream=<app01>:8080, <app02>:8080 upstream_status=502, 200

試験後は app01 を再開し、service、socket、App 自身と DB 接続の状態、両 App への要求到達を順に確認してから次へ進みます。

bash · コマンド
sudo systemctl start <App service名>
systemctl status <App service名> --no-pager
timeout 30 bash -c 'until curl --fail --silent "http://localhost:<Appの待受port>/health" >/dev/null; do sleep 1; done'
curl --fail --show-error "http://localhost:<Appの待受port>/health"
期待される出力
(2026-09-02 実測)
=== service / socket ===
active
待受 8080 の件数: 1

=== /health ===
{"status":"ok","db":"ok","node":"app01"}

=== 両 App への要求到達(fail_timeout 経過後)===
  restore-1 -> node app02
  restore-2 -> node app02
  restore-3 -> node app01     ← 経路へ復帰
  restore-4 -> node app02

⚠️ 再開した直後は、まだ片方にしか振られません。fail_timeout(この章では 30 秒)が 経過するまで、入口は停止していた側を経路から外したままです。 1 回試して app01 が出ないことを「復帰していない」と判断しないでください。

受入試験 7 · App / DB への直接接続が拒否される

由来する章: Chapter 5。 操作端末、入口、App 2 台、DB を送信元とし、入口、App 2 台、DB の各公開面へ同じ TCP probe(接続を試して届くかを確かめる操作)を行います。単発の失敗だけではなく、送信元と宛先の組を到達性の表にして、必要経路だけが許可されていることを示します。

項目記録
期待結果操作端末から入口は許可され、操作端末から App / DB への直接接続は拒否される。層間の必要経路は許可される
証跡5 台を送信元にした到達性の表、各 probe の終了 status、入口経由の正常タップ
判定☐ PASS / ☐ FAIL
bash · コマンド
# 教材で指定された同じ probe を、5 台から同じ宛先順で実行する
<TCP-probeコマンド> <入口の接続名> <TLSのport>
<TCP-probeコマンド> <app01の接続名> <Appの待受port>
<TCP-probeコマンド> <app02の接続名> <Appの待受port>
<TCP-probeコマンド> <db01の接続名> <DBの待受port>

probe は Chapter 5 の STEP 2 で使った probe_tcp と同じものを、同じ timeout・同じ宛先順で使います。測り方を変えると、ルールの差と測定方法の差が混ざります。

期待される出力
(2026-09-02 実測)
=== 5 台の到達性の表 ===
workstation01  front-https ALLOW / app01-svc DENY  / app02-svc DENY  / db-svc DENY
front01        front-https ALLOW / app01-svc ALLOW / app02-svc ALLOW / db-svc DENY
app01          front-https DENY  / app01-svc ALLOW / app02-svc DENY  / db-svc ALLOW
app02          front-https DENY  / app01-svc DENY  / app02-svc ALLOW / db-svc ALLOW
db01           front-https DENY  / app01-svc DENY  / app02-svc DENY  / db-svc DENY

=== 入口経由の正常タップ ===
{"ok":true,"card_id":"card-demo","balance":841,"event_id":"at7-final","node":"app01"}

判定 = PASS。操作端末から App と DB へは直接届かず、入口経由の要求だけが通ります。 app01 から app01-svc が ALLOW なのは自分自身への接続で、ファイアウォールの対象外です。

7 項目の合否を集計する

#受入項目由来期待結果と証跡の一致判定
1正常タップChapter 3☐☐ PASS / ☐ FAIL
2残高不足Chapter 2☐☐ PASS / ☐ FAIL
3同一 event ID の再送Chapter 3☐☐ PASS / ☐ FAIL
42 台への振り分けChapter 4☐☐ PASS / ☐ FAIL
5App 自身と DB 接続の明示確認Chapter 4☐☐ PASS / ☐ FAIL
6App 1 台停止後の失敗と継続Chapter 4☐☐ PASS / ☐ FAIL
7App / DB 直接接続の拒否Chapter 5☐☐ PASS / ☐ FAIL

7 行すべてが PASS であり、各行に期待結果と証跡がある場合だけ修了条件の受入部分を通過します。

ゴール到達 ✓ ① 正常タップを HTTP 応答だけでなく DB の確定結果まで確認した / ② 残高不足を DB 制約の証跡で説明した / ③ 再送を止めた境界を journal と DB から見分けた / ④ 2 台への振り分けを 3 種類の証跡で示した / ⑤ App 自身と DB 接続の状態を受講者が明示的に確認した / ⑥ App 1 台停止後の最初の失敗と後続継続を記録した / ⑦ 到達性の表で App と DB への直接接続拒否を示した。

計画を 1 枚の運用作業票へ育てる

STEP 01 で取得して記入を始めた handover-worksheet-1.0.md に、実際に使った値、受入結果、証跡の場所を追記して運用作業票へ仕上げます。複製して書き直さず、取得時の SHA-256 照合記録から引渡し時点までを 1 枚につなげます。提出先は設けません。完成した作業票は自分の証跡と同じ場所に保存し、次の担当者へ渡せる状態にしてください。

7-1. 構成と service を確定する

作業票の「4. 構成と service」へ引渡し時点の現在状態を記入します。本文には同じ記入表を重ねず、次の値が揃っているか確認します。

  • front01: <入口service名>、<指定値>、依存する app01・app02、構文・service・socket の確認方法
  • app01: <App service名>、<指定値>、依存する db01、service・socket・状態の確認方法
  • app02: <App service名>、<指定値>、依存する db01、service・socket・状態の確認方法
  • db01: <DB service名>、<指定値>、service・socket・schema の確認方法

7-2. ログと証跡の場所を確定する

作業票の「5. ログと証跡の場所」へ、入口の <入口ログの場所>、App の journalctl -u <App service名>、DB の <教材指定の確認SQL>、到達性を記録した <到達表の保存先> を転記します。それぞれに event ID などの検索キーと正常の目安を添え、次の担当者が確認を始められる状態にします。

7-3. 状態確認、既知の制約、戻し方を書く

作業票には次を明記します。

  • App 自身と DB 接続の状態は、受講者または運用担当者が各 App で /health を明示的に確認する。
  • App 片系停止では、入口が実要求の失敗を観測するため、最初の 1 件は失われ得る。
  • App 2 台は同じ設定に保ち、node 名だけを変える。
  • DB の残高制約と event ID 一意制約を、アプリとは別の防波堤として維持する。
  • 戻すときは、1 要因ずつ設定を戻し、service、socket、接続、受入項目の順で確認する。

7-4. Part 7 へ渡す監視候補を列挙する

本章では監視基盤を導入しません。次の候補について、対象、取得方法、正常の目安、異常時に参照する証跡を作業票へ記載します。

  1. 入口 service と TLS 待受 socket
  2. App 2 台の service と App 自身・DB 接続の状態
  3. 入口の upstream 失敗数と App journal
  4. DB service、接続、transaction エラー、制約違反
  5. 5 台の必要経路の到達性
#監視対象取得方法正常の目安(2026-09-08 実測)異常時に参照する証跡
1入口 service・TLS 待受systemctl is-active nginx / ss -lntpactive / 0.0.0.0:8443 待受ありjournalctl -u nginx・nginx -t
2App service・DB 接続systemctl is-active tapapi / curl /healthactive / {"status":"ok","db":"ok","node":"app01"}journalctl -u tapapi
3入口 upstream 失敗数tap_access.log の upstream_status正常時 200(片系故障で最初の 1 件のみ 502→以降 200)/var/log/nginx/tap_access.log(upstream= で振り分け先)
4DB service・接続・エラーsystemctl is-active postgresql / pg_stat_activityactive / 接続一覧に App が client_addr で並ぶPostgreSQL ログ・pg_stat_activity
55 台の必要経路の到達性各経路の curl/ssh入口→App→DB が 1 件の要求で貫通(/tap が ok:true)各ホストの service 状態と経路確認

受入証跡への参照: TLS は受入試験 7(Verify return code: 0 (ok)・CA 署名)、振り分けは STEP 05 の入口ログ(upstream=172.16.10.20/21)、片系故障は STEP 08-1(fail-2 のみ upstream_status=502)を参照。

2 種類の故障を比べ、8 分類で後始末する

この STEP は、① App 片系故障、②入口障害、③後始末の 3 段階です。2 種類の故障を同時に作りません。片系故障を正常へ戻し、その証跡を採ってから入口障害へ進みます。

8-1. App 片系故障で「継続する」範囲を記録する

受入試験 6 と異なる新しい event ID を使い、app02 の App service だけを停止します。入口、app01、DB は変更しません。

bash · コマンド
# app02
sudo systemctl stop <App service名>
systemctl status <App service名> --no-pager

# workstation01
<教材指定の間隔で、新しいevent IDの要求を順に送る>
観測場所期待結果
最初の要求失敗し得る
入口ログ停止した App への実要求失敗が記録される
後続要求app01 で継続する
DB成功した event と残高だけが確定する
期待される出力
(2026-09-02 実測例)この例では app01 を停止して試験しました。教材どおり app02 を止めた場合は、後続の応答の node は app01 になります。
=== 最初の失敗 ===
  at6c-hit-1 -> 502 Bad Gateway  http=502
=== 後続の継続 ===
  at6c-hit-2, at6c-next-1..4 -> すべて {"ok":true,...,"node":"app02"}  http=200
=== 入口ログ ===
status=502 → status=200 ×5
=== DB ===
失敗した 1 件は確定していない(5 件だけ)

継続する範囲:入口・残る App・DB は動き続けます。止まるのは、停止した App に当たった要求だけです。

app02 を再開し、service、socket、App 自身と DB 接続の状態、両 App への振り分けを確認します。正常へ戻るまでは入口障害へ進みません。

bash · コマンド
sudo systemctl start <App service名>
systemctl status <App service名> --no-pager
timeout 30 bash -c 'until curl --fail --silent "http://localhost:<Appの待受port>/health" >/dev/null; do sleep 1; done'
curl --fail --show-error "http://localhost:<Appの待受port>/health"
期待される出力
(実測例)この例では app01 を停止して試験し、同じ app01 を戻しました。app02 側で試しても対称です。
=== service / socket ===
active / 待受 8080 の件数: 1

=== /health ===
{"status":"ok","db":"ok","node":"app01"}

=== 振り分けの証跡(fail_timeout 経過後)===
  restore-1 -> node app02
  restore-2 -> node app02
  restore-3 -> node app01     ← 経路へ復帰
  restore-4 -> node app02

8-2. 入口障害で「全体が止まる」範囲を記録する

入口設定のうち、教材で指定された 1 か所だけを一時的に誤らせます。変更前の設定を退避し、App 2 台と DB は正常なままにします。構文確認、service 状態、操作端末からの要求、App journal、DB の順に観測します。

🔴 誤らせる 1 か所は「待受アドレス」。ただし reload では効きません。 入口の待受を listen 8443 ssl; から listen 127.0.0.1:8443 ssl;(ループバックだけに束縛)へ 1 行だけ変えます。 ここで reload では反映されません——古いワーカーが元の待受(0.0.0.0:8443)を持ち続けるため、 nginx -t は通り service も active のまま、要求も通り続けます。restart して初めて反映され、 そこで全体が止まります。第 1 章 STEP 06(listen の IPv4/IPv6 が 2 行)と同じ「1 行では効かない」構造です。

bash · コマンド
# 変更前を退避
sudo cp -a /etc/nginx/sites-available/tap.conf /tmp/tap.conf.bak
# 1 か所だけ誤らせる(待受をループバックへ束縛)
sudo sed -i 's/listen 8443 ssl;/listen 127.0.0.1:8443 ssl;/' /etc/nginx/sites-available/tap.conf
sudo nginx -t                       # → successful(構文では気づけない)
sudo systemctl reload nginx         # → まだ 0.0.0.0:8443 のまま(古いワーカー)=要求は通る
sudo ss -lntp | grep 8443           # → reload 後は 0.0.0.0:8443(変わっていない)
sudo systemctl restart nginx        # → ここで反映される
sudo ss -lntp | grep 8443           # → 127.0.0.1:8443(外から届かない)
# 復旧
sudo cp -a /tmp/tap.conf.bak /etc/nginx/sites-available/tap.conf
sudo nginx -t && sudo systemctl restart nginx
観測場所期待結果
操作端末から入口全要求が失敗する
app01・app02service と socket は正常だが、新しい要求が届かない
db01service は正常だが、新しい event は増えない
期待される出力
(2026-09-02 実測)
=== 入口の停止 ===
nginx: inactive / 8443 の待受件数: 0

=== 入口経由(全体が止まる)===
curl: (7) Failed to connect to <front01の接続名> port 8443 after 0 ms: Couldn't connect to server
http=000

=== 後段は正常 ===
[app01] {"status":"ok","db":"ok","node":"app01"}
[app02] {"status":"ok","db":"ok","node":"app02"}
DB: 接続できて件数も読める

🔴 この章の主題がそのまま出ます。App は 1 台落ちても継続しましたが、入口が落ちると全体が止まります。 しかも後段はどこも壊れていません。「App も DB も正常なのにサービスが死んでいる」という、 現場で実際に起きる形です。冗長化していない層が 1 つあれば、系全体の可用性はそこで決まります。

退避した正常設定を戻し、構文、service、socket、TLS、正常タップ、DB の確定結果を順に確認します。

bash · コマンド
sudo cp <演習用の退避先> <入口設定の配置先>
sudo <入口serviceの構文確認コマンド>
sudo systemctl restart <入口service名>
期待される出力
(2026-09-02 実測)
=== 構文 ===
nginx: configuration file /etc/nginx/nginx.conf test is successful

=== service / socket ===
active / 8443 の待受件数: 1

=== TLS ===
Verify return code: 0 (ok)

=== 正常タップ ===
{"ok":true,"card_id":"card-demo","balance":819,"event_id":"at8-recovered","node":"app01"}

=== DB 結果 ===
at8-recovered の行が 1 件確定

戻すときも、構文 → service → socket → TLS → 応答 → DB の順に 1 つずつ確かめます。 「起動した」だけでは、入口が正しく後段へ届けているかは分かりません。

App 片系故障では代替の App があるため後続要求が継続しました。入口障害では入口の代替がないため、後段が正常でも全体が停止しました。この影響範囲の差を、停止した component、到達できた最終地点、残った代替経路の 3 列で作業票へ追記します。

8-3. 自分で作ったものを依存順の逆で消す

まず教材の後始末一覧と STEP 1 の package・設定一覧を照合します。自分が作ったものだけを対象にし、対象が不明なまま削除しません。入口 → App 2 台 → DB の順で、依存する側から片付けます。db01 では、PostgreSQL の package を消す前に、次のブロックで演習用 DB・role(DB 内でユーザや権限を表すもの)が残っていないことを確かめます。

bash · コマンド
# db01 で、PostgreSQL の package を消す前に実行する(消した後は psql が無い)
sudo -u <DB管理ユーザ> psql -Atc "<教材指定の残留検査SQL>"

運営が配った 3 点は、次のように後始末します。ここで対象にするのは配布物の取得ファイルと一時展開物だけです。受講者が作った schema、設定、service の消し方は STEP 01 の計画から自分で判断します。

  • 教材アプリ: app01・app02 と、取得した場合は db01 に残る tapapi-1.1.tar.gz、および一時展開ディレクトリを、証跡の保存後に片付ける。
  • 初期 SQL: 単独配布物はない。教材アプリ書庫から一時展開した tapapi/schema.sql のコピーだけを一時展開ディレクトリとともに片付ける。実行して作った DB schema はこの項目に含めない。
  • 作業票の雛形: 完成した作業票は証跡として保存する。未記入の重複コピーや取得途中のファイルだけを片付け、引渡す完成版は消さない。

公開ルールは Chapter 5 の STEP 6 で置いた順の逆順で戻します。🔴 ここでも 1 つ戻すたびに新しい管理接続を確認します(戻す作業でも締め出しは起きます)。

remove は設定を残すため、設定も含めて戻すときは purge を使います。

メタ package を消しても依存の本体は残るため、package 差分が空になるまで確かめます。どの消し方を選ぶかは、自分の計画と対象を確認して判断してください。

  1. 入口の演習用公開ルール、TLS 素材、upstream 設定、service、package を教材の手順で戻す。
  2. app01・app02 の service、設定、教材アプリ、package を教材の手順で戻す。
  3. db01 の演習用 schema、接続権限、service、package を教材の手順で戻す。

採取範囲の外に置いた取得ファイル・APT log・入口設定の退避も、証跡へ必要な内容を転記したあとで削除します。次は各ファイルを作ったホストで実行します。

bash · 取得経路の後始末(4 台共通)
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
bash · 採取範囲外の一時物
# 取得経路を設定した各 VM
rm -f /tmp/infra-study-archive-keyring.asc /tmp/apt-update-part4.log /tmp/tapapi-1.1.tar.gz
test ! -e /tmp/infra-study-archive-keyring.asc
test ! -e /tmp/apt-update-part4.log
test ! -e /tmp/tapapi-1.1.tar.gz

# front01
sudo rm -f /tmp/tap.conf.bak
test ! -e /tmp/tap.conf.bak

消し忘れやすいものを、ホストごとに挙げます。この一覧に無いものを自分で足した場合は、作業票へ追記してから消してください。

ホスト消し忘れやすいもの
front01server certificate と秘密鍵、演習用の公開ルール、入口設定ファイルと有効化先
app01・app02App を動かす専用ユーザとグループ、unit ファイル、設定ディレクトリ、取得した書庫
db01接続許可に足した行、待受アドレスの設定、演習用 role
4 台共通章専用 APT source ディレクトリ(/etc/apt/sources.list.d.part4/)とkeyring。既存の /etc/apt/sources.list.d/ には最初から触っていないため、削除対象は章専用の 2 つだけ
操作端末演習用 CA の秘密鍵を含む作業ディレクトリ

🔴 秘密鍵は動作に影響しないので、残っていても気づけません。 消す対象として先に挙げておきます。

8-4. 8 分類で残留を検査し、残すものを記録する

削除コマンドが成功しただけでは修了になりません。「消えたこと」は「消すコマンドが終わったこと」とは別の確認です。 次の 8 分類を個別に検査し、構築前の基準状態へ戻ったことを記録します。

🔴 package・service・socket・DB schema の 4 つだけでは足りません。 この 4 つが合格でも、ファイル、専用ユーザ、公開ルール、秘密鍵は残ります。残したまま引き渡すと、次に使う人の結果が変わります。

#分類対象ホスト検査すること合格条件
1package4 台STEP 1 に採った構築前 package 一覧との差分差分の出力が空
2service・unit4 台本章で作成・起動した service と unit ファイル稼働しておらず、自動起動も設定されていない
3socket4 台本章で開いた待受 socket演習用 port の待受がない
4DB schemadb01演習用 DB、table、role、接続許可本章で作った schema・role・許可行が残っていない
5ファイル4 台配置物、設定ファイル、取得した書庫、一時展開物本章で置いたファイルとディレクトリが残っていない
6専用ユーザapp01・app02App を動かすために作ったシステムユーザユーザとグループが残っていない
7公開ルール4 台本章で追加したルールと、その有効・無効の状態追加したルールが残らず、状態が演習前と同じ
8TLS 素材front01・操作端末server certificate、秘密鍵、演習用 CA の素材鍵とその置き場が残っていない

検査は 8 分類を順に実行します。山括弧の値は STEP 01 の作業票から転記します。

bash · コマンド
# 1 package(出力が空なら合格)
comm -13 /tmp/part4-packages-before.tsv <(dpkg-query -W -f='${binary:Package}\n' | LC_ALL=C sort)

# 2 service・unit
systemctl status <本章で作成したservice名> --no-pager || true
systemctl is-enabled <本章で作成したservice名> || true
test ! -e <本章で作成したunitファイル>

# 3 socket
ss -lntp

# 4 DB schema(db01): 中身は 8-3 で package を消す前に確かめた。ここでは PostgreSQL 自体が残っていないこと
command -v psql || echo 'psql: absent'

# 5 ファイル
test ! -e <教材アプリの配置先>
test ! -e <教材アプリの設定ディレクトリ>
test ! -e <入口設定の配置先>
ls -la /tmp/tapapi-1.1.tar.gz 2>/dev/null || echo 'archive: absent'

# 6 専用ユーザ(app01・app02)
getent passwd <Appを動かす専用ユーザ名> || echo 'user: absent'
getent group  <Appを動かす専用ユーザ名> || echo 'group: absent'

# 7 公開ルール
sudo <公開ルールの一覧表示コマンド>

# 8 TLS 素材(front01)
test ! -e <server certificateの配置先>
test ! -e <秘密鍵の配置先>

# 8 の続き TLS 素材(操作端末)
test ! -e <演習用CAの作業ディレクトリ>
列挙の外を 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 source を元へ戻して apt update を実行した結果まで検査するためです。

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
    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 · 差分証跡の後片付け
rm -f /tmp/part4-packages-before.tsv
sudo rm -f /var/tmp/part4-chapter-manifest.diff
test ! -e /tmp/part4-packages-before.tsv
test ! -e /var/tmp/part4-chapter-manifest.diff

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

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

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

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

期待される出力
(2026-09-30 の実測データ(app01)から算出。台によって行数と件数は変わります。合格の条件は「残留候補: 0 件」です)
== 残留候補(0 件で合格)==
残留候補: 0 件
== 更新日時だけ変わった通常ファイル(1 件ずつ理由を説明できること)==
/etc/group
/etc/gshadow
/etc/passwd
/etc/shadow
/home/deployer/.local/state/wireplumber/restore-stream
/var/lib/update-notifier/dpkg-run-stamp
/var/lib/update-notifier/updates-available
更新日時だけ変わった通常ファイル: 7 件 / ディレクトリ: 11 件
差分行総数: 36

🔴 秘密鍵は、消えたことを必ず目で確かめてください。 鍵は他のファイルと違い、残っていても動作に影響しません。動いているかどうかで気づけない種類の残り物です。

期待される出力
(実測例。依存の逆順 front01 → app01/app02 → db01 で消したあと)
1 package   : 構築前一覧との差分出力 = 空(残存なし)
2 service   : tapapi = unit none(/etc/systemd/system/tapapi.service 無し)
3 socket    : 8443/8080/5432 とも待受 none
4 DB schema : transit_lab / tap_app / pg_hba 許可行 = none(DB ディレクトリごと除去)
5 file      : /opt/tapapi・/etc/tapapi・/etc/nginx/tls・書庫 = none
6 専用ユーザ: app01/app02 の tapapi user/group = none
7 公開ルール : 追加ルールなし(初期状態と同じ)
8 TLS 素材  : /etc/nginx/tls(server cert・秘密鍵)・演習用 CA = none
🔴 消す前に「在る」ことを先に確認しておきます: postgresql・nginx・python3-psycopg2 が導入済み、
   tapapi が active、8443 が待受、transit_lab と tap_app が存在、tapapi ユーザが存在、front01.crt が存在。
   「在ったものが無くなった」と言えて初めて、消えた証明になります。
🔴 postgres ユーザは package を消しても残るため、`userdel` で明示的に削除します(初期状態には無いユーザです)

予約終了時の巻き戻しは安全網です。本章の合格は自分の後片付けで達成し、巻き戻しを合格の証跡にはしません。

8-5. 意図的に残すものを、残す理由とともに記録する

すべてを消すのが正解ではありません。 完成した作業票は引渡しの成果物として残します。一方、STEP 02 で作った章専用 source(/etc/apt/sources.list.d.part4/)と keyring は構築中だけ使うものです。既存の /etc/apt/sources.list.d/ には最初から触っていないため、復元は不要です。章末は章専用ディレクトリと keyring を削除するだけで基準状態へ戻ります(8-3 で実行済み)。

残すものなぜ残すか
完成した作業票引渡しの成果物そのもの
bash · コマンド
test ! -e /etc/apt/sources.list.d.part4
test ! -e /etc/apt/keyrings/infra-study-archive-keyring.asc
grep -nHE '^(Types|URIs|Suites|Components|Signed-By):' /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources 2>/dev/null || true
期待される出力
🔴 実機で採り直しが必要(章専用の取得経路へ変更したため、旧実測 2026-09-08 はそのまま使えない)。
想定される形: test 2 行はどちらも何も出力せず(章専用ディレクトリと keyring が消えている)、
grep は既存の /etc/apt/sources.list.d/ubuntu.sources だけを返す(章の開始時から Types/URIs の行が変わっていない)。
🔴 既存 source はこの章でも一度も書き換えていないため、復元という手順そのものが要りません。

「消したもの」と「残したもの」を分けて書けることが、引渡しの条件です。 一覧に無いものが残っていたら、それは片付け漏れです。

つまずきポイント

ハマり #1 · 計画どおりに構築したのに系が動かない

1 台ずつの service は active でも、層間の接続先や待受 socket が一致していない可能性があります。入口から一気に調べず、依存先から戻って確認します。

計画表の値と実際の値を 1 行ずつ比較し、複数の設定を同時に直しません。

ハマり #2 · 受入試験のどれかだけが落ちる

まず、落ちた項目の期待結果と証跡が正しく定義されているかを確認します。次に、最後に観測できた層から先を切り分けます。

症状最初に見る場所次に見る場所
正常タップが DB に残らない応答と入口ログApp journal、DB transaction
残高不足で残高が変わるDB 制約定義App journal、確定後残高
再送で二重処理されるApp の再送判定DB 一意制約、event 件数
片方にしか振り分けられない応答 node と入口 upstream両 App journal、設定差
App 状態の明示確認が失敗する対象 App の service と socketApp journal、DB 接続
直接接続が通る到達性の表許可・拒否の対象経路

ほかの受入項目が通ることを理由に、失敗項目を合格へ変えないでください。修正後はその項目だけでなく、影響する前後の項目も再試験します。

ハマり #3 · 片系故障のはずが全体停止になる

停止対象を取り違えた、2 台に設定差がある、正常側の App が DB へ接続できない、入口が 1 台しか upstream に持っていない可能性があります。

入口の設定と App の設定を同時に変更しません。正常側 App を単独で確認し、その後に入口からの後続要求を確認します。

ハマり #4 · 後始末で残留が見つかる

残留した分類から、どの片付けが抜けたかを特定します。8 分類の表を省略せず、構築前の基準状態と比較します。とくに秘密鍵と専用ユーザは、残っていても動作に影響しないため気づけません。

対象が自分で作ったものか判断できない場合は削除せず、作業票と教材の指定を再確認してください。

修了確認

問 1 · 受入試験と動作確認の違いを説明する

コマンドが成功し、HTTP 応答が返っただけでは、正常タップの受入試験に合格したと言えないのはなぜですか。

問 2 · 状態確認と振り分けを分ける

App 自身と DB 接続の状態を明示的に確認する試験と、2 台への振り分けを確認する試験で、証跡を分ける理由を説明してください。

問 3 · 片系故障と入口障害の影響範囲を比べる

App 1 台の停止では後続要求が継続し、入口障害では全体が停止するのはなぜですか。代替経路という言葉を使って説明してください。

問 4 · 分類を分けて確認する理由を説明する

package、service、socket、DB schema を、1 つの確認結果へまとめず個別に検査する理由を答えてください。

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

問 1 のヒントと答え

ヒント 1 段 · 成功の意味を分ける

コマンドの終了、HTTP 応答、DB の確定結果は、同じ出来事でしょうか。

ヒント 2 段 · event ID で証跡をつなぐ

入口ログ、App journal、DB の event 行と残高を、同じ event ID で照合します。

答えを見る

コマンドや HTTP の成功は、要求が入口や App まで届いたことしか示さない場合があります。正常タップの合格には、同じ event ID の event 行と更新後残高が DB で transaction として確定したことまで必要です。期待結果と複数層の証跡が一致して初めて受入試験に合格します。

問 2 のヒントと答え

ヒント 1 段 · 見ている対象を分ける

一方は App の内部状態、もう一方は実要求がどの App へ届いたかを見ています。

ヒント 2 段 · それぞれの証跡を列挙する

状態確認では各 App の明示的な応答と journal、振り分けでは応答 node、入口 upstream、両 App journal を使います。

答えを見る

App の状態確認は、受講者が各 App を直接確認し、App 自身と DB 接続の状態を見る試験です。振り分けは実要求の応答 node、入口ログの upstream、App journal から転送先を特定する試験です。目的も証跡も異なるため、同じ成功としてまとめません。

問 3 のヒントと答え

ヒント 1 段 · 台数を見る

App と入口には、それぞれ何台の処理経路がありますか。

ヒント 2 段 · 要求の入口から線をたどる

App の片方が止まった線と、すべての要求が最初に通る線を図で消してみてください。

答えを見る

App は 2 台あるため、片方の実要求失敗を入口が観測した後は、残る App への代替経路で後続要求を継続できます。入口は 1 台で代替経路がないため、入口が要求を受けられないと、後ろの App と DB が正常でもサービス全体へ到達できません。

問 4 のヒントと答え

ヒント 1 段 · 状態の種類を分ける

package がないことと、service が停止していることは同じ状態でしょうか。

ヒント 2 段 · 分類ごとに照合する

導入物、実行状態、待受、業務データ定義を、それぞれ構築前と比較してください。

答えを見る

package を削除しても service 定義や待受 socket が残る場合があり、service を止めても DB schema は消えません。種類ごとに状態と確認方法が異なるため、8 分類を個別に構築前と比較し、すべてに残留がないことを修了条件にします。とくにファイル・専用ユーザ・公開ルール・秘密鍵は、残っていてもサービスは正常に見えるので、動作確認では検出できません。

1 サービスの構築から、基盤と監視へ

Part 4 では、改札という 1 サービスを clean な状態から組み立て、制約、冗長化、公開範囲を受入証跡で説明し、運用作業票へ引き渡しました。街として複数サービスを連携させる最終統合は本章の範囲外です。ここからは、サービスを支える VM、共有ストレージ、監視を、それぞれ提供する側へ進みます。

次の Part へ渡すもの

  • Part 5: 4 台の VM に必要な役割、基準状態、変更と復旧の確認項目
  • Part 6: 共有ストレージの成立条件(2 台同時利用、セッション隔離、容量、lifecycle、残留検査)
  • Part 7: service、socket、App と DB の状態、ログ、制約違反、到達性の監視候補

参考資料