Skip to content
Published on

Dockerfileで繰り返される失敗 — root実行、PID 1、イメージに残るシークレット

シェア
Authors

はじめに — ビルドは通るのに運用でだけ壊れるもの

Dockerfileの問題はビルド段階ではほとんど表に出ません。イメージは作られ、コンテナは起動し、ローカルではうまく動きます。問題はその次に出てきます。

デプロイのたびにdocker stopがきっかり10秒待ってからコンテナを強制終了し、終了コードが143ではなく137で記録されます。その10秒のあいだに処理中だったリクエストはそのまま切れます。数か月後にはプロセステーブルにゾンビが数千個たまっており、セキュリティスキャンはイメージ履歴からデプロイ用のトークンを見つけ出します。

この記事はそうした問題を症状ではなく原因の側から見ます。ほとんどはDockerfileの2、3行で片づきますが、なぜそうすべきかを知らないと次のプロジェクトで同じことを繰り返します。

rootで実行 — 誰も指定しなければ0番になる

USERを使っていないイメージはUID 0で動きます。

docker run --rm node:22-bookworm-slim id
uid=0(root) gid=0(root) groups=0(root)

「コンテナは隔離されているから中でrootでも構わない」という話が長く出回りましたが、コンテナのrootは既定の設定ではホストのrootと同じUIDです。ユーザー名前空間を有効にしていなければ、ホストのファイルシステムをボリュームとしてマウントした瞬間にそのまま露呈します。

docker run --rm -v /etc:/host-etc alpine:3.20 \
  sh -c 'echo "# injected" >> /host-etc/hosts && tail -1 /host-etc/hosts'
# injected

コンテナの中からホストの/etc/hostsを書き換えました。ここにランタイムの脆弱性が1つ加われば、脱出はそのままホストのroot権限です。

直し方は決まっています。ユーザーを作り、必要なパスの所有権をそのユーザーに与え、USERで切り替えます。

FROM node:22-bookworm-slim
WORKDIR /app

RUN groupadd --gid 10001 app \
 && useradd --uid 10001 --gid app --shell /usr/sbin/nologin --create-home app

COPY --chown=10001:10001 package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --chown=10001:10001 . .

USER 10001:10001
EXPOSE 8080
CMD ["node", "dist/server.js"]

名前ではなく数値のUIDを使ったのは意図的です。KubernetesのrunAsNonRootは、イメージ設定のUSERの値が名前だとそれがrootでないかを判定できず、Podを拒否します。

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]

USER appと書いたうえで上の設定を適用すると、こういうイベントを見ることになります。

Error: container has runAsNonRoot and image has non-numeric user (app),
cannot verify user is non-root

非特権ユーザーに変えると1024未満のポートを開けません。ここでNET_BIND_SERVICEのcapabilityを追加する選択肢もありますが、アプリケーションのポートを8080に変えてサービス層で80として公開するほうがはるかに単純です。

latestタグと再現性

FROM node:22は便利ですが、時間とともに別のものを指します。今日のビルドと来週のビルドが違うベースの上に載り、障害が起きたときに「先週はできていたのに」の原因を追跡できません。

docker pull node:22-bookworm-slim
docker inspect --format '{{index .RepoDigests 0}}' node:22-bookworm-slim
node@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

ダイジェストで固定すれば、どの時点でビルドしても同じベースが保証されます。

FROM node:22-bookworm-slim@sha256:9f3c1a5a4d1b7e2c8f0a6b9d3e5c7a1b4d8f2e6c0a9b3d5e7f1c4a8b2d6e0f31

ここでよく出る反論が「それではセキュリティパッチを受け取れない」です。もっともな指摘であり、だからこそ更新は人ではなくボットに任せます。DependabotやRenovateがダイジェストを上げるPRを開き、CIがその組み合わせを検証したうえでマージされます。自動的に流れ込んでくるものと、検証を経て入ってくるものの違いです。

アプリケーションのイメージタグも同じです。myapp:latestでデプロイすると、ロールバックする対象がありません。コミットSHAをタグに使い、必要ならlatestを追加で付ける程度が安全です。

ADDとCOPY — 既定はCOPYである

2つのコマンドは似て見えますが、ADDのほうがはるかに多くのことをします。そしてその追加動作のほとんどが問題です。

ADDはローカルのtarアーカイブを自動で展開します。

ADD release.tar.gz /opt/app/

この1行はrelease.tar.gzをコピーするのではなく、展開します。アーカイブの中に../のパスやシンボリックリンクが入っていれば、意図しない場所にファイルが書かれます。ファイルをそのまま置いておきたかったのならCOPYを使うべきです。

ADDはURLも受け取ります。

ADD https://example.com/tool.tar.gz /tmp/
RUN tar -xzf /tmp/tool.tar.gz -C /opt && rm /tmp/tool.tar.gz

チェックサムの検証がなく、圧縮ファイルがレイヤーに残り、後続のRUNで消してもサイズはそのままです。リモートのファイルが必要なら、RUNの中で取得し検証して消すほうがよいです。

RUN set -eux \
 && curl -fsSL -o /tmp/tool.tar.gz https://example.com/tool-1.4.2.tar.gz \
 && echo "3f0a91c2e4d7b5a8f16c9d02e3b7a41f8c5d29e06b3a7f1c4d8e2b0a95f3c716  /tmp/tool.tar.gz" | sha256sum -c - \
 && tar -xzf /tmp/tool.tar.gz -C /opt \
 && rm -f /tmp/tool.tar.gz

ADDが正当な場合は2つだけです。ローカルのtarを意図して展開するとき、そしてBuildKitでgitリポジトリを直接取得するときです。それ以外は常にCOPYです。

PID 1という席 — SIGTERMがアプリに届かない理由

最も高くつく失敗がここにあります。症状はデプロイのたびに繰り返される10秒の遅延です。

FROM node:22-bookworm-slim
WORKDIR /app
COPY . .
CMD npm start
docker run -d --name shellform demo:shell
time docker stop shellform
docker inspect -f '{{.State.ExitCode}}' shellform
shellform

real    0m10.412s
137

10秒はDockerの既定の終了猶予時間で、137はSIGKILLです。正常な終了であれば即座に終わり、143(SIGTERM)が出ていたはずです。

原因は二重です。shell formで書いたCMD npm startは、実際には/bin/sh -c "npm start"として実行されます。するとPID 1はシェルになります。そしてPID 1はカーネルで特別扱いされ、明示的にハンドラを登録していないシグナルの既定動作が適用されません。つまりSIGTERMが来ても単に無視されます。

ここでよく出回る説明は「シェルがシグナルを子に伝えない」ですが、半分だけ正しいです。Debianのdashやalpineのbusybox ashは、sh -cに単純なコマンドが1つだけならforkせずexecで置き換える最適化をします。するとシェルは消えて対象のプロセスがPID 1になります。問題はその最適化が条件付きだという点です。コマンドにパイプや&&、変数展開、リダイレクトが入った瞬間にシェルが残ります。シェルに依存した動作はいつか静かに壊れます。

そして2つ目の層のほうがよくあります。npmyarnpnpmのようなラッパーは、シェルがexecで置き換えられたとしても、それ自体が子プロセスを起動する管理者です。npmは長らくSIGTERMを子に伝えませんでした。ですからシェルをなくしてもアプリはシグナルを受け取れません。

直し方はexec formで書いてラッパーを取り除くことです。

CMD ["node", "dist/server.js"]
docker run -d --name execform demo:exec
time docker stop execform
docker inspect -f '{{.State.ExitCode}}' execform
execform

real    0m0.284s
143

アプリケーション側にも、シグナルを受け取って後始末するコードが必要です。Dockerはシグナルを配達するだけで、優雅な終了を代わりにやってはくれません。

const server = app.listen(8080)

for (const sig of ['SIGTERM', 'SIGINT']) {
  process.on(sig, () => {
    server.close(() => process.exit(0))
    setTimeout(() => process.exit(1), 15000).unref()
  })
}

エントリポイントのスクリプトがどうしても必要な場合は、最後の行で必ずexecを使います。これを抜くとスクリプトがPID 1として残り、同じ問題が再現します。

#!/bin/sh
set -e
./wait-for-db.sh
exec "$@"
COPY --chmod=0755 entrypoint.sh /usr/local/bin/
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "dist/server.js"]

終了猶予時間も併せて調整すべきです。15秒必要なアプリに10秒の既定値を置いておくと、毎回途中で切られます。

docker run -d --stop-timeout 30 api:v312
# Kubernetes
spec:
  terminationGracePeriodSeconds: 30

PID 1のもう1つの責任 — ゾンビの回収

シグナル伝達を直したからといってPID 1の問題が終わったわけではありません。Linuxで親を失ったプロセスはPID 1に引き取られ、PID 1はwaitでその終了状態を回収する責任を負います。initシステムはこの仕事をします。NodeやPythonのプロセスはしません。

コンテナの中で子プロセスを頻繁に作るアプリケーションなら、ゾンビがたまります。

docker exec api ps -eo pid,stat,comm | grep -c 'Z'
1847

プロセステーブルが埋まると新しいプロセスの生成が失敗し、その症状はアプリケーションのログにfork: Resource temporarily unavailableとして現れます。

解決策は本物のinitを1番に置くことです。最も簡単な方法はDockerの組み込みオプションです。

docker run -d --init api:v312
docker exec api ps -eo pid,comm
    PID COMMAND
      1 docker-init
      7 node

イメージ自体に入れたい場合はtiniを使います。

FROM node:22-bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends tini \
 && rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "dist/server.js"]

Kubernetesには--initに相当するPodのオプションがないので、イメージ側で対処する必要があります。ただし子プロセスを作らない単一プロセスのサーバーなら、あえて必要ではありません。サブプロセスを起動するかどうかを確認してから決めれば十分です。

イメージ履歴に残るシークレット

プライベートレジストリからパッケージを取得するには、ビルド中にトークンが必要です。ここで2つの方法がよく使われ、どちらも間違いです。

# アンチパターン1: ENV
ENV NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ
RUN npm ci

# アンチパターン2: ARG
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc \
 && npm ci \
 && rm ~/.npmrc

1つ目はイメージの設定に永久に残ります。

docker inspect -f '{{json .Config.Env}}' bad:v1 | tr ',' '\n'
["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
"NODE_VERSION=22.14.0"
"NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ"]

2つ目は~/.npmrcを消していますが、前の節で見たとおり下位レイヤーにはそのまま残り、クラシックビルダーでは履歴にも残ります。

docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*' | head -1
NPM_TOKEN=npm_9fA3xQ2LkD8vR1sT6yU0wZ4bN7mC5eJ

レジストリへプッシュしたのなら、そのトークンはすでに漏洩したものとみなして破棄すべきです。イメージを消しても取り返しはつきません。

正しい方法はBuildKitのsecret mountです。シークレットは当該のRUNが実行されているあいだだけtmpfsとしてマウントされ、レイヤーには記録されません。

# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc,mode=0400 \
    npm ci --omit=dev
COPY . .
CMD ["node", "dist/server.js"]
printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > /tmp/npmrc
docker buildx build --secret id=npmrc,src=/tmp/npmrc -t api:v312 .
shred -u /tmp/npmrc

環境変数からそのまま渡すこともできます。

docker buildx build --secret id=npmtoken,env=NPM_TOKEN -t api:v312 .
RUN --mount=type=secret,id=npmtoken \
    NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci --omit=dev

プライベートなgitリポジトリを取得する必要があるなら、SSHエージェントを転送する方法もあります。

RUN --mount=type=ssh \
    git clone git@github.com:org/private-lib.git /opt/lib
docker buildx build --ssh default -t api:v312 .

検証は習慣にすべきです。プッシュ前に履歴と設定を一度ざっと見るだけで、ほとんどの事故はふるい落とされます。

docker history --no-trunc api:v312 | grep -Ei 'token|secret|password|key='
docker inspect -f '{{json .Config.Env}}' api:v312

HEALTHCHECK、攻撃面、そしてファイルの所有権

残りの項目は個別には小さいですが、まとまると運用の品質を変えます。

HEALTHCHECKはイメージに状態判定の基準を埋め込みます。

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \
  CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

ここには誤解が2つあります。第一に、ヘルスチェックが失敗してもdocker runはコンテナを再起動しません。状態がunhealthyと表示されるだけで、自動的な対処はSwarmやオーケストレータの担当です。第二に、KubernetesはイメージのHEALTHCHECKを完全に無視します。PodではlivenessProbereadinessProbeを別に定義しなければなりません。そしてdistrolessイメージにはwgetcurlもないので、ヘルスチェック用の小さなバイナリを一緒に入れるか、アプリケーションに状態確認のサブコマンドを作るほうがよいです。

攻撃面は、最終イメージに何が残ったかという問題です。ビルドに使ったcurlgitgccがランタイムイメージに残っていれば、侵入者に道具を手渡すようなものです。マルチステージで分離するのが第一の答えで、それができないなら少なくともインストールの範囲を狭めます。

RUN apt-get update \
 && apt-get install -y --no-install-recommends libpq5 ca-certificates \
 && rm -rf /var/lib/apt/lists/*

--no-install-recommendsひとつで、Debian系では数十MBと数十個のパッケージが減ります。

ファイルの所有権はサイズに直結します。コピーしたあとで所有権を変えるパターンは、ファイル全体を新しいレイヤーに書き直します。

# アンチパターン: レイヤーが2倍になる
COPY . /app
RUN chown -R app:app /app
COPY . /app          286MB
RUN chown -R app:app  286MB

--chownを使えばコピーしながら所有権が決まるので、レイヤーは1つです。

COPY --chown=10001:10001 . /app

読み取り専用のルートファイルシステムで運用するなら、書き込みが必要なパスだけを個別に開けます。

docker run -d --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=64m \
  --tmpfs /run:rw,noexec,nosuid,size=8m \
  api:v312
失敗現れる症状直し方
USER未指定コンテナがrootで実行、k8sポリシー違反数値UIDでUSER 10001:10001
FROM node:22昨日のビルドと今日のビルドが違うダイジェスト固定 + Renovate
ADDの乱用アーカイブの自動展開、検証なしのダウンロードCOPYまたはRUNでチェックサム検証
shell formのCMDdocker stopが10秒、終了コード137JSON配列のexec form
ラッパーで実行(npm start)SIGTERMがアプリに未達ランタイムのバイナリを直接実行
initなしゾンビの蓄積、forkの失敗--initまたはtini
ENV/ARGでトークンを渡すdocker historyに平文で露出RUN --mount=type=secret
RUN chown -Rイメージサイズが2倍COPY --chown
HEALTHCHECKへの依存k8sで無視されて無動作liveness/readinessプローブ

おわりに — Dockerfileは実行の契約書である

この記事の項目は互いに違って見えますが、根はひとつです。Dockerfileはファイルを集めるスクリプトではなく、このプロセスが誰として、どの権限で、どのように始まりどのように終わるのかを書いた契約書です

新しいDockerfileをレビューするとき見るべきは5行です。FROMが固定されているか、USERが数値UIDで指定されているか、CMDENTRYPOINTがJSON配列か、シークレットがARGENVで入ってきていないか、そして最終ステージにビルドツールが残っていないか。この5つを通すだけで、運用で出会う事故のほとんどが消えます。