.env は .gitignore に書いてある。Gitには上げていない。だから秘密は守れている。
そう考えている人は多いはずです。私もそうでした。ですが、Dockerでコンテナ化しているなら、その安心は半分しか正しくありません。.env をGitに上げていなくても、COPY .env した瞬間に、秘密はDockerイメージのレイヤーにそのままコピーされます。そして docker history や、イメージを展開するだけで、誰でも読めます。
この記事では、ビルドしたイメージから秘密を抜き出す手順を実演し、なぜマルチステージでも消えないのかを説明し、BuildKitのsecret mountで正しく守るところまで書き切ります。Gitの履歴とは別の、コンテナ特有の漏れ方の話です。
この記事が扱うのは「コンテナイメージに焼き込まれた秘密」です
最初に、似たテーマとの違いを切り分けます。秘密の漏れ方は経路ごとに対策が違うので、混ぜると守り損ねます。
| 軸 | どこに残るか | この記事との関係 |
|---|---|---|
| git履歴の残存 | VCSのコミットオブジェクト | 別軸(削除しても残る) |
| npm等の依存攻撃 | 第三者パッケージ | 別軸 |
| ローカルの窃取 | 開発機の .env
|
別軸 |
| イメージへの焼き込み | ビルドで生成したレイヤー | 本記事 |
本記事が扱うのは一番下です。Gitに何を上げたかは関係ありません。ビルドという行為そのものが、秘密をイメージのレイヤーに固定してしまう、という話です。
他人のイメージから鍵が出てきた話
具体例から入ります。以前、あるOSSのDockerイメージをベースにしようとしてDocker Hubから引いてきたとき、中身を確認するために docker history を流しました。
そこに、外部サービスのAPIキーらしき文字列が、伏字なしで表示されました。ENV API_KEY=... の形で焼き込まれていたのです。作者は本番では使っていない検証用の鍵だと思いますが、世界中の誰でもpullして読める状態でした。
このとき、ひやりとしたのは自分のことでした。私も昔、検証用のイメージで COPY .env . をやったことがある。あのイメージ、レジストリに残っていないだろうか、と。確認したら、幸いプライベートでしたが、もし公開していたら同じことになっていました。他人の事故は、たいてい自分にも起こりえます。
なぜマルチステージでも秘密は消えないのか
そもそも、なぜイメージに秘密が残るのでしょうか。理由はDockerイメージのデータ構造にあります。
Dockerイメージは、読み取り専用のレイヤーを積み重ねた構造です。RUN・COPY・ADD の命令ひとつごとに、変更分だけを記録した新しいレイヤーが1枚作られます。各レイヤーはtarballとして保存され、内容ハッシュで管理されます。地層のようなもので、上に土を盛っても、下に埋めたものは下の地層に残り続けます。
ここに落とし穴があります。COPY .env で秘密を入れたあと、後続のレイヤーで RUN rm .env しても、秘密は消えません。削除という操作も「上に積む新しいレイヤー」でしかなく、.env を含む下のレイヤーはそのまま残るからです。最終的なファイル一覧から見えなくなるだけで、レイヤーを1枚ずつ展開すれば中身は出てきます。
ARG や ENV も同じです。ビルド時に渡した値はイメージのメタデータに記録され、docker history で丸見えになります。
「レイヤーをまとめれば消えるのでは」と考える人もいます。--squash で全レイヤーを1枚に潰す方法です。これは当てになりません。squashしても、ビルド途中のレイヤーがキャッシュやレジストリに残ることがあり、ENV の値はメタデータに残ります。レイヤーを潰すのは、焼き込んだ秘密を隠す対症療法であって、根本対策にはなりません。秘密は、そもそも焼き込まないのが唯一の正解です。
ARG と ENV は秘密の置き場所ではありません。渡した値はイメージのレイヤー履歴に保存され、docker history で誰でも読めます。認証情報をこの2つで渡してはいけません。
実演:ビルドしたイメージから秘密を抜く
仕組みだけでは実感が湧きません。実際に抜いてみます。
まず、よくある「うっかり」を含むDockerfileです。
FROM python:3.12-slim
WORKDIR /app
COPY .env . # 秘密ごとレイヤーに焼き込まれる
COPY . .
RUN rm .env # 消したつもり。だが下のレイヤーには残る
CMD ["python", "main.py"]
これをビルドします。最終的なコンテナ内に .env は存在しません。RUN rm したからです。ですが、秘密はイメージに残っています。
docker history で見る
docker history --no-trunc myapp:latest
--no-trunc を付けると、各レイヤーを作った命令が省略なしで表示されます。ARG で渡した値や、RUN 内にベタ書きした認証情報は、ここに伏字なしで出ます。出力はこんな具合です。
CREATED BY SIZE
ENV API_KEY=sk-live-abcd1234efgh5678 0B
COPY .env . # buildkit 112B
RUN /bin/sh -c rm .env # buildkit 0B
ENV の値が平文で見え、COPY .env のレイヤーも RUN rm のレイヤーも別々に記録されています。削除した事実は記録されますが、焼き込んだ事実は取り消されません。
レイヤーを展開して読む
docker history に出ない COPY .env の中身も、イメージを展開すれば取り出せます。
# イメージをtarに書き出す
docker save myapp:latest -o myapp.tar
mkdir extract && tar -xf myapp.tar -C extract
# レイヤーのtarを片端から展開して .env を探す
find extract -name "*.tar" -exec tar -tf {} \; | grep -i env
# 見つけたレイヤーを展開すれば中身が読める
RUN rm .env をしていても、.env を焼き込んだレイヤーのtarballは残っています。そこを展開すれば、秘密はそのまま出てきます。攻撃者がやることは、これだけです。
公開レジストリにpushしたイメージは、誰でもpullして展開できます。Docker Hub上で1万件を超えるイメージから認証情報が見つかったという調査もあります。「コンテナの中から見えないから安全」は、レイヤーを展開する相手には通用しません。
公開イメージは実際に漏れている
これは理論上のリスクではありません。
セキュリティ各社の調査では、Docker Hubなどの公開イメージから、APIキー・データベース認証情報・秘密鍵などが大量に検出されています。1万件超のイメージで認証情報の混入が確認されたという報告もあります。多くは COPY .env やbuild ARG経由の、ごくありふれたミスです。
なぜこれほど起きるのか。理由は、COPY .env . がいちばん手っ取り早く動くからです。ローカルで .env を読んでいたアプリを、そのままコンテナでも動かそうとすると、.env をイメージに入れるのが最短ルートに見えます。動いてしまうので、問題に気づきません。秘密がレイヤーに焼かれていることは、docker history を叩くまで表に出てこないからです。
つまり、これは「一部の不注意な人」の問題ではありません。Dockerでアプリをコンテナ化する人なら誰でも踏みうる、構造的な罠です。動くコードほど見直されないので、むしろ真面目に動かした人ほどはまります。だからこそ、人の注意ではなく仕組みで防ぐ必要があります。
スキャンはCIに組み込むのが効きます。trivy image --scanners secret をpush前のパイプラインに入れておけば、秘密入りのイメージがレジストリに出る前に止められます。人間のレビューは見落としますが、機械は毎回同じ精度で全レイヤーを走査します。ここは自動化に任せる場所です。
正しく守る:BuildKit secret mount とマルチステージ
ここからが対策です。ビルド時に秘密を使いつつ、レイヤーに焼き込まない方法があります。
BuildKit の secret mount
RUN --mount=type=secret は、秘密を「そのRUN命令の実行中だけ」ファイルとしてマウントします。マウントされた秘密はどのレイヤーにも書き込まれず、docker history にも出ません。これが本命の解決策です。
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY . .
# 秘密はこのRUNの間だけマウントされ、レイヤーに残らない
RUN --mount=type=secret,id=pipconf,target=/root/.pip/pip.conf \
pip install -r requirements.txt
# ビルド時に秘密ファイルを渡す
docker build --secret id=pipconf,src=$HOME/.pip/pip.conf -t myapp .
その他の必須対策
-
.dockerignore:
.envやキー類を.dockerignoreに書き、COPY . .で巻き込まないようにする。.gitignoreとは別ファイルなので、両方に書く - マルチステージビルド: ビルド専用ステージで秘密を使い、最終ステージには成果物だけをコピーする。ただしビルドステージのレイヤーに秘密を焼くと無意味なので、secret mountと併用する
- 実行時に注入する: 認証情報はイメージに入れず、起動時に環境変数やマウントで渡す。イメージは「鍵の入っていない箱」に保つ
-
スキャンする:
trivy image --scanners secret myapp:latestでpush前にレイヤーを走査し、秘密の混入を検出する
安全なDockerfileの全体像
ここまでの対策をまとめると、最終的なDockerfileはこういう形になります。
# syntax=docker/dockerfile:1
# --- ビルドステージ:秘密はsecret mountで一時利用 ---
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=secret,id=pipconf,target=/root/.pip/pip.conf \
pip install --prefix=/install -r requirements.txt
# --- 実行ステージ:成果物だけをコピー、秘密は一切焼かない ---
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
# 認証情報はイメージに入れず、起動時に渡す
CMD ["python", "main.py"]
ポイントは、ビルドステージで使った秘密が最終イメージに一切渡らないことです。実行時の認証情報は、docker run --env-file やオーケストレータのsecret機能で起動時に注入します。イメージ自体は「鍵の入っていない箱」のまま保てます。
対策の優先順位はこうです。(1).dockerignoreで秘密をビルドコンテキストから外す → (2)ビルド時に要る秘密はBuildKit secret mountで渡す → (3)実行時の認証情報はimageに入れず起動時に注入する → (4)push前にTrivyでスキャンする。COPY .envは、そもそも選択肢に入れません。
まとめ
- Dockerイメージはレイヤーの積み重ね。
COPY .envした秘密はRUN rmしても下のレイヤーに残る -
ARG・ENVで渡した値はdocker historyで誰でも読める。秘密の置き場所にしない -
docker saveでイメージを展開すれば、焼き込まれた秘密はそのまま取り出せる - 公開イメージから1万件超の認証情報漏れが実際に確認されている
- 守り方はBuildKit secret mount + .dockerignore + 実行時注入 + Trivyスキャン
知識は、人生の難易度を下げます。「レイヤーは積み重ね」というデータ構造をひとつ知っているだけで、COPY .env という1行が一生書けなくなります。仕組みを知る側に回りましょう。
git履歴側の「削除しても残る秘密」については別記事で扱っています。VCSとコンテナイメージは、秘密の残り方がまったく違うので、両方を押さえておくと守りが固くなります。
