本記事には広告(アフィリエイトリンク)が含まれます。

Kubernetes応用編 第07回

Kubernetes応用編 第07回
コンテナのセキュリティ強化 — SecurityContext / Pod Security Standards

7.1 はじめに

前回(第6回)では、NetworkPolicyを導入してPod間の通信を最小権限に制御しました。デフォルト拒否ポリシーの上にホワイトリストを重ねることで、「API → DBのみ許可」「Gateway経由のIngressのみ許可」という通信制御を実現しています。

現在のTaskBoardの状態を確認しておきましょう。

[app Namespace]
  Nginx (Deployment, replicas: 2) + Service (ClusterIP)
  TaskBoard API (Deployment, replicas: 2, MySQL接続版) + Service (ClusterIP)
  Gateway (taskboard-gateway) + HTTPRoute (taskboard-route)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み
  + NetworkPolicy適用済み

[db Namespace]
  MySQL (StatefulSet, replicas: 1) + Headless Service + PVC
  + DB初期化Job(Completed)
  + DBバックアップCronJob(稼働中)
  + Secret(MySQL認証情報)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み
  + NetworkPolicy適用済み

[monitoring Namespace]
  ログ収集DaemonSet(全Worker Nodeに配置)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み

[クラスタ全体]
  Calico CNI
  Metrics Server 稼働中
  Gateway API 稼働中(/ → Nginx, /api → TaskBoard API)
  マルチノード構成(CP 1 + Worker 3)

ネットワークの入口(Gateway API)と内部の通信経路(NetworkPolicy)は制御しました。しかし、コンテナ自体のセキュリティ設定はどうでしょうか。TaskBoardの各Podは、どのユーザーで動いているのか。ファイルシステムに自由に書き込めるのか。権限昇格は可能なのか。これらを意識したことがないなら、今のTaskBoardはセキュリティ上の穴を抱えています。

VMの世界でいえば、「ネットワークのファイアウォールは設定した。でもサーバー上のプロセスがrootで動いていて、SELinuxもdisabled」という状態です。外からの防御はあっても、中の守りが甘い。

BeforeAfter
コンテナのセキュリティ設定を意識したことがない。全Podがデフォルト設定で稼働中SecurityContextで最小権限のコンテナ実行環境を構築でき、Pod Security Standardsの基準を理解している

今回は、SecurityContextを使ってTaskBoardの各コンテナを「最小権限の実行環境」に強化します。その過程で、すでに非rootで動いている「良い例」(Payara Micro)と、rootで動いている「修正が必要な例」(Nginx)の対比を通じて、コンテナによって非root対応の難易度が異なることを体感してもらいます。

7.2 VMの世界との対比 — SELinuxとサービスアカウントの最小権限

7.2.1 VMでの最小権限の考え方

VMware環境でサーバーを運用する際、セキュリティの基本は「最小権限の原則」でした。具体的には以下のような対策を施していたはずです。

まず、プロセスの実行ユーザーです。ApacheやNginxはapache/nginxユーザーで動かし、MySQLはmysqlユーザーで動かす。rootで常駐プロセスを動かすのは避ける。次に、SELinuxやAppArmorによる強制アクセス制御です。プロセスがアクセスできるファイルやポートを、カーネルレベルで制限する。さらに、sudoの設定で特定コマンドだけの実行を許可し、不要なケーパビリティ(CAP_NET_RAWなど)をプロセスから剥奪する。

これらの対策は、「万が一プロセスが乗っ取られた場合の被害を最小限に抑える」ためのものです。rootで動くプロセスが攻撃者に乗っ取られれば、サーバー全体が制圧されます。非rootで動いていれば、被害はそのユーザーの権限範囲に限定されます。

7.2.2 K8sではSecurityContextがその役割を担う

Kubernetesでは、VMのSELinux/AppArmor + サービスアカウント設定に相当する機能がSecurityContextです。PodやコンテナのマニフェストにSecurityContextを記述することで、コンテナの実行環境を制限できます。

VMの世界Kubernetes
プロセスの実行ユーザー(apache, mysql等)SecurityContext: runAsUser, runAsNonRoot
SELinux / AppArmorによるファイルアクセス制御SecurityContext: readOnlyRootFilesystem
sudoの制限 / 権限昇格の防止SecurityContext: allowPrivilegeEscalation
ケーパビリティの剥奪(setcap等)SecurityContext: capabilities
サーバー全体のセキュリティポリシー(ベースライン策定)Pod Security Standards + Pod Security Admission

VMではOS設定ファイル(/etc/sysconfig等)やAnsibleで管理していた内容が、KubernetesではYAMLマニフェストに宣言的に書ける。しかもPodの再作成のたびに必ず適用されるため、「設定漏れ」が起きにくいという利点があります。

7.3 現状を確認する — TaskBoardの各Podは誰として動いているか

SecurityContextを適用する前に、まず現状を把握しましょう。TaskBoardの各コンテナが「誰として」動いているのかを確認します。

7.3.1 各Podの実行ユーザーを確認する

各Podに対してidコマンドを実行し、実行ユーザーを確認します。

[Execution User: developer]

# Nginx Podの実行ユーザーを確認
kubectl exec -n app deploy/nginx -- id

# TaskBoard API(Payara Micro)Podの実行ユーザーを確認
kubectl exec -n app deploy/taskboard-api -- id

# MySQL Podの実行ユーザーを確認
kubectl exec -n db statefulset/mysql -- id
# Nginx
uid=0(root) gid=0(root) groups=0(root)

# TaskBoard API(Payara Micro)
uid=1000(payara) gid=1000(payara) groups=1000(payara)

# MySQL
uid=999(mysql) gid=999(mysql) groups=999(mysql)

結果を整理します。

コンポーネント実行ユーザーUID状態
Nginxroot0修正が必要 — rootで動いている
TaskBoard API(Payara Micro)payara1000良い例 — すでに非root
MySQLmysql999すでに非root — 追加設定で強化可能

3つのコンテナのうち、rootで動いているのはNginxだけです。Payara MicroとMySQLは、ベースイメージの段階で非rootユーザーが設定されています。この違いが、SecurityContext適用時の対応難易度に直結します。

7.3.2 Payara Micro — すでに非rootの「良い例」

Payara Microの公式イメージ(payara/micro:7.2026.1)は、Dockerfileの段階でUSER payaraが設定されています。uid=1000のpayaraユーザーとして動作するため、SecurityContextでrunAsNonRoot: trueを指定してもそのまま起動します。

「最初から非rootで設計されたコンテナイメージ」の典型です。コンテナイメージを設計する際には、Payara Microのように最初から非rootで動作するよう作るのがベストプラクティスです。

7.3.3 Nginx — rootで動いている「修正が必要な例」

一方、標準のnginx:1.27イメージはrootで動作します。これにはいくつかの理由があります。Nginxのデフォルト設定では80番ポートをリッスンしますが、Linuxでは1024未満のポート(well-known ports)にバインドするにはroot権限(またはCAP_NET_BIND_SERVICEケーパビリティ)が必要です。また、masterプロセスがrootで起動してworkerプロセスをnginxユーザーで起動する構成がデフォルトになっています。

Nginxをrootのまま放置するのはセキュリティ上のリスクです。万が一Nginxプロセスに脆弱性が見つかり、攻撃者にシェルを奪われた場合、rootとしてコンテナ内のすべてのファイルにアクセスでき、権限昇格によってホストへの侵入につながる可能性もあります。今回はこのNginxを非root化する作業が、ハンズオンの中心になります。

7.4 SecurityContextの4つの設定項目

SecurityContextには多くのフィールドがありますが、本番で最低限設定すべき項目を4つに絞って解説します。適用の優先順位は以下の通りです。まず非rootで動かすこと、次にファイルシステムを保護すること、その上で権限昇格を防ぎ、最後にケーパビリティを最小化します。

7.4.1 runAsNonRoot / runAsUser — 実行ユーザーの制御

優先順位: 最も重要

runAsNonRoot: trueは、「このコンテナをrootで実行することを禁止する」宣言です。コンテナイメージのUSERディレクティブでrootが指定されている場合、Pod起動時にエラーになります。

securityContext:
  runAsNonRoot: true     # rootでの実行を禁止
  runAsUser: 1000        # 実行ユーザーのUIDを明示的に指定

runAsUserは、コンテナの実行UIDを明示的に指定します。イメージにUSERが設定されていても、runAsUserで上書きできます。Payara Microのようにuid=1000で動くイメージにはrunAsUser: 1000を、MySQLのようにuid=999で動くイメージにはrunAsUser: 999を指定します。

runAsNonRootだけ設定すると、イメージのUSERに依存します。runAsUserも併せて指定することで、「このUIDで動く」ことをマニフェスト上で保証できます。万が一イメージが更新されてUSERが変わっても、マニフェスト側で強制されるため安全です。

7.4.2 readOnlyRootFilesystem — ファイルシステムの書き込み制限

優先順位: 2番目

readOnlyRootFilesystem: trueは、コンテナのルートファイルシステムを読み取り専用にします。攻撃者がシェルを奪ったとしても、マルウェアをファイルシステムに書き込むことができなくなります。

securityContext:
  readOnlyRootFilesystem: true   # ルートファイルシステムを読み取り専用に

ただし、多くのアプリケーションはログの書き出し、一時ファイルの作成、キャッシュの保存などでファイルシステムへの書き込みを必要とします。readOnlyRootFilesystemを有効にする場合、書き込みが必要なディレクトリをemptyDirボリュームでマウントして書き込み可能にする必要があります。

volumeMounts:
  - name: tmp
    mountPath: /tmp       # 一時ファイル用
volumes:
  - name: tmp
    emptyDir: {}          # Pod単位の一時ボリューム

emptyDirはPodが削除されると消えるため、永続データが失われることはありません。「ルートは読み取り専用、書き込みが必要な場所だけ明示的にマウントする」というホワイトリスト思考です。NetworkPolicyのデフォルト拒否と同じ考え方ですね。

7.4.3 allowPrivilegeEscalation — 権限昇格の防止

優先順位: 3番目

allowPrivilegeEscalation: falseは、コンテナ内のプロセスが親プロセスよりも高い権限を取得することを防ぎます。Linuxのno_new_privsフラグに相当します。

securityContext:
  allowPrivilegeEscalation: false  # 権限昇格を禁止

VMの世界でいえば、sudoやsetuidビットによる権限昇格を無効化するようなものです。コンテナ内に万が一setuidバイナリが残っていても、この設定があれば権限昇格に利用できません。非rootで実行しているコンテナには必ず設定すべき項目です。

7.4.4 capabilities — Linuxケーパビリティの最小化

優先順位: 4番目

Linuxケーパビリティは、従来rootが持っていた特権を細分化した仕組みです。たとえばCAP_NET_BIND_SERVICE(1024未満のポートにバインドする権限)、CAP_SYS_PTRACE(他プロセスをデバッグする権限)など、40以上のケーパビリティがあります。

SecurityContextでは、capabilitiesフィールドでケーパビリティを制御します。推奨される設定は「すべて剥奪し、必要なものだけ追加する」パターンです。

securityContext:
  capabilities:
    drop:
      - ALL              # まずすべてのケーパビリティを剥奪
    # add:              # 必要なものがあれば個別に追加
    #   - NET_BIND_SERVICE

非rootで動くコンテナの場合、ほとんどのケーパビリティは不要です。drop: ALLで全剥奪しても問題なく動くケースが大半です。もし特定のケーパビリティが必要であれば(たとえば1024未満のポートを使いたい場合のNET_BIND_SERVICE)、addで個別に追加します。

以上4つの設定をまとめると、SecurityContextの「鉄板構成」は以下のようになります。

securityContext:
  runAsNonRoot: true                 # 1. 非rootで実行
  runAsUser: 1000                    # 1. UIDを明示
  readOnlyRootFilesystem: true       # 2. ルートFSを読み取り専用
  allowPrivilegeEscalation: false    # 3. 権限昇格を禁止
  capabilities:                      # 4. ケーパビリティを最小化
    drop:
      - ALL

では、この鉄板構成をTaskBoardの各コンテナに適用していきましょう。まずは一番手強い相手 — rootで動いているNginxからです。

7.5 Nginxを非rootで動かす — SecurityContext適用時のハマりポイント

Nginxは現在rootで動いており、SecurityContextを適用するにはコンテナ側の修正も必要です。この「コンテナイメージがroot前提で設計されている場合の対処」は、現場で非常によく出会うパターンです。手順を1つずつ進めましょう。

7.5.1 ポート変更とnginx.confの修正

非rootユーザーでNginxを動かすための最初の壁は、ポート番号です。現在のNginxは80番ポートでリッスンしていますが、Linuxでは1024未満のポートにバインドするにはroot権限が必要です。非rootで動かすには、リッスンポートを1024以上(ここでは8080)に変更する必要があります。

まず、非root対応版のnginx.confを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/nginx.conf
# 非root対応版 nginx.conf
# - listen 8080(1024未満のポートは非rootでは使用不可)
# - pid を /tmp に変更(デフォルトの /var/run/nginx.pid は書き込み不可)
# - user ディレクティブを削除(非rootでは使用不可)

worker_processes  auto;

# pidファイルを書き込み可能なパスに変更
pid        /tmp/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # 一時ファイルのパスを /tmp 配下に変更
    client_body_temp_path /tmp/client_temp;
    proxy_temp_path       /tmp/proxy_temp;
    fastcgi_temp_path     /tmp/fastcgi_temp;
    uwsgi_temp_path       /tmp/uwsgi_temp;
    scgi_temp_path        /tmp/scgi_temp;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent"';

    access_log  /tmp/access.log  main;
    error_log   /tmp/error.log;

    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       8080;       # 80 → 8080 に変更
        server_name  localhost;

        location / {
            root   /usr/share/nginx/html;
            index  index.html index.htm;
        }

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }
}
EOF

変更点を整理します。

  • userディレクティブを削除 — 非rootではworkerプロセスのユーザー切り替えができないため、このディレクティブ自体が不要です
  • listen 8080 — 80番ポートから8080番ポートに変更。1024以上なので非rootでもバインドできます
  • pid /tmp/nginx.pid — デフォルトの/var/run/nginx.pidは非rootでは書き込めないため、/tmpに変更します
  • 各種一時ファイルのパスを/tmp配下に変更 — client_body_temp_path等がデフォルトでは/var/cache/nginx配下に作成されますが、非rootでは書き込めないため/tmp配下に集約します
  • access_log/error_log/tmpに変更 — 同様の理由です

次に、このnginx.confをConfigMapとしてKubernetesに登録します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/nginx-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
  namespace: app
  labels:
    app: taskboard
    component: frontend
data:
  nginx.conf: |
    worker_processes  auto;
    pid        /tmp/nginx.pid;

    events {
        worker_connections  1024;
    }

    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;

        client_body_temp_path /tmp/client_temp;
        proxy_temp_path       /tmp/proxy_temp;
        fastcgi_temp_path     /tmp/fastcgi_temp;
        uwsgi_temp_path       /tmp/uwsgi_temp;
        scgi_temp_path        /tmp/scgi_temp;

        access_log  /tmp/access.log  main;
        error_log   /tmp/error.log;

        log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                          '$status $body_bytes_sent "$http_referer" '
                          '"$http_user_agent"';

        sendfile        on;
        keepalive_timeout  65;

        server {
            listen       8080;
            server_name  localhost;

            location / {
                root   /usr/share/nginx/html;
                index  index.html index.htm;
            }

            error_page   500 502 503 504  /50x.html;
            location = /50x.html {
                root   /usr/share/nginx/html;
            }
        }
    }
EOF

[Execution User: developer]

kubectl apply -f ~/k8s-applied/nginx-configmap.yaml
configmap/nginx-config created

7.5.2 SecurityContextとemptyDirの設定

nginx.confの準備ができたので、Nginx Deploymentを更新します。SecurityContext(非root実行 + readOnlyRootFilesystem + 権限昇格禁止 + ケーパビリティ全剥奪)を適用し、ConfigMapのマウントと書き込み用のemptyDirを設定します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/nginx-deployment-v2.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  replicas: 2
  selector:
    matchLabels:
      app: taskboard
      component: frontend
  template:
    metadata:
      labels:
        app: taskboard
        component: frontend
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 8080    # 80 → 8080 に変更
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
          # ▼ SecurityContext(本回で追加)
          securityContext:
            runAsNonRoot: true                 # rootでの実行を禁止
            runAsUser: 101                     # nginx:1.27 の nginx ユーザー (uid=101)
            readOnlyRootFilesystem: true        # ルートFSを読み取り専用
            allowPrivilegeEscalation: false     # 権限昇格を禁止
            capabilities:
              drop:
                - ALL                          # 全ケーパビリティを剥奪
          # ▼ ボリュームマウント
          volumeMounts:
            - name: nginx-config
              mountPath: /etc/nginx/nginx.conf
              subPath: nginx.conf              # ConfigMapの特定キーのみマウント
              readOnly: true
            - name: tmp
              mountPath: /tmp                  # PID、一時ファイル、ログ用
            - name: cache
              mountPath: /var/cache/nginx      # Nginxキャッシュ用
      volumes:
        - name: nginx-config
          configMap:
            name: nginx-config
        - name: tmp
          emptyDir: {}
        - name: cache
          emptyDir: {}
EOF

変更点を整理します。

  • containerPort: 8080 — リッスンポートの変更を反映
  • runAsNonRoot: true + runAsUser: 101 — nginx:1.27イメージにはnginxユーザー(uid=101)が定義されています。このユーザーで実行します
  • readOnlyRootFilesystem: true — ルートファイルシステムを読み取り専用にします
  • allowPrivilegeEscalation: false + capabilities.drop: ALL — 権限昇格とケーパビリティを最小化
  • ConfigMapマウント — nginx-config ConfigMapのnginx.confキーを/etc/nginx/nginx.confにマウント。subPathを使うことで、/etc/nginxディレクトリ内の他のファイル(mime.types等)を上書きしません
  • emptyDirマウント — /tmp(PID、一時ファイル、ログ)と/var/cache/nginx(キャッシュ)を書き込み可能にします。readOnlyRootFilesystemでルート全体が読み取り専用でも、emptyDirでマウントした箇所には書き込めます

runAsUser: 101について補足します。nginx:1.27イメージのDockerfileではnginxユーザーがuid=101で定義されています。Payara Microのpayara(uid=1000)やMySQLのmysql(uid=999)と同様に、イメージ内で定義済みのユーザーのUIDを指定するのが安全です。存在しないUIDを指定しても動作しますが、kubectl exec -- idでユーザー名が表示されず運用時に混乱する可能性があります。

7.5.3 関連リソースの更新(Service targetPort)

Nginxのリッスンポートが80から8080に変わったため、Nginx ServiceのtargetPortも更新する必要があります。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/nginx-service-v2.yaml
apiVersion: v1
kind: Service
metadata:
  name: nginx
  namespace: app
  labels:
    app: taskboard
    component: frontend
spec:
  type: ClusterIP
  selector:
    app: taskboard
    component: frontend
  ports:
    - port: 80             # Service自体のポートは80のまま
      targetPort: 8080     # 転送先をコンテナの8080に変更
EOF

ここでのポイントは、Serviceのport(80)はそのまま維持し、targetPortだけを8080に変更している点です。Service自体は80番ポートで受け付け、背後のNginx Podの8080番に転送します。これにより、HTTPRouteのbackendRefは変更不要です。

第5回で作成したHTTPRouteを確認してみましょう。

# httproute-taskboard.yaml(第5回で作成済み)の該当部分
rules:
  - matches:
      - path:
          type: PathPrefix
          value: /
    backendRefs:
      - name: nginx
        port: 80           # ← Service の port を参照。変更不要

HTTPRouteのbackendRefs.portはServiceのport(80)を参照するため、ServiceのtargetPortを変更しても影響はありません。Gateway API経由のルーティングはそのまま機能します。

7.5.4 デプロイして動作確認する

では、更新したマニフェストをデプロイしましょう。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/nginx-service-v2.yaml
kubectl apply -f ~/k8s-applied/nginx-deployment-v2.yaml
service/nginx configured
deployment.apps/nginx configured

Podのロールアウトを待ちます。

[Execution User: developer]

kubectl rollout status deployment/nginx -n app
deployment "nginx" successfully rolled out

Podが正常に起動しているか確認します。

[Execution User: developer]

kubectl get pods -n app -l component=frontend
NAME                     READY   STATUS    RESTARTS   AGE
nginx-5f8b7c9d4f-abcde   1/1     Running   0          30s
nginx-5f8b7c9d4f-fghij   1/1     Running   0          28s

Nginxが非rootで動いているか確認します。

[Execution User: developer]

kubectl exec -n app deploy/nginx -- id
uid=101(nginx) gid=101(nginx) groups=101(nginx)

uid=101(nginx)で動作しています。rootではなくなりました。

ルートファイルシステムが読み取り専用になっているか確認します。

[Execution User: developer]

# ルートFSへの書き込みを試みる
kubectl exec -n app deploy/nginx -- touch /test-file
touch: cannot touch '/test-file': Read-only file system
command terminated with exit code 1

読み取り専用になっています。一方、emptyDirでマウントした/tmpには書き込めます。

[Execution User: developer]

# emptyDirマウントの /tmp には書き込める
kubectl exec -n app deploy/nginx -- touch /tmp/test-file
echo $?
0

Gateway API経由でのアクセスも確認しましょう。

[Execution User: developer]

# Gateway のNodePortを取得
GATEWAY_PORT=$(kubectl get svc -n envoy-gateway-system \
  -l gateway.envoyproxy.io/owning-gateway-name=taskboard-gateway \
  -o jsonpath='{.items[0].spec.ports[0].nodePort}')

# Nginx(フロントエンド)にアクセス
curl -s -o /dev/null -w "%{http_code}" http://localhost:${GATEWAY_PORT}/

# TaskBoard API にアクセス
curl -s -o /dev/null -w "%{http_code}" http://localhost:${GATEWAY_PORT}/api/tasks
200
200

フロントエンド(Nginx)もAPI(TaskBoard API)も、Gateway API経由で正常にアクセスできています。Nginxの非root化による影響は、外部からの利用者にとっては完全に透過的です。

ここまでの作業で、Nginxは以下のセキュリティ強化を達成しました。

項目BeforeAfter
実行ユーザーroot (uid=0)nginx (uid=101)
ルートFS読み書き可読み取り専用
権限昇格可能禁止
ケーパビリティデフォルト(複数付与)全剥奪(ALL drop)

7.6 TaskBoard API(Payara Micro)にSecurityContextを追加する

次は「良い例」であるPayara Microです。すでに非rootで動いているため、Nginxのようなコンテナ側の修正は不要です。SecurityContextの宣言を追加するだけで完了します。

7.6.1 すでに非rootであることの確認

先ほど確認したとおり、Payara Microはpayaraユーザー(uid=1000)で動いています。Payara Microの公式DockerfileではUSER payaraが指定されているため、SecurityContextのrunAsNonRoot: trueをそのまま設定できます。

Nginxとの対比を明確にしましょう。Nginxでは「ポート変更 → nginx.conf修正 → ConfigMap作成 → Deployment更新」という一連の作業が必要でした。Payara Microでは「DeploymentにsecurityContextブロックを追加するだけ」です。コンテナイメージが最初から非rootで設計されていると、SecurityContextの適用がどれほど楽かがわかります。

7.6.2 readOnlyRootFilesystem + emptyDirの設定

Payara MicroにreadOnlyRootFilesystem: trueを適用する場合、書き込みが必要なディレクトリをemptyDirでマウントする必要があります。Payara Microが書き込むディレクトリは以下の通りです。

  • /tmp — Javaの一時ファイル領域。多くのライブラリがjava.io.tmpdirとして使用します
  • /opt/payara/config — Payara Microが実行時に設定ファイルを生成・更新するディレクトリ

7.6.3 capabilities制限の追加

Payara Microは8080番ポート(1024以上)でリッスンするため、NET_BIND_SERVICEケーパビリティも不要です。drop: ALLで全剥奪できます。

7.6.4 デプロイして動作確認する

SecurityContext適用版のTaskBoard API Deploymentを作成します。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/taskboard-api-deployment-v3.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskboard-api
  namespace: app
  labels:
    app: taskboard
    component: api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: taskboard
      component: api
  template:
    metadata:
      labels:
        app: taskboard
        component: api
    spec:
      containers:
        - name: taskboard-api
          image: taskboard-api:2.0.0
          imagePullPolicy: Never
          ports:
            - containerPort: 8080
          env:
            - name: DB_HOST
              value: "mysql-0.mysql-headless.db.svc.cluster.local"
            - name: DB_NAME
              value: "taskboard"
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_USER
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-secret
                  key: MYSQL_PASSWORD
          resources:
            requests:
              cpu: "200m"
              memory: "384Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          # ▼ SecurityContext(本回で追加)
          securityContext:
            runAsNonRoot: true                 # rootでの実行を禁止
            runAsUser: 1000                    # payaraユーザー (uid=1000)
            readOnlyRootFilesystem: true        # ルートFSを読み取り専用
            allowPrivilegeEscalation: false     # 権限昇格を禁止
            capabilities:
              drop:
                - ALL                          # 全ケーパビリティを剥奪
          # ▼ 書き込み用ボリュームマウント
          volumeMounts:
            - name: tmp
              mountPath: /tmp                  # Java一時ファイル領域
            - name: payara-config
              mountPath: /opt/payara/config     # Payara実行時設定
      volumes:
        - name: tmp
          emptyDir: {}
        - name: payara-config
          emptyDir: {}
EOF

第3回(v2)からの変更点は以下の通りです。

  • securityContextブロックの追加 — 非root実行、readOnlyRootFilesystem、権限昇格禁止、ケーパビリティ全剥奪
  • volumeMountsvolumesの追加 — /tmp/opt/payara/configをemptyDirでマウント
  • その他(image、env、resources)は第3回から変更なし

デプロイします。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/taskboard-api-deployment-v3.yaml
deployment.apps/taskboard-api configured

[Execution User: developer]

kubectl rollout status deployment/taskboard-api -n app
deployment "taskboard-api" successfully rolled out

動作確認を行います。

[Execution User: developer]

# 実行ユーザーの確認
kubectl exec -n app deploy/taskboard-api -- id

# MicroProfile Healthエンドポイントの確認
kubectl exec -n app deploy/taskboard-api -- curl -s http://localhost:8080/health/ready

# API動作確認(タスク一覧の取得)
GATEWAY_PORT=$(kubectl get svc -n envoy-gateway-system \
  -l gateway.envoyproxy.io/owning-gateway-name=taskboard-gateway \
  -o jsonpath='{.items[0].spec.ports[0].nodePort}')
curl -s http://localhost:${GATEWAY_PORT}/api/tasks | head -1
uid=1000(payara) gid=1000(payara) groups=1000(payara)
{"status":"UP","checks":[]}
[{"id":1,"title":"サンプルタスク","completed":false}]

Payara Microは問題なく動作しています。SecurityContextの追加だけで、コンテナ側の修正は一切不要でした。これが「最初から非rootで設計されたコンテナイメージ」の強みです。

ルートファイルシステムの読み取り専用も確認しておきましょう。

[Execution User: developer]

kubectl exec -n app deploy/taskboard-api -- touch /test-file
touch: cannot touch '/test-file': Read-only file system
command terminated with exit code 1

Nginxで20分以上かかった作業が、Payara Microでは5分で完了しました。コンテナイメージを選定する際に「非rootで動くか」を確認しておくことの重要性がわかります。

7.7 MySQLにSecurityContextを追加する

MySQLにもSecurityContextを追加しましょう。MySQLはmysqlユーザー(uid=999)で動作していますが、データベースという特性上、ファイルシステムへの書き込みが本質的な動作の一部であるため、いくつか考慮が必要です。

[Execution User: developer]

cat <<'EOF' > ~/k8s-applied/mysql-statefulset-v2.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: mysql
  namespace: db
  labels:
    app: taskboard
    component: db
spec:
  serviceName: mysql-headless
  replicas: 1
  selector:
    matchLabels:
      app: taskboard
      component: db
  template:
    metadata:
      labels:
        app: taskboard
        component: db
    spec:
      containers:
        - name: mysql
          image: mysql:8.0
          envFrom:
            - secretRef:
                name: mysql-secret
          ports:
            - containerPort: 3306
              name: mysql
          resources:
            requests:
              cpu: "200m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          # ▼ SecurityContext(本回で追加)
          securityContext:
            runAsNonRoot: true                 # rootでの実行を禁止
            runAsUser: 999                     # mysqlユーザー (uid=999)
            allowPrivilegeEscalation: false     # 権限昇格を禁止
            capabilities:
              drop:
                - ALL                          # 全ケーパビリティを剥奪
            # readOnlyRootFilesystem は適用しない(下記参照)
          volumeMounts:
            - name: mysql-data
              mountPath: /var/lib/mysql
            - name: tmp
              mountPath: /tmp                  # MySQL一時ファイル用
            - name: run-mysqld
              mountPath: /var/run/mysqld        # MySQLソケットファイル用
      volumes:
        - name: tmp
          emptyDir: {}
        - name: run-mysqld
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: mysql-data
      spec:
        accessModes:
          - ReadWriteOnce
        resources:
          requests:
            storage: 1Gi
EOF

MySQLのSecurityContextで注意すべき点を整理します。

readOnlyRootFilesystemはMySQLには適用していません。MySQLは起動時に/var/lib/mysql以外にも/var/lib/mysql-files/var/lib/mysql-keyringなど複数のディレクトリへの書き込みを必要とします。さらに、MySQLの初期化プロセスでは/docker-entrypoint-initdb.d内のスクリプト処理などでルートファイルシステムの各所に書き込みが発生します。これらすべてのパスをemptyDirでマウントすることは可能ですが、MySQLのバージョンアップで書き込みパスが変わるリスクを考えると、データベースコンテナにreadOnlyRootFilesystemを強制するのは費用対効果が低いと判断しました。

一方、runAsNonRootallowPrivilegeEscalation: falsecapabilities.drop: ALLは適用しています。これらは書き込みパスに影響しないため、問題なく動作します。また、/tmp/var/run/mysqld(ソケットファイル格納ディレクトリ)をemptyDirでマウントしています。

「すべての設定を適用する」ことが目的ではなく、「リスクとコストのバランスで判断する」のが実務のセキュリティ設計です。

デプロイします。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/mysql-statefulset-v2.yaml
statefulset.apps/mysql configured

StatefulSetの更新では、Podが1つずつ再作成されます。完了を待ちましょう。

[Execution User: developer]

kubectl rollout status statefulset/mysql -n db
statefulset rolling update complete 1 pods at revision mysql-7d8b9c6f5...

[Execution User: developer]

# 実行ユーザーの確認
kubectl exec -n db statefulset/mysql -- id

# MySQLへの接続確認
kubectl exec -n db statefulset/mysql -- \
  mysql -u taskuser -ptaskpass -e "SELECT 1 AS test;"
uid=999(mysql) gid=999(mysql) groups=999(mysql)
+------+
| test |
+------+
|    1 |
+------+

MySQLも正常に動作しています。データの永続化も確認しておきましょう。

[Execution User: developer]

# API経由でタスクが取得できるか確認
GATEWAY_PORT=$(kubectl get svc -n envoy-gateway-system \
  -l gateway.envoyproxy.io/owning-gateway-name=taskboard-gateway \
  -o jsonpath='{.items[0].spec.ports[0].nodePort}')
curl -s http://localhost:${GATEWAY_PORT}/api/tasks
[{"id":1,"title":"サンプルタスク","completed":false}]

SecurityContext適用後も、TaskBoard全体の機能は正常に保たれています。

7.8 Pod Security Standards — Namespaceレベルのセキュリティ基準

ここまで、個別のPodにSecurityContextを適用してきました。しかし、チームで運用していると「新しいPodを追加するとき、SecurityContextの設定を忘れる」というヒューマンエラーが発生します。

Pod Security Standards(PSS)は、Namespace単位で「このNamespace内のPodは、最低限このセキュリティ基準を満たさなければならない」というルールを設定する仕組みです。個別のPodではなく、Namespace全体にセキュリティのベースラインを強制できます。

7.8.1 3つのレベル — Privileged / Baseline / Restricted

Pod Security Standardsでは、3つのセキュリティレベルが定義されています。

レベル概要用途
Privileged制限なし。すべての設定が許可されるシステムコンポーネント、インフラ系(kube-system等)
Baseline既知の危険な設定を禁止。最低限の安全性を確保一般的なアプリケーション。導入のハードルが低い
Restricted最も厳しい制限。非root実行、全ケーパビリティ剥奪等を強制セキュリティ要件が厳しい本番環境

Restrictedレベルで要求される主な設定は以下の通りです。

  • runAsNonRoot: true が必須
  • allowPrivilegeEscalation: false が必須
  • capabilities.drop: ["ALL"] が必須
  • seccompProfile.type: RuntimeDefault または Localhost が必須

TaskBoardの各Podには、先ほどSecurityContextでrunAsNonRootallowPrivilegeEscalation: falsecapabilities.drop: ALLを設定しました。Restrictedレベルに近い状態にあります。

7.8.2 Pod Security Admission — enforce / audit / warn

Pod Security Standardsのレベルは、Pod Security Admission(PSA)というKubernetes組み込みのAdmission Controllerによって適用されます。PSAはNamespaceにラベルを付けるだけで有効化できます。

PSAには3つのモードがあります。

モード動作用途
enforce基準を満たさないPodの作成を拒否する本番環境での強制適用
audit基準を満たさないPodを許可するが、監査ログに記録する移行期間中の影響調査
warn基準を満たさないPodを許可するが、CLIに警告を表示する開発時のフィードバック

設定は、Namespaceにラベルを付けるだけです。

# ラベルの形式
pod-security.kubernetes.io/<モード>: <レベル>

# 例: app Namespace に Restricted レベルを warn モードで適用
kubectl label namespace app \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=latest

本番環境では段階的に導入するのがベストプラクティスです。まずwarnで警告を確認し、問題がないことを確認してからenforceに切り替えます。既存のPodが稼働している環境でいきなりenforceを適用すると、Podの再作成時に起動拒否されてサービス断になるリスクがあります。

7.8.3 app Namespaceにwarnモードで適用して検証する

では、app NamespaceにRestrictedレベルをwarnモードで適用しましょう。

[Execution User: developer]

kubectl label namespace app \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=latest
namespace/app labeled

ラベルが設定されたことを確認します。

[Execution User: developer]

kubectl get namespace app --show-labels
NAME   STATUS   AGE   LABELS
app    Active   7d    kubernetes.io/metadata.name=app,pod-security.kubernetes.io/warn=restricted,pod-security.kubernetes.io/warn-version=latest

warnモードの効果を確認するために、SecurityContext未設定のPodを作成してみましょう。

[Execution User: developer]

# SecurityContext未設定のPodを作成してみる
kubectl run test-pod --image=nginx:1.27 -n app
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false (container "test-pod" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "test-pod" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "test-pod" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "test-pod" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
pod/test-pod created

warnモードなのでPodは作成されますが、警告が表示されています。Restrictedレベルで要求される設定が不足していることが具体的に指摘されています。enforceモードであれば、このPodは作成を拒否されます。

テスト用Podを削除しておきます。

[Execution User: developer]

kubectl delete pod test-pod -n app

では、TaskBoardのDeploymentを再applyして、警告が出ないことを確認しましょう。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/nginx-deployment-v2.yaml
kubectl apply -f ~/k8s-applied/taskboard-api-deployment-v3.yaml

ここで警告が出る可能性があります。RestrictedレベルではseccompProfileの設定も要求されるためです。警告が出た場合の対処を見てみましょう。

Warning: would violate PodSecurity "restricted:latest": seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/nginx configured
Warning: would violate PodSecurity "restricted:latest": seccompProfile (pod or container "taskboard-api" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/taskboard-api configured

seccompProfile(Seccomp = Secure Computing Mode)はコンテナが発行できるシステムコールを制限する仕組みです。Restrictedレベルで完全に準拠するには、この設定も必要です。各DeploymentのSecurityContextに以下を追加します。

securityContext:
  seccompProfile:
    type: RuntimeDefault   # コンテナランタイムのデフォルトプロファイルを使用

RuntimeDefaultは、containerd等のコンテナランタイムが提供するデフォルトのSeccompプロファイルを使用します。ほとんどのアプリケーションはこの設定で正常に動作します。

Nginx DeploymentにseccompProfileを追加します。

[Execution User: developer]

# nginx-deployment-v2.yaml の securityContext に seccompProfile を追加
# 既存の securityContext ブロックに1行追加するだけです
kubectl patch deployment nginx -n app --type='json' -p='[
  {"op": "add", "path": "/spec/template/spec/containers/0/securityContext/seccompProfile", "value": {"type": "RuntimeDefault"}}
]'

# taskboard-api-deployment-v3.yaml にも同様に追加
kubectl patch deployment taskboard-api -n app --type='json' -p='[
  {"op": "add", "path": "/spec/template/spec/containers/0/securityContext/seccompProfile", "value": {"type": "RuntimeDefault"}}
]'
deployment.apps/nginx patched
deployment.apps/taskboard-api patched

マニフェストファイルにも反映しておきましょう。先ほど作成したnginx-deployment-v2.yamltaskboard-api-deployment-v3.yamlsecurityContextブロックに、以下の行を追加してください。

          securityContext:
            runAsNonRoot: true
            runAsUser: 101                     # Nginx の場合(Payara は 1000)
            readOnlyRootFilesystem: true
            allowPrivilegeEscalation: false
            seccompProfile:                    # ← 追加
              type: RuntimeDefault             # ← 追加
            capabilities:
              drop:
                - ALL

再度applyして、警告が出なくなることを確認します。

[Execution User: developer]

kubectl apply -f ~/k8s-applied/nginx-deployment-v2.yaml
kubectl apply -f ~/k8s-applied/taskboard-api-deployment-v3.yaml
deployment.apps/nginx configured
deployment.apps/taskboard-api configured

警告が出なくなりました。app Namespaceの2つのDeploymentは、Pod Security StandardsのRestrictedレベルに準拠しています。

db Namespaceにも同様にwarnモードを適用します。ただし、MySQLはreadOnlyRootFilesystemを適用していないため、RestrictedではなくBaselineレベルを適用するのが現実的です。

[Execution User: developer]

kubectl label namespace db \
  pod-security.kubernetes.io/warn=baseline \
  pod-security.kubernetes.io/warn-version=latest
namespace/db labeled

BaselineレベルはrunAsNonRootを要求しないため、MySQLでも警告なく適用できます。将来的にMySQLのSecurityContextをさらに強化できれば、Restrictedレベルに引き上げることも可能です。

7.9 全体の最終確認

SecurityContext適用後のTaskBoard全体の正常性を確認しましょう。

[Execution User: developer]

# 全Podのステータスを確認
echo "=== app Namespace ==="
kubectl get pods -n app
echo ""
echo "=== db Namespace ==="
kubectl get pods -n db
echo ""
echo "=== monitoring Namespace ==="
kubectl get pods -n monitoring
=== app Namespace ===
NAME                             READY   STATUS    RESTARTS   AGE
nginx-5f8b7c9d4f-abcde           1/1     Running   0          5m
nginx-5f8b7c9d4f-fghij           1/1     Running   0          5m
taskboard-api-6c9d8e7f1a-klmno   1/1     Running   0          4m
taskboard-api-6c9d8e7f1a-pqrst   1/1     Running   0          4m

=== db Namespace ===
NAME      READY   STATUS    RESTARTS   AGE
mysql-0   1/1     Running   0          3m

=== monitoring Namespace ===
NAME                     READY   STATUS    RESTARTS   AGE
log-collector-xxxxx      1/1     Running   0          7d
log-collector-yyyyy      1/1     Running   0          7d
log-collector-zzzzz      1/1     Running   0          7d

[Execution User: developer]

# 各Podの実行ユーザーを再確認
echo "Nginx:"
kubectl exec -n app deploy/nginx -- id
echo "TaskBoard API:"
kubectl exec -n app deploy/taskboard-api -- id
echo "MySQL:"
kubectl exec -n db statefulset/mysql -- id
Nginx:
uid=101(nginx) gid=101(nginx) groups=101(nginx)
TaskBoard API:
uid=1000(payara) gid=1000(payara) groups=1000(payara)
MySQL:
uid=999(mysql) gid=999(mysql) groups=999(mysql)

[Execution User: developer]

# Gateway API 経由の E2E 確認
GATEWAY_PORT=$(kubectl get svc -n envoy-gateway-system \
  -l gateway.envoyproxy.io/owning-gateway-name=taskboard-gateway \
  -o jsonpath='{.items[0].spec.ports[0].nodePort}')

echo "フロントエンド:"
curl -s -o /dev/null -w "%{http_code}" http://localhost:${GATEWAY_PORT}/
echo ""
echo "API:"
curl -s http://localhost:${GATEWAY_PORT}/api/tasks | head -1
フロントエンド:
200
API:
[{"id":1,"title":"サンプルタスク","completed":false}]

全コンポーネントが非rootで動作し、アプリケーション全体が正常に機能しています。

7.10 この回のまとめ

7.10.1 TaskBoardの現在地

今回の作業で、TaskBoardに以下が追加されました。

[app Namespace]
  Nginx (Deployment, replicas: 2) + Service (ClusterIP)
    ★ SecurityContext適用(非root uid=101、readOnlyRootFilesystem、capabilities drop ALL)
    ★ ConfigMap(非root対応版nginx.conf)
    ★ containerPort: 8080、Service targetPort: 8080
  TaskBoard API (Deployment, replicas: 2, MySQL接続版) + Service (ClusterIP)
    ★ SecurityContext適用(非root uid=1000、readOnlyRootFilesystem、capabilities drop ALL)
  Gateway (taskboard-gateway) + HTTPRoute (taskboard-route)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み
  + NetworkPolicy適用済み
  + Pod Security Admission: warn=restricted ← 今回追加

[db Namespace]
  MySQL (StatefulSet, replicas: 1) + Headless Service + PVC
    ★ SecurityContext適用(非root uid=999、capabilities drop ALL)
    ★ readOnlyRootFilesystemは未適用(DB特有の書き込み要件のため)
  + DB初期化Job(Completed)
  + DBバックアップCronJob(稼働中)
  + Secret(MySQL認証情報)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み
  + NetworkPolicy適用済み
  + Pod Security Admission: warn=baseline ← 今回追加

[monitoring Namespace]
  ログ収集DaemonSet(全Worker Nodeに配置)
  + ResourceQuota / LimitRange 適用済み
  + RBAC適用済み

[クラスタ全体]
  Calico CNI
  Metrics Server 稼働中
  Gateway API 稼働中(/ → Nginx, /api → TaskBoard API)
  マルチノード構成(CP 1 + Worker 3)

7.10.2 SecurityContext設計の判断基準 — 最低限設定すべき項目の優先順位

優先順位設定項目判断基準
1(必須)runAsNonRoot: true + runAsUserほぼすべてのワークロードで設定すべき。rootで動かす正当な理由がない限り適用する
2(強く推奨)allowPrivilegeEscalation: false非rootコンテナには必ず設定する。設定しない理由がない
3(推奨)capabilities.drop: ALL非rootコンテナのほとんどで適用可能。特定のケーパビリティが必要な場合のみaddで追加
4(可能であれば)readOnlyRootFilesystem: trueアプリの書き込みパスを把握したうえで適用。DB等の書き込みが本質的なワークロードでは、コストと効果を天秤にかける
判断ケース
SecurityContextを適用すべき本番環境のすべてのワークロード。コンプライアンス要件(CIS Benchmark、PCI DSS等)がある場合。マルチテナント環境
段階的に適用する既存のワークロードが多数稼働している環境。まずwarnモードで影響を確認し、段階的にenforceに移行する
一部を適用しないことが許容されるreadOnlyRootFilesystemがDBコンテナに適用困難な場合など。理由を文書化したうえで他の項目は必ず適用する

7.10.3 実践編への橋渡し

今回学んだSecurityContextの知識は、実践編で以下の場面で使います。

  • 実践編 第3回(詳細設計): SecurityContextの各設定値を「なぜこの設計にしたか」という根拠とともに設計書に落とし込みます。今回の「Nginxはuid=101、Payara Microはuid=1000、MySQLはreadOnlyRootFilesystem未適用」といった判断の理由を設計書に記録します
  • 実践編 第5回(アプリケーション構築): 設計書通りにSecurityContextを適用した状態でデプロイし、Pod Security Admissionの警告がないことを受け入れテストの項目として確認します
  • 実践編 第7回(運用設計): Pod Security Standardsのレベルをwarnからenforceに移行する際の手順と影響確認方法を運用設計書に含めます

7.10.4 次回予告

コンテナのセキュリティが固まりました。次回(第8回)では、自動スケーリングとヘルスチェック設計に取り組みます。HPAでTaskBoardのフロントエンドとAPIの自動スケーリングを実装し、Probeの各パラメータがPodの挙動にどう影響するかを体験的に理解します。Payara Microの起動時間(15〜20秒)がstartupProbe設計の鍵になる場面は、身体で理解する価値のある体験です。

AIコラム — SecurityContextの設定をAIに生成させる

SecurityContextの設定は、コンテナイメージごとに「書き込みが必要なパス」の特定が必要です。この調査はAIに任せるのに向いています。

AIに「nginx:1.27イメージをreadOnlyRootFilesystemで動かしたい。書き込みが必要なパスを洗い出して、SecurityContextとemptyDirの設定を生成してほしい」と依頼すると、必要なマウントポイントの一覧とYAMLのドラフトを短時間で得られます。

ただし、以下の点は自分で検証してください。

  • AIが洗い出した書き込みパスに漏れがないか — 実際にデプロイしてkubectl logsでエラーを確認する
  • runAsUserのUIDはイメージ内で実際に定義されているか — docker run --rm <image> idで確認する
  • 生成されたSecurityContextがPod Security StandardsのRestrictedレベルに準拠しているか — warnモードで検証する

AIのドラフト生成 + 自分の検証という組み合わせが、SecurityContext設定の効率的な進め方です。実践編第3回の詳細設計では、この手法をより本格的に使います。