目次

Docker セキュリティのベストプラクティス:コンテナを安全にする10の方法

コンテナは一定の分離を提供しますが、安全を保証するものではありません。開発からデプロイまで実施すべき基本的な対策を、実行結果とともに紹介します。

1. rootで実行しない

$ docker run --rm alpine:3.23 id
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)

$ docker run --rm -u 10001:10001 alpine:3.23 id
uid=10001 gid=10001 groups=10001

$ docker run --rm node:24-alpine id node
uid=1000(node) gid=1000(node) groups=1000(node),1000(node)
  • コンテナ内ではデフォルトで root(uid=0)として実行されます。プログラムに脆弱性があり侵入された場合、攻撃者はコンテナ内で最大の権限を持ち、ほかの脆弱性と組み合わせるとホストに影響を及ぼす可能性があります。
  • -u 10001:10001を使うと、イメージを変更せずに実行時のユーザーを指定できます。
  • 多くの公式イメージには一般ユーザーがあらかじめ用意されています。たとえばnodeイメージのnode(uid 1000)や、distrolessイメージのnonrootです。

最善の方法は、Dockerfileでユーザーを切り替えることです。

RUN adduser -D -u 10001 app      # Alpine
# RUN useradd --system --uid 10001 app   # Debian / Ubuntu
USER app

実際の例はPythonアプリケーションとNode.jsアプリケーションをご覧ください。

2. 信頼できるベースイメージを使い、バージョンを固定する

  • Docker Official Images(python、node、nginxなど)または著名な組織が公開しているイメージを優先してください。
  • 出所が不明なイメージは使わないでください。特に、人気のイメージに名前がよく似たもの(例:ngnix)には注意してください。
  • nodeやlatestではなく、node:24-alpineや、より細かく指定したnode:24.21-alpineのようにバージョンを固定してください。完全に固定するには、digestを追加します:node:24-alpine@sha256:...。

3. イメージをできるだけ小さくする

イメージ内の各パッケージに脆弱性が含まれる可能性があります。slim、alpine、distrolessやマルチステージビルドを使い、実行に必要なものだけを残してください。不要なツール(コンパイラー、curl、ssh)が少ないほど、攻撃者に悪用される可能性も低くなり、脆弱性スキャンの結果も改善します。

4. シークレットをイメージに含めない

実践:シークレットはイメージに残る

次のDockerfileには、よくある間違いが3つあります。

FROM alpine:3.23
ARG NPM_TOKEN
ENV DB_PASSWORD=super-secret
COPY .env /app/.env
RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
CMD ["sh"]

最後の行で.envを使った後に削除しているので、問題ないように見えます。ビルドして確認しましょう。

$ docker build -q -f Dockerfile.bad --build-arg NPM_TOKEN=npm_abc123secret -t leaky .
sha256:c9523bb4d0484e3abf049d15dec57df3282ff8203574c03127a8dfc6ee37bf9b

$ docker run --rm leaky ls /app

$ docker inspect -f "{{.Config.Env}}" leaky
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DB_PASSWORD=super-secret]

$ docker history --no-trunc --format "{{.CreatedBy}}" leaky | head -n 4
CMD ["sh"]
RUN |1 NPM_TOKEN=npm_abc123secret /bin/sh -c echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env # buildkit
COPY .env /app/.env # buildkit
ENV DB_PASSWORD=super-secret
  • ls /appの結果は空で、.envは削除されたように見えます。
  • しかし、docker inspectにはENVで設定したDB_PASSWORD=super-secretがそのまま表示されます。
  • docker historyには、ビルド時に渡したNPM_TOKEN=npm_abc123secretが表示されます。ARGの値は、それを使用したレイヤーに記録されます。

.envはどうでしょうか。イメージは複数のレイヤーを重ねてできています。rmは新しいレイヤーで「削除」を示すだけなので、COPYしたレイヤーの内容は残っています。docker saveでイメージをエクスポートし、各レイヤーから.envを探してみましょう。

$ docker save leaky -o leaky.tar && mkdir x && tar -xf leaky.tar -C x && for l in x/blobs/sha256/*; do tar -tzf $l 2>/dev/null | grep -q "app/.env" && tar -xzOf $l app/.env; done; rm -rf x leaky.tar
API_KEY=sk-live-1234567890abcdef

.envの全内容が取り出されました。このイメージを取得できる人なら誰でも同じことができます。Registryにプッシュするイメージでは、誰もがすべてのレイヤーの内容を見られると想定してください。

docker build --checkで早期に検出する

docker build --checkは変数名から、ARGやENVがシークレットらしいかどうかを判断します。

$ docker build --check -f Dockerfile.bad . 2>&1 | sed -n "/Check complete/,\$p"
Check complete, 2 warnings have been found!

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN")
Dockerfile.bad:2
--------------------
   1 |     FROM alpine:3.23
   2 | >>> ARG NPM_TOKEN
   3 |     ENV DB_PASSWORD=super-secret
   4 |     COPY .env /app/.env
--------------------

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data (ENV "DB_PASSWORD")
Dockerfile.bad:3
--------------------
   1 |     FROM alpine:3.23
   2 |     ARG NPM_TOKEN
   3 | >>> ENV DB_PASSWORD=super-secret
   4 |     COPY .env /app/.env
   5 |     RUN echo "use the token: $NPM_TOKEN" > /dev/null && rm /app/.env
--------------------

NPM_TOKENとDB_PASSWORDは検出されます。しかし、このチェックが見るのはARGとENVの名前だけです。COPY .envのようにシークレットファイルをコピーする問題は検出できないため、.dockerignoreも必要です。

正しい方法

  • Dockerfile で ENV や ARG を使ってパスワードや API キーを渡さないでください。
  • .dockerignore で .env、秘密鍵、.git を除外し、COPY . . でコピーされないようにします。
  • 実行時に必要な秘密情報は、環境変数、--env-file、または Compose secrets を使って実行時に渡します。環境変数と .env ファイルを参照してください。
  • ビルド時に必要な秘密情報(プライベートパッケージをインストールするためのトークンなど)は、BuildKit の secret マウントを使います。
FROM alpine:3.23
RUN --mount=type=secret,id=npm_token \
    test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
CMD ["sh"]
$ NPM_TOKEN=npm_abc123secret docker build --progress plain --no-cache --secret id=npm_token,env=NPM_TOKEN -f Dockerfile.good -t safe . 2>&1 | grep "token length"
#5 [stage-0 2/2] RUN --mount=type=secret,id=npm_token     test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)"
#5 0.261 token length: 16

$ docker history --no-trunc --format "{{.CreatedBy}}" safe | head -n 2
CMD ["sh"]
RUN /bin/sh -c test -s /run/secrets/npm_token && echo "token length: $(wc -c < /run/secrets/npm_token)" # buildkit

$ docker run --rm safe ls /run/secrets
ls: /run/secrets: No such file or directory
  • ビルド時の RUN は /run/secrets/npm_token からトークン(長さ 16)を読み取れます。
  • docker history に残るのはコマンドだけで、トークンの値は記録されません。
  • イメージにも /run/secrets は含まれません。secret はその RUN の実行中だけマウントされます。

5. 不要なポートを外部に公開しない

# 悪い例:データベースを全世界に公開
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=secret postgres:18

# 良い例:ローカルからのみ接続可能
docker run -d -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=secret postgres:18

Linux では、Docker が iptables ルールを直接変更します。-p で公開したポートは、ufw や firewalld などのファイアウォール設定を迂回します。ufw で 5432 を許可していなくても、外部から接続できます。最も安全なのは、データベースやキャッシュなどの内部サービスで -p を使わず、他のコンテナから Docker ネットワーク経由で接続する方法です。たとえばNginx リバースプロキシでは、ports を設定するのは Nginx だけです。

6. コンテナの権限を制限する

読み取り専用ファイルシステム

$ docker run --rm --read-only alpine:3.23 touch /tmp/x
touch: /tmp/x: Read-only file system

$ docker run --rm --read-only --tmpfs /tmp alpine:3.23 sh -c "touch /tmp/x && echo ok"
ok

--read-only を指定すると、コンテナのファイルシステムが読み取り専用になり、攻撃者がプログラムを変更したり、悪意のあるファイルを書き込んだりできなくなります。プログラムが書き込みを必要とする場所(たとえば /tmp)には、--tmpfs でメモリ上の一時領域をマウントするか、Volume をマウントします。

Linux capabilities を削除する

Linux では root の権限が複数の capabilities に分割されています。たとえば CHOWN(ファイル所有者の変更)や NET_BIND_SERVICE(1024 未満のポートへのバインド)があります。Docker はデフォルトで、コンテナに一部の capabilities を付与します。

$ docker run --rm alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok

$ docker run --rm --cap-drop ALL alpine:3.23 chown nobody /etc/hostname
chown: /etc/hostname: Operation not permitted

$ docker run --rm --cap-drop ALL --cap-add CHOWN alpine:3.23 chown nobody /etc/hostname && echo "chown ok"
chown ok
  • デフォルトでは chown を実行できます。
  • --cap-drop ALL ですべての capabilities を削除すると、root でも Operation not permitted になります。
  • --cap-add を使い、本当に必要なものだけを追加します。

ほとんどの Web アプリケーションは capabilities を必要としないため、そのまま --cap-drop ALL を指定できます。

権限の昇格を禁止する

$ docker run --rm alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs:	0

$ docker run --rm --security-opt no-new-privileges alpine:3.23 grep NoNewPrivs /proc/self/status
NoNewPrivs:	1

NoNewPrivs: 1 は、コンテナ内のプロセスが setuid プログラム(sudo や su など)を使って権限を昇格できないことを示します。一般ユーザーとして実行する場合も、指定することをおすすめします。

まとめると、次のようになります。

docker run -d \
  --user 10001:10001 \
  --read-only --tmpfs /tmp \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  myapp

Compose では次のように記述します。

services:
  app:
    image: myapp
    user: "10001:10001"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true

--privileged は決して安易に使わないでください。コンテナにすべての capabilities を付与し、ホスト上のすべてのデバイスへのアクセスを許可するため、隔離をほぼ無効にしてしまいます。

7. リソースを制限する

$ docker run --rm --pids-limit 50 alpine:3.23 sh -c "for i in \$(seq 100); do sleep 5 & done; wait" 2>&1 | sort | uniq -c
      1 sh: can't fork: Resource temporarily unavailable

$ docker run -d --name limited --memory 512m --cpus 1 --pids-limit 200 nginx:1.30-alpine
WARNING: Your kernel does not support swap limit capabilities or the cgroup is not mounted. Memory limited without swap.
2edbff0c81d73b83f0055b781d8d8ad20661b2b64ef1dfd5a6def334ef02723c

$ docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.PIDs}}" limited
NAME      CPU %     MEM USAGE / LIMIT   PIDS
limited   0.00%     3.406MiB / 512MiB   2

$ docker inspect -f "Memory={{.HostConfig.Memory}} NanoCpus={{.HostConfig.NanoCpus}} PidsLimit={{.HostConfig.PidsLimit}}" limited
Memory=536870912 NanoCpus=1000000000 PidsLimit=200

$ docker rm -f limited
limited
  • --pids-limit 50 を指定すると、コンテナが 100 個のプロセスを作成しようとしても、上限を超えた時点で can't fork: Resource temporarily unavailable になります。これにより、fork bomb がホスト全体を停止させるのを防げます。
  • --memory 512m --cpus 1 はメモリと CPU を制限します。docker stats の LIMIT には 512MiB と表示されます。メモリ上限を超えたプロセスはシステムによって終了され(OOM)、ホスト全体のメモリ不足を防ぎます。
  • WARNING: Your kernel does not support swap limit capabilities は、ホストのカーネルで swap 制限が有効になっていないことを示します。メモリ制限は引き続き有効ですが、swap は同時に制限できません。この警告は無視できます。

Compose では次のように記述します。

services:
  app:
    image: myapp
    mem_limit: 512m
    cpus: 1
    pids_limit: 200

8. Docker socketを保護する

/var/run/docker.sock は Docker の制御インターフェースです。これにアクセスできれば、ホストのルートディレクトリをマウントするコンテナを含め、あらゆるコンテナを作成できます。ホストの / をコンテナにマウントして実際に確認します。

$ docker run --rm -v /:/host alpine:3.23 sh -c "head -n 2 /host/etc/os-release; ls /host/var/lib/docker | head -n 5"
NAME="Alpine Linux"
ID=alpine
buildkit
containerd
containers
engine-id
image

コンテナから Docker ホストの /etc/os-release と /var/lib/docker を直接読み取れました(このチュートリアルのテスト環境は Alpine Linux です)。同じ方法で /etc/shadow や SSH キーなど、あらゆるファイルを読み書きできます。そのため、次の点に注意してください。

  • docker コマンドを実行できることは、ホストの root 権限を持つことと同じです。ユーザーを docker グループに追加することは、そのユーザーに root 権限を与えることに相当します。
  • -v /var/run/docker.sock:/var/run/docker.sock を安易にコンテナへマウントしないでください。Traefik、Portainer、Trivy など、必要とするツールのイメージを完全に信頼できる場合に限ります。
  • Docker daemon の TCP ポート(2375)をネットワークに公開しないでください。ホストの root 権限を公開するのと同じです。
  • より強い分離が必要な場合は、Docker の rootless モードを検討してください。

9. イメージの脆弱性をスキャンする

オープンソースツールの Trivy は、イメージ内の OS パッケージやプログラミング言語のパッケージに含まれる既知の脆弱性をスキャンできます。コンテナで実行するため、インストールは不要です。

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx:1.30-alpine

Report Summary

┌───────────────────────────────────┬────────┬─────────────────┐
│              Target               │  Type  │ Vulnerabilities │
├───────────────────────────────────┼────────┼─────────────────┤
│ nginx:1.30-alpine (alpine 3.24.2) │ alpine │        3        │
└───────────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)


nginx:1.30-alpine (alpine 3.24.2)
=================================
Total: 3 (HIGH: 3, CRITICAL: 0)

┌──────────┬─────────────────┬──────────┬────────┬───────────────────┬───────────────┬─────────────────────────────────────────────────────────┐
│ Library  │  Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                          Title                          │
├──────────┼─────────────────┼──────────┼────────┼───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ libexpat │ CVE-2026-93990  │ HIGH     │ fixed  │ 2.8.4-r0          │ 2.8.5-r0      │ expat: Expat: XML Injection via Malformed UTF-16 Input  │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-93990              │
├──────────┼─────────────────┤          │        ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ pcre2    │ CVE-2026-103111 │          │        │ 10.48-r0          │ 10.49-r0      │ pcre2: pcre2: Out-of-bounds write via crafted regular   │
│          │                 │          │        │                   │               │ expression                                              │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-103111             │
├──────────┼─────────────────┤          │        ├───────────────────┼───────────────┼─────────────────────────────────────────────────────────┤
│ tiff     │ CVE-2026-4775   │          │        │ 4.7.1-r0          │ 4.7.2-r0      │ libtiff: libtiff: Arbitrary code execution or denial of │
│          │                 │          │        │                   │               │ service via signed integer...                           │
│          │                 │          │        │                   │               │ https://avd.aquasec.com/nvd/cve-2026-4775               │
└──────────┴─────────────────┴──────────┴────────┴───────────────────┴───────────────┴─────────────────────────────────────────────────────────┘
  • --severity HIGH,CRITICAL は HIGH 以上の重大度の脆弱性だけを表示し、--quiet は脆弱性データベースのダウンロード進捗を非表示にします。
  • docker.sock をマウントすると、Trivy はローカルのイメージを読み取れます。trivy-cache Volume は脆弱性データベースを保存するため、次回のスキャンで再ダウンロードする必要がありません。
  • 結果では、nginx:1.30-alpine のベースである Alpine に HIGH レベルの脆弱性が 3 件あり、いずれも Status は fixed です。また、Fixed Version も示されています。つまり、Alpine はすでに修正を公開していますが、このイメージのビルド時にはまだ含まれていません。

スキャン結果は時間とともに変わります。新しい脆弱性が毎日発見され、イメージも継続的に更新されるため、実行時に表示される結果は異なる可能性があります。

Docker Desktop と新しい CLI には Docker Scout(docker scout quickview、docker scout cves)も組み込まれており、Docker アカウントにログインすると利用できます。CI フローで自動的にスキャンし、重大な脆弱性が見つかった場合はデプロイを止めることをおすすめします。

10. 定期的な更新

ベースイメージの脆弱性は継続的に修正されます。上記のスキャン結果を踏まえ、Dockerfile でパッケージを更新してから再スキャンします。

$ cat Dockerfile
FROM nginx:1.30-alpine
RUN apk upgrade --no-cache

$ docker build -q -t nginx-patched .
sha256:c88ea56e7062e7703dccc5eb2fedea74c333f33982d9735753056a50858646f2

$ docker run --rm -v /var/run/docker.sock:/var/run/docker.sock -v trivy-cache:/root/.cache aquasec/trivy:latest image --quiet --scanners vuln --severity HIGH,CRITICAL nginx-patched

Report Summary

┌───────────────────────────────┬────────┬─────────────────┐
│            Target             │  Type  │ Vulnerabilities │
├───────────────────────────────┼────────┼─────────────────┤
│ nginx-patched (alpine 3.24.2) │ alpine │        0        │
└───────────────────────────────┴────────┴─────────────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)

apk upgrade で脆弱性のあるパッケージを修正版に更新すると、HIGH と CRITICAL の脆弱性数は 0 になります。

実際には、定期的に docker build --pull で再ビルドする方法がより適切です(--pull は最新のベースイメージを先にダウンロードします)。その後、再デプロイします。Dependabot や Renovate などのツールを使って、ベースイメージの新しいバージョンを自動で通知できます。一度だけビルドして、その後何年も更新しないイメージでは、脆弱性が増え続けます。

チェックリスト

  • Dockerfile に USER を設定し、root で実行しない
  • 信頼できる提供元のベースイメージを使い、バージョンを固定する
  • 軽量イメージまたはマルチステージビルドを使う
  • イメージにパスワード、キー、.env が含まれていない(docker history と docker inspect で確認する)
  • ビルド時のシークレットには RUN --mount=type=secret を使う
  • データベースなどの内部サービスのポートを外部に公開しない
  • 必要に応じて --read-only、--cap-drop ALL、no-new-privileges を使う
  • --privileged を使わず、docker.sock をむやみにマウントしない
  • メモリ、CPU、PID の上限を設定する
  • CI でイメージの脆弱性をスキャンする
  • セキュリティ更新を取り込むため、定期的に --pull で再ビルドする

まとめ

  • Dockerfile に USER を追加し、root 以外のユーザーで実行します。
  • ARG、ENV、削除したファイルもイメージに残ります。ビルド時のシークレットには --secret を使います。
  • 外部公開が不要なサービスでは -p を使いません。-p は ufw を迂回します。
  • docker.sock を使えることは、ホストの root 権限を持つことと同じです。
  • 定期的にスキャンし、--pull で再ビルドします。

練習問題

練習 1:docker history --no-trunc を使って、自分でビルドしたイメージにパスワードやトークンが表示されていないか確認しましょう。

ヒント

CREATED BY 欄にある ARG、ENV、RUN 命令の内容と、docker inspect -f "{{json .Config.Env}}" 映像檔 を確認します。見つかった場合は --secret または実行時の環境変数に切り替え、漏えいしたキーを失効させてください。

よくある質問

コンテナ内のrootはホストのrootと同じですか?

通常モードでは同じuidですが、namespace、capabilities、seccompによる制限があります。ホストのrootとして実行させないには、[Rootlessモード](/ja/docker/rootless-podman/)を使います。

イメージスキャンで脆弱性がたくさん見つかった場合はどうすればよいですか?

まずFixed Versionがある高リスクの脆弱性に対処します。再ビルド(docker build --pull)するか、パッケージをアップグレードしてください。修正版がない場合は、そのパッケージを本当に使うか評価します。

Docker ScoutとTrivyはどう違いますか?

どちらも脆弱性スキャンツールです。ScoutはDocker Desktopに統合されており、ログインが必要です。Trivyはオープンソースツールで、コンテナを使って実行できます。

更新日 2026年10月14日