0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42日でバックエンドエンジニアの基礎を完全に理解する #24 - コンテナの調査テクニック

0
Last updated at Posted at 2026-09-03

この記事で学ぶこと

前回、composeでアプリとDBを一発起動できるようにしました。コンテナ編の仕上げは「調査」です。動いているコンテナの中で何が起きているのか。死んだコンテナはなぜ死んだのか。#22 で作った実行イメージにはシェルが入っていないので、docker exec で中に入る、という一番素朴な手すら使えません。それでも調べる方法を、今日は道具箱として揃えます。

種明かしをすると、新しい魔法はほぼ登場しません。#20 で学んだ「コンテナはホストから見えるただのプロセス」と「隔離の正体はnamespace」を、今度は調査の武器として使い回します。

この記事を終えると以下ができるようになります。

  • logs・inspect・stats で、コンテナに触らず外から状態を把握できる
  • シェルの無いコンテナを、ホスト側からの覗き込みとデバッグコンテナの2通りで調査できる
  • 死んだコンテナの死因を、終了コードと OOMKilled フラグから切り分けられる

この回は言語に依存しません。調査対象がGo製かPython製かで、やることは何も変わりません。

見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。障害対応の現場では、この手の知識は「知っているか、いないか」がそのまま初動の速さになります。


準備

前回の compose 環境をそのまま使います。hello-container ディレクトリで docker compose up -d して、scratchベースのアプリ(app)とPostgreSQL(db)が動いている状態から始めます。

なお、途中でホスト側のroot権限(sudo)を使う場面があります。これはWSL2や自前のLinuxならではの贅沢で、本番のマネージド環境ではホストに入れないことも多いです。だからこそ「ホストに入れる場合の技」と「入れない場合の技」を両方やります。


🔴 調査はログから始まる

障害の一報が来たとき、最初に打つコマンドはこれです。

docker compose logs --tail 2 app
app-1  | 2026/09/01 19:56:33 DB接続OK
app-1  | 2026/09/01 19:56:33 listening on :8080

--tail で末尾だけ、-f で流しっぱなし、--since 10m で直近10分だけ、が定番の使い方です。compose外のコンテナなら docker logs コンテナ名 で同じことができます。

ここで一度立ち止まってほしいのは、なぜこのアプリはログをファイルではなく標準出力に吐いているのかです。私たちのGoコードは log.Println しているだけで、ログファイルなど一切開いていません。これは手抜きではなく、コンテナの流儀です。コンテナ内に書いたファイルはコンテナと共に消えます(#20)。一方、標準出力に吐けば、Dockerが受け取って保管し、いま見たように後から読めます。ログの保存先や転送先を変えたくなったら、アプリには一切触らずDocker側の設定(ログドライバ)を差し替えるだけで済みます。「コンテナのアプリはログを標準出力へ」は言語を問わない鉄則です。

もう1つ大事な性質があります。**ログは死んだコンテナからも読めます。**前回、起動レースでappが即死したとき、私たちは docker compose logs app で死んだ後のエラーを読みました。コンテナは止まってもログは残っていて、コンテナを rm するまで読めます。「落ちたからもう何もわからない」ではありません。


🔴 inspect、コンテナの履歴書

ログの次は docker inspect です。そのコンテナに関するメタデータ(状態、設定、ネットワーク、マウント)が全部JSONで出てきます。全部出すと長いので、--format で欲しい項目だけ抜くのが実戦の型です。

docker inspect --format '{{.State.Status}} / PID {{.State.Pid}} / 起動 {{.State.StartedAt}}' \
  $(docker compose ps -q app)
running / PID 760273 / 起動 2026-09-01T19:56:33.550277596Z

$(docker compose ps -q app) はサービス名からコンテナIDを引く小技で、composeの世界と素のdockerコマンドの橋渡しに使えます。この PID 760273 が後半の主役になります。

もう1つ、覚えておくべき出力があります。

docker inspect --format '{{.Config.Env}}' $(docker compose ps -q app)
[DB_HOST=db PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin]

コンテナに渡した環境変数は、inspectできる人には丸見えです。今日は DB_HOST だけなので無害ですが、実務の環境変数にはDBパスワードやAPIキーが入りがちです。「環境変数に入れた秘密は、docker(や実行基盤のAPI)に触れる人全員に見えている」は、#37 でIAMやシークレット管理を学ぶときの伏線として覚えておいてください。


【手を動かす①】シェルの無いコンテナを、外から調べる

では本題です。appはscratchベースなので、中に入ろうとすると門前払いされます。

docker compose exec app sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

docker exec は「コンテナの中でコマンドを起動する」仕組みなので、中にシェルという実行ファイルが無ければ何もできません。さて、どうするか。#20 の武器を取り出します。コンテナはホストから見えるただのプロセスです。

PID=$(docker inspect --format '{{.State.Pid}}' $(docker compose ps -q app))
ps -o user,pid,comm -p $PID
USER         PID COMMAND
nobody    760273 server

ここまでは #20 の復習です。ここからが今日の新技で、実はホストのrootは、このプロセスの「見ている世界」まで覗けます。/proc/PID/root/ は、そのプロセスにとっての / です。

sudo ls /proc/$PID/root/
sudo ls -l /proc/$PID/root/app
app
dev
etc
proc
sys
===
total 7792
-rwxr-xr-x 1 root root 7978047 Sep  2 04:47 server

シェル無しで、scratchコンテナのファイルシステムがホストから丸見えになりました。/app/server が7.9MB、それ以外ほぼ何も無い、という #22 で作ったとおりの中身です。mount namespaceで隔離された世界も、ホスト側には /proc 経由の覗き窓が開いています。

ネットワークも調べられます。「appは本当に8080で待ち受けているのか」を確認したいとき、scratchの中には ssnetstat もありません。そこで nsenter を使います。#20 では unshare で新しいnamespaceを作りました。nsenter はその逆で、既存のプロセスのnamespaceに入るコマンドです。

sudo nsenter -t $PID -n ss -tlnp
State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
LISTEN 0      4096      127.0.0.11:39507      0.0.0.0:*    users:(("dockerd",pid=327,fd=49))
LISTEN 0      4096               *:8080             *:*    users:(("server",pid=760273,fd=7))

-t $PID -n は「対象プロセスの、networkのnamespaceだけに入れ」という指定です。ss コマンド自体はホストのものを使い、視点だけをコンテナ側に移す。だからコンテナに道具が無くても調べられます。2行目に、server が8080で待ち受けている証拠が出ました。

1行目はおまけの収穫です。127.0.0.11で待っているのは dockerd、つまり #23 で学んだ内蔵DNSの正体です。あのDNSは、各コンテナのnamespaceの中でDocker本体が窓口を開いていたのでした。伏線がここで回収されます。

このやり方の裏返しも押さえてください。ホストのrootには、コンテナの中身が全部見えます。ファイルも、通信も、(/proc/PID/environ を読めば)環境変数もです。#20 で「コンテナの分離はVMより弱い」と言った意味が、調査の便利さと同じコインの裏表として現れています。


【手を動かす②】道具箱ごと隣に置く(デバッグコンテナ)

ホストに入れない環境ではどうするか。発想を変えて、道具の入ったコンテナを、調査対象と同じnamespaceに参加させます。

docker run --rm -it \
  --network container:hello-container-app-1 \
  --pid container:hello-container-app-1 \
  alpine sh

--network container:名前 は「新しいnetwork namespaceを作らず、このコンテナのものに相乗りしろ」という指定です。--pid も同様。つまりこのalpineは、appと同じネットワーク・同じプロセス一覧の世界に、シェルと道具一式を持って入ります。中で叩いてみます。

ps -o pid,user,comm
wget -qO- localhost:8080/users
PID   USER     COMMAND
    1 nobody   server
   18 root     sh
   24 root     ps
===
1: alice
2: bob

psserver がPID 1として見えています。PID namespaceを共有したからです。そして localhost:8080 でappに届きました。#23 で「コンテナのlocalhostは自分自身」と学びましたが、network namespaceを共有した2つのコンテナは、その「自分」が同じになります。だからlocalhostで隣(正確には同居人)に届きます。

scratchやdistrolessで運用していても、この技があれば調査には困りません。Kubernetesにも同じ発想の仕組み(エフェメラルコンテナ、kubectl debug)があり、考え方はここで学んだものがそのまま通用します。


🟡 いまの資源使用量を見る

「なんか重い」の一報に対しては docker stats です。

docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'
NAME                    CPU %     MEM USAGE / LIMIT
hello-container-app-1   0.00%     1.816MiB / 7.755GiB
hello-container-db-1    7.32%     23.04MiB / 7.755GiB

Goのscratchコンテナはメモリ1.8MB。この数字の出どころは #20 でやったcgroupで、dockerはcgroupの集計ファイルを読んで表示しているだけです。LIMITが7.755GiB(ホスト全体)になっているのは、--memory で上限を付けていないからで、実務では「上限を付けていないコンテナは暴れたときホストごと巻き込む」という #20 の教訓がこの列に表れます。

docker top コンテナ名 もあります。コンテナ内のプロセスを、ホスト側の視点(ホスト側PID・実行ユーザー)で一覧します。exec不要なので、シェルの無いコンテナにも使えます。


🔴 死んだコンテナの検死

ここからは、すでに死んでいるコンテナの調べ方です。まず、わざと殺します。#20 でやったOOMの実験を、今度は --rm を付けずに実行して、死体を残します。

docker run --name oom-victim -m 20m alpine dd if=/dev/zero of=/dev/shm/blob bs=1M count=50
echo "exit: $?"
docker ps -a --filter name=oom-victim --format '{{.Names}} {{.Status}}'
exit: 137
oom-victim Exited (137) Less than a second ago

Exited (137)。#20 で「137 = 128 + 9 = SIGKILLで殺された」と学びました。本番でこの数字を見たら次に打つのはこれです。

docker inspect --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} FinishedAt={{.State.FinishedAt}}' oom-victim
ExitCode=137 OOMKilled=true FinishedAt=2026-09-01T19:57:39.183223733Z

OOMKilled=true。カーネルのOOM killerに撃たれたと、Dockerが記録してくれています。死因はメモリ超過で確定です。いつ死んだか(FinishedAt)も取れるので、監視のグラフと突き合わせられます。

137なのにOOMではないケース

ここで実務の落とし穴です。137はOOMとは限りません。docker stop は、まずSIGTERM(終了要請)を送り、10秒待って、まだ生きていたらSIGKILLで強制終了します。つまり「行儀よく終われなかったコンテナ」も137で死にます。実験します。

docker run -d --name pid1-demo alpine sleep 300
time docker stop pid1-demo
docker inspect --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}}' pid1-demo
real	0m10.303s
ExitCode=137 OOMKilled=false

stopに10.3秒かかり、137で死に、OOMKilledはfalseです。切り分けの手順はこうなります。137を見たら OOMKilled を確認し、trueならメモリ、falseなら「SIGTERMで終了できなかった」を疑う。

ところでこの sleep、なぜSIGTERMを無視したのでしょうか。実は #20 の「コンテナの中ではPID 1」がここで牙をむきます。PID 1は特別扱いで、自分でハンドラを用意していないシグナルは、カーネルが届けてくれません(うっかり殺されないためのinit向け仕様です)。素の sleep はSIGTERMのハンドラを持たないので、PID 1として動く限り終了要請が効かず、毎回10秒待ちの強制終了になります。「docker stopがいつも10秒かかるコンテナ」は、この仕様を踏んでいるサインです。

ちなみに私たちのGoサーバーを docker stop すると0.3秒で止まりました(Goのランタイムがシグナルハンドラを登録しているためです。終了コードは私の環境では2でした)。終了コードの慣習として137(SIGKILL)と143(SIGTERMで終了)は覚えておくべきですが、シグナル受信時に何を返すかは最終的にアプリ次第です。数字は手がかり、確定させるのは OOMKilled とログ、と覚えてください。実験に使った死体は docker rm oom-victim pid1-demo で片付けておきます。


実務での判断基準

🔴 調査の順番は「外から中へ」

logs(何が起きたとアプリが言っているか)→ ps -a と inspect(生死・終了コード・設定)→ stats(資源)→ それでもわからなければ中へ(nsenter・デバッグコンテナ)。この順番には理由があります。外からの観察はコンテナに影響を与えませんが、中に入る調査は対象の状態を変えうるからです。いきなり中に入るのは、現場検証の前に現場を歩き回るようなものです。

🔴 調査は読み取りに徹する。直すのはイメージ側

execやデバッグコンテナで中に入れると、その場でファイルを書き換えて直したくなります。やってはいけません。#21 で学んだとおりイメージは不変で、コンテナ上の手当ては次の再作成で消えます。さらに悪いことに、「イメージと中身が違うコンテナ」が生まれ、同じイメージから作ったはずの環境の再現性が死にます。中で直すのではなく、原因をイメージ(Dockerfileやアプリコード)に反映して作り直す。応急処置でどうしても触ったなら、必ず記録してすぐイメージに反映します。

🟡 ファイルの回収は docker cp

コンテナ内のファイル(ヒープダンプ、生成物など)を取り出したいときは docker cp コンテナ名:/path ホスト側path です。execと違って中に実行ファイルを要求しないので、シェルの無いコンテナからも回収できます。実測で、scratchコンテナから server バイナリをそのまま抜き出せました。

⚪ 今は流してよいもの

docker diff(イメージから変わったファイルの一覧。実測ではdbコンテナがinitスクリプトとソケットファイルを書いた跡が見えます)、docker events(デーモンのイベントストリーム)、Kubernetes環境での kubectl debugcrictl は、名前だけ知っておけば必要なときに調べられます。


確認テスト

知識問題

Q1. コンテナで動かすアプリは、ログをファイルではなく標準出力に書くのが定石です。理由を2つ挙げてください。

Q2. シェルの入っていないコンテナを調査する方法を2つ、それぞれ「なぜそれが可能なのか」の仕組みと併せて説明してください。

Q3. 本番のコンテナが Exited (137) で停止していました。次に確認すべきことと、結果ごとに疑うべき原因を説明してください。

Q4. docker stop にいつも10秒かかるコンテナがあります。何が起きているか、PID 1の性質に触れながら説明してください。

実技問題

Q5. あなたはscratchベースのAPIコンテナ(シェル無し)について「ポート8080で待ち受けていないように見える」という報告を受けました。ホストにroot権限で入れる場合と入れない場合のそれぞれについて、待ち受けの有無を確認する具体的なコマンドを示してください。


確認テスト 解答・解説

Q1

1つ目は、コンテナ内のファイルはコンテナの削除と共に消えるからです。ログをコンテナ内のファイルに書くと、一番読みたい「死んだ後」に読めなくなります。標準出力に書けばDocker側が保管するので、コンテナが Exited になっても docker logs で読めます。

2つ目は、ログの行き先をアプリから切り離せるからです。標準出力に吐いておけば、保存先の変更やログ収集基盤への転送はDocker側のログドライバ設定だけで済み、アプリのコードや設定に手を入れる必要がありません。

Q2

1つ目はホスト側から覗く方法です。コンテナはホストから見えるただのプロセスなので、docker inspect でホスト側PIDを特定すれば、rootは /proc/PID/root/ からそのコンテナのファイルシステムを直接読め、nsenter -t PID -n でネットワークのnamespaceだけに入り、ホストの ss などの道具で調査できます。namespaceは「見える世界」を切り替えているだけで、ホストのrootに対して秘密を作る仕組みではない、というのが根拠です。

2つ目はデバッグコンテナです。docker run --network container:対象 --pid container:対象 で、道具入りのイメージ(alpineなど)を対象と同じnamespaceに相乗りさせます。network namespaceを共有すればlocalhostが同じ「自分」になり、PID namespaceを共有すれば相手のプロセスが見えます。対象コンテナに道具が無くても、道具は持ち込めばよい、という発想です。

Q3

docker inspectState.OOMKilled を確認します。137は「SIGKILLで殺された」ことしか意味せず、殺した犯人が2通りいるからです。

OOMKilled=true なら、メモリ上限超過でカーネルのOOM killerに殺されています。上限値(-m)とアプリのメモリ使用量を調べます。false なら、SIGKILLの出どころは停止処理です。docker stop はSIGTERM→10秒猶予→SIGKILLの順で進むため、SIGTERMで終了できないアプリは最終的に137で死にます。デプロイや再起動の時刻と FinishedAt を突き合わせ、アプリがSIGTERMを処理できているかを確認します。

Q4

docker stop はまずSIGTERMを送り、10秒待ってからSIGKILLで強制終了します。毎回10秒かかるのは、SIGTERMが効いていない、つまりアプリが終了要請に反応していないサインです。

コンテナの主プロセスはPID 1として動きます。PID 1は特別で、自分でハンドラを登録していないシグナルはカーネルが届けません。そのため、シグナル処理を用意していないプログラム(素の sleep や素朴なスクリプト)をPID 1で動かすと、SIGTERMは無視され、常に10秒猶予の後の強制終了(137)になります。対策はアプリでSIGTERMを処理して後始末をしてから終了することです。デプロイのたびに強制終了されるのは、処理中のリクエストを断ち切ることでもあるからです。

Q5

ホストに入れる場合は、ホスト側PIDを特定し、対象のnetwork namespaceに入ってホストの ss で確認します。

PID=$(docker inspect --format '{{.State.Pid}}' 対象コンテナ)
sudo nsenter -t $PID -n ss -tlnp

入れない場合は、対象とnetwork namespaceを共有するデバッグコンテナを立てて確認します。

docker run --rm -it --network container:対象コンテナ alpine sh
# 中で
netstat -tln        # または wget -qO- localhost:8080 で実際に叩く

ポイント

  • どちらも「対象コンテナに道具を要求しない」のが肝。道具はホストのものを使うか、持ち込む
  • network namespaceさえ共有すれば、対象のlocalhostは自分のlocalhostになる
  • 実際に wgetcurl で叩いて応答を見る方が、LISTENの有無より一段強い証拠になる

まとめ

  • 調査は外から中へ。logs → ps・inspect → stats → 最後にnamespaceの中へ。外からの観察は対象を汚さない
  • ログは標準出力へ吐き、死んだコンテナからも docker logs で読める。inspectは履歴書で、PID・環境変数・死因まで載っている
  • シェルの無いコンテナは、ホストからは /proc/PID/root/nsenter で、ホストに入れなければ --network container: のデバッグコンテナで調べる。すべて #20 のnamespaceの応用
  • 137はSIGKILL。OOMKilled がtrueならメモリ、falseなら「SIGTERMで終われなかった」。PID 1はハンドラの無いシグナルを受け取らないため、シグナル処理の無いアプリはstopに毎回10秒かかる
  • 調査は読み取りに徹し、直すのはイメージ側。コンテナ内の手当ては再現性を壊す

これでPart 4のコンテナ編は完結です。namespace・cgroupという正体から始めて、Dockerfile、マルチステージビルド、compose、そして調査まで、一通りの武器が揃いました。

次回から Part 5 認証認可編 に入ります。初回は #25 認証と認可の違い です。「あなたは誰か」を確かめることと、「あなたに何を許すか」を決めることは、混ぜると事故になる別々の問いです。#1 でJWTに触れたときの伏線を、ここから本格的に回収していきます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?