Dockerコンテナのセキュリティ:開発者向けベストプラクティス

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

5分ですぐに試してみよう

理論に入る前に、違いをすぐに実感してほしい。このコマンドを実行して、コンテナがどの権限で動いているか確認しよう:

# コンテナ内で実行中のユーザーを確認する
docker exec -it ten_container whoami

# 'root'と表示された場合 → コンテナにセキュリティ上の問題がある

結果がrootなら、この記事はあなたのためにある。コンテナをrootで実行するのは玄関のドアを鍵もかけずに開けておくようなものだ——普段は問題ないが、アプリに脆弱性があれば、攻撃者は簡単にホストまで権限昇格できる。

最速の修正:DockerfileにUSERを追加する:

FROM node:20-alpine

# 専用ユーザーを作成し、rootを使わない
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# アプリ起動前にappuserに切り替える
USER appuser

CMD ["node", "server.js"]

ビルドし直して再起動すると、whoamiappuserを返す。完了。多くのチームが見逃しがちな最も一般的な脆弱性の一つを塞いだことになる。

Dockerのアイソレーションだけでは不十分な理由

DockerはLinux namespacesとcgroupsを使ってコンテナを分離している——堅牢に聞こえるが、これはソフトウェアアイソレーションであってハードウェアではない。コンテナはホストとカーネルを共有している。CVE-2019-5736(runcの脆弱性)とCVE-2020-15257(Containerd)はその典型例だ:ランタイムの脆弱性を突かれると、攻撃者がコンテナを脱出してホストの制御を奪えてしまう——これをコンテナエスケープと呼ぶ。

以前のプロジェクトのコードレビューでこのケースを実際に見た:rootで動くコンテナ、/var/run/docker.sockのマウント、そしてアプリにRCE脆弱性。この三つが揃えば最悪の状態だ——攻撃者はホスト上のDockerデーモン全体を制御でき、それ以上の操作は不要になる。

最小権限の原則

各コンテナが持つべき権限は、必要なものだけに限定する。それ以上は不要だ。基本チェックリスト:

  • Non-root user: コンテナ内では常に一般ユーザーを使用する
  • Read-only filesystem: アプリが書き込み不要ならファイルシステムをread-onlyでマウントする
  • Drop capabilities: 不要なLinux capabilitiesを削除する
  • No privileged mode: 本当に必要な場合を除き、--privilegedは絶対に使わない

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

実際の経験から言うと、Node.js/Python製のWebアプリの約80%はランタイム中にディスクへの書き込みが不要だ——/tmpにtmpfsを追加するだけで十分。攻撃者がRCEを持っていてもファイルを書き込めなければ、パーシステンスの確立がかなり難しくなる:

# ファイルシステムをread-onlyにしてコンテナを実行する
docker run --read-only \
  --tmpfs /tmp \
  --tmpfs /var/run \
  -p 3000:3000 \
  your-app:latest

# またはdocker-compose.ymlで
services:
  app:
    image: your-app:latest
    read_only: true
    tmpfs:
      - /tmp
      - /var/run

--tmpfsは、アプリが書き込む必要があるディレクトリ(一時ファイル、PIDファイル)のためにRAM上に一時領域を作成する。

応用編:ネットワーク分離とシークレット管理

カスタムブリッジネットワークによるネットワーク分離

デフォルトでは、同じDockerホスト内の全コンテナがブリッジネットワーク越しに互いを認識できる。NginxはDBに、アプリはRedisに、監視ツールは全てに見える——境界が存在しない。サービスが5〜10個になると、これはかなり大きな攻撃面になる。

スタック全体をdocker-compose v1からv2に移行したとき、ついでにネットワークをより明確に分離した——かなりスムーズにいったし、今も使っているパターンだ:

version: '3.8'

services:
  nginx:
    image: nginx:alpine
    networks:
      - frontend
    ports:
      - "80:80"

  app:
    image: your-app:latest
    networks:
      - frontend   # nginxからのリクエストを受け取る
      - backend    # データベースと通信する

  db:
    image: postgres:15
    networks:
      - backend    # appのみアクセス可能、nginxからは見えない
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

networks:
  frontend:
  backend:
    internal: true  # バックエンドネットワークからインターネットアクセス不可

secrets:
  db_password:
    file: ./secrets/db_password.txt

この設定により、nginxはデータベースに直接接続できず、アプリ層を経由する必要がある。nginxが侵害されても、攻撃者はDBに直接アクセスできない。

シークレット管理——環境変数にシークレットを入れない

環境変数は便利だが深刻な問題がある:子プロセスが全て継承して読み取れてしまう。さらにdocker inspect ten_containerで全ての環境変数がダンプされる——ホスト上でそのコマンドを実行できる人は誰でもDBパスワードを読める。クラッシュダンプやアプリケーションログも同様に一般的な漏洩源だ。

Docker Secrets(Swarm向け)またはファイルマウントがより良い方法だ:

# ファイルからシークレットを作成する
echo "super_secret_password" | docker secret create db_password -

# シークレットはコンテナ内の/run/secrets/db_passwordにマウントされる
# アプリは環境変数ではなくファイルから読み取る
# Pythonアプリ内:ファイルからシークレットを読み取る
import os

def get_db_password():
    secret_path = '/run/secrets/db_password'
    if os.path.exists(secret_path):
        with open(secret_path, 'r') as f:
            return f.read().strip()
    # 開発環境用のフォールバック
    return os.environ.get('DB_PASSWORD', '')

Linuxケーパビリティの削除

コンテナはデフォルトで、一般的なWebアプリには不要なLinux capabilitiesをいくつか持っている。全て削除し、必要なものだけを追加しよう:

services:
  app:
    image: your-app:latest
    cap_drop:
      - ALL          # 全てのcapabilitiesを削除する
    cap_add:
      - NET_BIND_SERVICE  # 必要なものだけ追加する(例:1024未満のポートをバインド)
    security_opt:
      - no-new-privileges:true  # プロセスの権限昇格を防ぐ

実際のプロジェクトで使える実践的なTips

デプロイ前のイメージスキャン

ベースイメージには必ず脆弱性がある——問題はCRITICALがいくつあるかだけだ。本番にプッシュする前にTrivyで確認しよう:

# Trivyをインストールする
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# イメージをスキャンする
trivy image your-app:latest

# HIGHとCRITICALのみ確認する
trivy image --severity HIGH,CRITICAL your-app:latest

# CI/CDで使用し、CRITICALがあればパイプラインを失敗させる
trivy image --exit-code 1 --severity CRITICAL your-app:latest

攻撃面を減らすためにdistrolessまたはAlpineを使う

イメージが小さいほど、ツールが少ないほど、攻撃者がコンテナに侵入後にできることが減る。Node.js 20 Alpineは約180MB;distrolessはさらに小さく、シェルがない——bashなし、curlなし、横展開に使えるものが何もない:

# マルチステージビルド:フルイメージでビルドし、distrolessで実行する
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

# 最終ステージ:シェルなし、パッケージマネージャーなし
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app .
USER nonroot
CMD ["server.js"]

コンテナにDockerソケットをマウントしない

この誤りは古いチュートリアルによく見られる——コンテナからDocker APIを呼べるよう/var/run/docker.sockをマウントするものだ。便利に聞こえるが、本番環境ではやめるべきだ。ソケットにアクセスできる人は誰でもDockerデーモン全体を制御でき、ホスト上のrootと同等になる。

代替案:Portainer Agent(このブログに専用記事あり)またはTLS認証付きDocker APIを使う。

リソース制限

直接的なセキュリティではないが、DoS攻撃を受けた際の被害を軽減するのに役立つ:

services:
  app:
    image: your-app:latest
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: 512M
        reservations:
          memory: 256M

デプロイ前の簡単チェックリスト

  • [ ] コンテナがnon-rootユーザーで実行されている(DockerfileのUSER
  • [ ] 環境変数やイメージレイヤーにシークレットが含まれていない
  • [ ] アプリがディスク書き込み不要なら--read-onlyを設定
  • [ ] security_optにno-new-privileges:trueを設定
  • [ ] ネットワーク分離:データベースが外部に露出していない
  • [ ] Trivyでイメージをスキャン済み、CRITICAL脆弱性なし
  • [ ] ベースイメージにalpineまたはdistrolessを使用
  • [ ] /var/run/docker.sockをマウントしていない
  • [ ] リソース制限が設定済み

コンテナのセキュリティは一度設定すれば終わりではない。2週間ごとにイメージをスキャンし、リリース前のスキャンを必須にしている——実際、CRITICAL脆弱性のほとんどは自分のコードではなくベースイメージに由来する。依存関係を更新したり新しいサービスを追加したりするたびに、チェックリストを実行することを忘れずに。ベースイメージにセキュリティアップデートがあれば?すぐにリビルドしよう、次のスプリントまで放置してはいけない。

Share: