- 対象: Docker Compose で nginx + PHP(WordPress 等)の Web サイトを動かしている Web 担当者・制作会社・サーバー管理者
- 解決すること: アプリの脆弱性を突かれたときに、被害がコンテナの外(ホストや他のコンテナ)まで広がらないよう、コンテナの権限を最小限に絞る
-
前提: Linux / Docker Engine + Docker Compose v2(
docker composeコマンド)
この記事はご自身が管理するサーバー・コンテナの点検と設定見直しを目的としています。本番に入れる前に、必ず検証環境で動作を確認してください。
なぜ「コンテナだから安全」ではないのか
コンテナは便利ですが、何も設定しなければ権限はかなり広い 状態で動きます。Docker 公式ドキュメントに書かれている既定の挙動を整理すると次のとおりです。
| 項目 | 既定の挙動 | 何が困るか |
|---|---|---|
| 実行ユーザー |
user 未指定なら root
|
アプリが乗っ取られると、コンテナ内で root 権限を取られる |
| リソース | 制限なし(no resource constraints) | 1 つのコンテナの暴走でホストのメモリを使い切る |
| Docker デーモン | 原則 root 権限で動作 |
docker.sock を渡すとホスト全体を操作できる |
docker グループ |
root 相当の権限(公式の警告) | グループに入れたユーザー = root と同じ |
Docker は capability(root 権限を細かく分けたもの)を許可リスト方式で絞っていますが、公式ドキュメントのベストプラクティスは「プロセスが明示的に必要とするもの以外の capability はすべて外す」です。
つまり「コンテナに入れたから隔離されている」ではなく、Compose ファイルに書いた分だけ隔離が強くなる と考えます。本記事では、compose.yaml に足していける 7 つの設定を順に紹介します。
① まず現状を棚卸しする
いま動いているコンテナが、どんな権限で動いているかを一覧にします。
# 稼働中コンテナの実行ユーザー・特権・capability・読み取り専用・セキュリティオプションを一覧
docker ps -q | xargs docker inspect --format \
'{{.Name}} user={{.Config.User}} privileged={{.HostConfig.Privileged}} capadd={{.HostConfig.CapAdd}} capdrop={{.HostConfig.CapDrop}} ro={{.HostConfig.ReadonlyRootfs}} secopt={{.HostConfig.SecurityOpt}} mem={{.HostConfig.Memory}} pids={{.HostConfig.PidsLimit}}'
見るポイントは次の 5 つです。
| 表示 | 要注意の状態 |
|---|---|
user= |
空欄(= root で動いている) |
privileged= |
true(ほぼすべての制限が外れる) |
capadd= / capdrop=
|
capadd に SYS_ADMIN 等がある / capdrop が空 |
ro= |
false(ファイルシステムに書き込み放題) |
mem= / pids=
|
0 や空欄(上限なし) |
ホストのどこをマウントしているかも確認します。特に docker.sock とホストの / は要注意です。
# マウント一覧(docker.sock・/etc・/ のマウントがないか)
docker ps -q | xargs docker inspect --format \
'{{.Name}}{{range .Mounts}} {{.Source}}->{{.Destination}}(rw={{.RW}}){{end}}'
Compose ファイルの最終形(include や環境変数の展開後)は docker compose config で確認できます。以降の設定を入れたあとも、このコマンドで反映を確かめます。
② root ではなく一般ユーザーで動かす
user を書かないとコンテナのプロセスは root で動きます。まず実行ユーザーを一般ユーザーに変える のが一番効きます。
nginx は非 root 版のイメージを使う
公式 nginx イメージはマスタープロセスが root で起動します。NGINX が公開している nginx/nginx-unprivileged は、最初から非 root で動くように調整された版です。主な違いは次のとおりです。
- 待ち受けポートが 80 → 8080(非 root では 1024 未満のポートを使わない前提)
- PID ファイルが
/tmp/nginx.pid、一時ファイルの置き場所が/tmp/*
旧名の nginxinc/nginx-unprivileged は Docker Hub 上で「メンテナンス終了」とされているので、新しい名前の方を使います。
自前の nginx.conf を丸ごと差し替える場合は、PID のパスを書かないと Permission denied で起動しません。
# nginx.conf(nginx-unprivileged 用)
pid /tmp/nginx.pid;
events {}
http {
include /etc/nginx/mime.types;
server_tokens off;
server {
listen 8080;
root /var/www/html;
index index.php index.html;
location ~ \.php$ {
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}
}
PHP-FPM は user で一般ユーザーを指定する
公式 php:*-fpm イメージには www-data ユーザーがあるので、Compose 側で指定します。
services:
php:
image: php:8.4-fpm
user: "www-data"
マスタープロセスごと一般ユーザーで動くため、ログに「user directive is ignored(root で起動していないので pool の user 指定は無視)」という通知が出ますが、動作上の問題はありません。
自前でイメージを作る場合は、Docker 公式のビルドのベストプラクティスに沿って Dockerfile で USER を指定します。UID/GID は再ビルドしても変わらないよう 数値で固定 するのがおすすめです。
FROM php:8.4-fpm
# 必要な拡張の導入など root が要る処理を先に済ませる
RUN docker-php-ext-install pdo_mysql
# 最後に一般ユーザーへ切り替える
USER www-data
WordPress 公式イメージは、初回起動時に本体ファイルを /var/www/html へコピーするなど root を前提にした処理があります。いきなり本番で user を変えず、検証環境で初期セットアップを済ませてから切り替えて動作を確認してください。
③ capability をすべて外し、権限の昇格を禁止する
root を「細かい権限(capability)」に分けた単位で、全部外してから必要なものだけ戻す のが基本です。②で非 root にした nginx・PHP-FPM は、通常どれも不要です。
services:
web:
image: nginx/nginx-unprivileged:stable-alpine
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
php:
image: php:8.4-fpm
user: "www-data"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
-
cap_drop: [ALL]… capability を全部外す。起動しない場合だけ、エラー内容を見て 必要な 1 つをcap_addで戻す(まとめて戻さない) -
no-new-privileges:true… setuid されたコマンド等で 実行中に権限が上がることを禁止 する
そして privileged: true は書かない のが原則です。「とりあえず動いたから」で付いたままの設定がないか、①の棚卸しで確認します。
④ ファイルシステムを読み取り専用にする
アプリが乗っ取られたとき、攻撃者は Web シェルやバックドアをディスクに置こうとします。コンテナのファイルシステムを読み取り専用にして、書き込める場所を最小限に限定 すると、置ける場所そのものが減ります。
services:
web:
image: nginx/nginx-unprivileged:stable-alpine
read_only: true
tmpfs:
- /tmp # PID・一時ファイル(nginx-unprivileged の置き場所)
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./src:/var/www/html:ro # ソースは読み取り専用でマウント
php:
image: php:8.4-fpm
user: "www-data"
read_only: true
tmpfs:
- /tmp # セッション・アップロードの一時ファイル
volumes:
- ./src:/var/www/html:ro
- uploads:/var/www/html/uploads # 書き込みが必要な場所だけボリュームにする
volumes:
uploads:
ポイントは 3 つです。
-
read_only: trueでコンテナ全体を読み取り専用に - 一時ファイルが必要な場所(
/tmp等)だけtmpfs(メモリ上の一時領域で、再起動すると消える)にする - ソースコードは
:roでマウントし、書き込みが本当に必要なディレクトリ(アップロード先・キャッシュ)だけ を書き込み可能なボリュームにする
アップロード先は書き込めてしまうので、そこで PHP が実行されないよう nginx 側で止めておきます。
# アップロード先での PHP 実行を禁止(server ブロック内・PHP の location より前に書く)
location ^~ /uploads/ {
location ~ \.php$ { deny all; }
}
WordPress の場合は wp-content/uploads(と、使っていればキャッシュ系プラグインのディレクトリ)が書き込み先になります。プラグインやテーマの更新を管理画面から行う運用だと書き込み先が増えるので、更新はイメージの作り直し・デプロイで行う運用 に寄せると、読み取り専用の効果が大きくなります。
⑤ ホストとの境界を守る(docker.sock・マウント・ポート)
docker.sock をコンテナに渡さない
/var/run/docker.sock は Docker デーモンへの入口です。デーモンは原則 root で動くため、ソケットを触れるコンテナは 新しいコンテナを作ってホストの / をマウントする、といった操作ができてしまいます。公式ドキュメントも「Docker デーモンを操作できるのは信頼できるユーザーだけにすべき」としています。
# NG: アプリ用のコンテナに渡さない
volumes:
- /var/run/docker.sock:/var/run/docker.sock
:ro を付けても安全にはなりません。:ro が止めるのはソケットファイル自体への書き込みで、ソケット越しの API 操作は止まらない からです。リバースプロキシや監視ツールでどうしても必要な場合は、そのコンテナだけに限定し、Web アプリのコンテナには絶対に渡さないようにします。
同じ理由で、ホストのユーザーを安易に docker グループに入れないことも大切です(公式の注意書きのとおり root 相当になります)。
ホストのディレクトリは必要な場所だけ・読み取り専用で
/・/etc・/root・ホームディレクトリ全体などのマウントは避け、アプリが必要とする最小限のディレクトリだけ を、可能なら :ro でマウントします(④の例のとおり)。
ポートは必要なものだけ・内部向けは外に出さない
DB などの内部向けサービスには ports を書かず、外部から到達できないネットワーク に置きます。
services:
web:
image: nginx/nginx-unprivileged:stable-alpine
ports:
- "127.0.0.1:8080:8080" # ホスト側のリバースプロキシ経由で公開する場合
networks: [front, back]
php:
image: php:8.4-fpm
networks: [back]
db:
image: mariadb:11
networks: [back] # ports は書かない
networks:
front:
back:
internal: true # このネットワークからは外部へ出られない
Docker が公開したポートは ufw の設定を素通りすることがある点は、関連記事の「MySQL/MariaDB を外部に晒さない 7 つの設定」で詳しく解説しています。
⑥ メモリ・CPU・プロセス数に上限を付ける
Docker 公式ドキュメントのとおり、コンテナは既定で リソース制限なし です。ホストのメモリが尽きるとカーネルがプロセスを強制終了(OOM)し始め、関係のないコンテナまで巻き込まれます。
services:
php:
image: php:8.4-fpm
mem_limit: 512m # メモリ上限
cpus: "1.0" # CPU 1 コア分まで
pids_limit: 200 # プロセス数の上限(fork を繰り返す暴走対策)
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m" # ログ 1 ファイルの上限
max-file: "3" # 世代数
- 上限値は
docker statsで普段の使用量を見てから、ピーク時の 1.5〜2 倍 程度に決めると安全です - PHP-FPM は
pm.max_children× 1 プロセスあたりのメモリがmem_limitに収まるように調整します -
json-fileのログは何も指定しないと ローテーションされずに増え続けます。ディスクが埋まるとサイト全体が止まるので、ログ上限も一緒に入れておきます
設定後は次のコマンドで反映を確認します。
docker compose up -d
docker inspect --format '{{.Name}} mem={{.HostConfig.Memory}} cpus={{.HostConfig.NanoCpus}} pids={{.HostConfig.PidsLimit}}' $(docker compose ps -q)
⑦ イメージを固定し、定期的に作り直して脆弱性を検査する
タグを固定する
latest や無指定は、いつのまにか別のバージョンに変わります。最低でもメジャー・マイナーまでタグを固定し、より厳密にするならダイジェスト(イメージの中身のハッシュ)で固定します。
services:
php:
# タグで固定(更新は自分のタイミングで)
image: php:8.4-fpm
# より厳密に: ダイジェストで固定(値は docker buildx imagetools inspect php:8.4-fpm で確認)
# image: php:8.4-fpm@sha256:<ダイジェスト>
Docker 公式も、ダイジェスト固定は「同じイメージが保証される代わりに、セキュリティ修正が自動では入らなくなる」と注意しています。固定したら、更新の仕組みとセットにします(Dependabot は Docker のイメージ更新にも対応しています)。
定期的に作り直す
ベースイメージには日々セキュリティ修正が入ります。公式のベストプラクティスにあるとおり、--pull(新しいベースイメージを取得)と --no-cache(全手順をやり直し)を併用して定期的に再ビルドします。
# 新しいベースイメージを取得して全手順をやり直す
docker compose build --pull --no-cache
docker compose up -d
# 使われなくなった古いイメージを掃除
docker image prune -f
脆弱性を検査する
Docker Scout(Docker Hub へのログインが必要)で、重大度の高い既知の脆弱性を確認します。
# Critical / High だけ表示
docker scout cves --only-severity critical,high php:8.4-fpm
# CI 用: 該当があれば終了コード 2 で失敗させる
docker scout cves --exit-code --only-severity critical,high php:8.4-fpm
# 修正版がある脆弱性だけに絞る(すぐ対応できるもの)
docker scout cves --only-fixed php:8.4-fpm
検出されたら「ベースイメージの更新 → 再ビルド → 再検査」を回します。修正版がまだ出ていないものは、②〜⑥の権限の絞り込みで 悪用されたときの被害範囲を小さくしておく のが現実的な対策です。
完成形の compose.yaml
ここまでの設定をまとめると次のようになります。
services:
web:
image: nginx/nginx-unprivileged:stable-alpine
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 256m
cpus: "0.5"
pids_limit: 100
ports:
- "127.0.0.1:8080:8080"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./src:/var/www/html:ro
- uploads:/var/www/html/uploads:ro # nginx は配信するだけなので読み取り専用
networks: [front, back]
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
php:
image: php:8.4-fpm
user: "www-data"
read_only: true
tmpfs:
- /tmp
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 512m
cpus: "1.0"
pids_limit: 200
volumes:
- ./src:/var/www/html:ro
- uploads:/var/www/html/uploads
networks: [back]
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
volumes:
uploads:
networks:
front:
back:
internal: true
uploads ボリュームの所有者が root のままだと PHP-FPM(www-data)から書き込めません。初回だけ所有者を合わせます。
docker compose run --rm --user root php chown -R www-data:www-data /var/www/html/uploads
入れたあとは ① の棚卸しコマンドをもう一度実行し、user が埋まり、ro=true・capdrop=[ALL]・mem と pids に値が入っていることを確認します。
まとめ
| # | 設定 | 効果 |
|---|---|---|
| ① |
docker inspect で実行ユーザー・特権・capability・マウントを棚卸し |
危ない設定の発見 |
| ② |
user: / USER / nginx-unprivileged で非 root 化 |
乗っ取られても root を取られない |
| ③ |
cap_drop: [ALL] + no-new-privileges / privileged 禁止 |
権限の昇格を防ぐ |
| ④ |
read_only + tmpfs + 書き込み先だけボリューム |
バックドアを置ける場所を減らす |
| ⑤ |
docker.sock を渡さない / マウント最小化 / internal ネットワーク |
ホストと内部サービスを守る |
| ⑥ |
mem_limit / cpus / pids_limit / ログ上限 |
暴走・ディスク枯渇で全体が止まるのを防ぐ |
| ⑦ | タグ固定 + build --pull --no-cache + docker scout cves
|
既知の脆弱性を放置しない |
コンテナは「入れれば安全」ではなく、書いた分だけ安全になる 仕組みです。①の棚卸しで 1 つでも要注意の項目が見つかったら、まずそのコンテナから ②③④ を足すところから始めてみてください。
関連記事
- MySQL/MariaDBを外部に晒さない7つの設定(Docker・phpMyAdmin対応)
- WordPressの改ざんを防ぐファイル権限とPHP実行制限
- npm/Composerの脆弱な依存を止める7つの設定
本記事のような設定の抜けは、Web サイトを 9 つの守りでまるごと守るサイトドックでまとめて対策できます → https://sitedock.jp