KubernetesでDockerデーモンなしにDockerイメージをビルドする:セキュアなCI/CDのための実践的Kanikoガイド

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Kubernetesの管理者が夜も眠れないセキュリティの穴

KubernetesでCI/CDパイプラインを初めて構築する際、Dockerイメージをビルドする最も手っ取り早い方法は、ホストノードのDockerソケットをビルドPodにマウントすることです。すぐに動作し、誰もがそうしています。しかし3ヶ月後、セキュリティチームから重大な脆弱性として指摘されることになります。

コンテナに/var/run/docker.sockをマウントすると、そのコンテナはホストノード上で動作するDockerデーモンを完全に制御できるようになります。ビルドPod内の任意のプロセスがイメージをプルしたり、特権コンテナを起動したり、ホストファイルシステムにエスケープしたりでき、事実上ノード全体を乗っ取ることができます。任意のビルドスクリプトを実行するCI/CDシステム——時には完全には信頼できないコントリビューターからのものも——にとって、これは深刻なリスクです。

私自身、前職のKubernetesクラスター向けCI/CDパイプラインを構築する際にこの問題に直面しました。チームは何ヶ月もDockerソケットをマウントし続けており、内部監査で指摘されるまで誰も疑問を持ちませんでした。修正は単純な設定変更では済まず、Kubernetesコンテキストにおけるイメージビルドの仕組みを根本から見直す必要がありました。監査で発覚してからの対処は難しくありませんが、そこまで追い込まれる前に手を打つのが賢明です。

DockerデーモンがKubernetesのセキュリティと相容れない理由

Dockerの従来のアーキテクチャは、rootで実行される中央デーモン(dockerd)に依存しています。docker buildを実行すると、CLIはUnixソケット経由でこのデーモンと通信し、デーモンが実際の処理を行います。この設計はDockerがローカル開発ツールだった頃は理にかなっていましたが、Kubernetesが求めるものと根本的に相反します:

  • デーモンはnamespace、cgroup、ファイルシステムレイヤーを管理するためにroot権限が必要
  • ソケットアクセスを持つPodはKubernetesのRBACを完全にバイパスし、ノードのデーモンに直接通信できる
  • 異なるチームがノードを共有するマルチテナントクラスターでは、適切な分離が不可能になる
  • 「restricted」レベルのPod Security StandardsはソケットファイルのhostPathマウントを明示的にブロックする

根本原因は設定ミスではありません。Dockerデーモンが、Kubernetesの要求するマルチテナント・最小権限の環境向けに設計されていなかったことにあります。

デーモン不要ビルドの3つのアプローチ(そのうち2つが不十分な理由)

Docker-in-Docker(DinD)

Docker-in-Dockerはビルドコンテナ内でフルのDockerデーモンを実行します。ホストソケットのマウントは回避できますが、内部デーモンが機能するには依然として--privilegedモードが必要です。これは排除しようとしていたホストアクセスと同じ権限を付与することになります。

パフォーマンスコストもあります。内部デーモンはジョブ間で永続ストレージを持たないため、毎回コールドスタートになります。overlay2の中でoverlay2を実行するI/Oオーバーヘッドも加わり、ビルド時間が必要以上に2〜3倍かかることがあります。セキュリティを重視する環境では、1つの問題を別の問題に置き換えたに過ぎません。

Buildah

Red HatのBuildahは、デーモン不要で動作する優れたルートレスイメージビルダーです。Podmanベースのワークフローとの統合も優れています。Kubernetesでの課題として、ルートレスBuildahはホスト上でusernamespaceが必要です。ハードニングされたノードAMIを使用するEKSや一部のGKE設定など、マネージドKubernetesサービスではこれがデフォルトで無効になっていることがあります。DockerのワークフローになじんだチームにBuildahへの移行を説得するのも一苦労です。

Kaniko(Kubernetes向けの正解ツール)

GoogleがKubernetes向けに特別に設計したKanikoは、特権を一切必要とせずにコンテナイメージをビルドできます。完全にユーザースペースで動作し、デーモンなしでDockerfileの各コマンドを実行し、結果を直接レジストリにプッシュします。特権モード不要、ソケットマウント不要、ホストのroot権限不要です。Kubernetes CI/CDに限れば、他のツールの追随を許さない唯一の選択肢です。

Kanikoのセットアップ:完全な動作例

前提条件

  • 稼働中のKubernetesクラスター(1.19以上)
  • クラスターアクセスが設定済みのkubectl
  • コンテナレジストリ(Docker Hub、GCR、ECR、またはプライベートレジストリ)
  • アプリケーションのDockerfile

ステップ1:レジストリシークレットの作成

Kanikoはビルドしたイメージをプッシュするために認証情報が必要です。レジストリの認証情報を含むKubernetesシークレットを作成します:

# Docker Hubの場合
kubectl create secret docker-registry regcred \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username=YOUR_USERNAME \
  --docker-password=YOUR_PASSWORD \
  --docker-email=YOUR_EMAIL \
  -n ci-build

# Google Container Registryの場合(サービスアカウントJSONキーを使用)
kubectl create secret generic gcr-credentials \
  --from-file=config.json=/path/to/service-account-key.json \
  -n ci-build

ステップ2:KanikoビルドJobの作成

GitリポジトリからイメージをビルドしてDocker Hubにプッシュする、KubernetesのJobマニフェストです:

apiVersion: batch/v1
kind: Job
metadata:
  name: kaniko-build
  namespace: ci-build
spec:
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: kaniko
          image: gcr.io/kaniko-project/executor:latest
          args:
            - "--dockerfile=Dockerfile"
            - "--context=git://github.com/yourorg/yourrepo.git#refs/heads/main"
            - "--destination=yourdockerhubuser/yourapp:latest"
            - "--cache=true"
            - "--cache-repo=yourdockerhubuser/yourapp-cache"
          volumeMounts:
            - name: docker-config
              mountPath: /kaniko/.docker/
      volumes:
        - name: docker-config
          secret:
            secretName: regcred
            items:
              - key: .dockerconfigjson
                path: config.json

privileged: trueもなく、hostPathマウントもなく、セキュリティコンテキストの例外もありません。このJobはrestricted Pod Security Standardsが適用されたノードでも問題なく動作します。

ステップ3:ローカルワークスペースコンテキストからのビルド

DockerfileとソースコードがPersistentVolumeClaimまたはマウントされたワークスペースボリューム経由で提供される場合は、contextの引数を適宜調整します:

args:
  - "--dockerfile=/workspace/Dockerfile"
  - "--context=dir:///workspace"
  - "--destination=yourdockerhubuser/yourapp:$(BUILD_TAG)"

ステップ4:GitLab CIの統合

KubernetesエグゼキューターによるGitLab CIの場合:

build-image:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor
        --context "${CI_PROJECT_DIR}"
        --dockerfile "${CI_PROJECT_DIR}/Dockerfile"
        --destination "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
        --cache=true
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

debugイメージバリアントにはbusyboxシェルが含まれており、GitLab Runnerがコンテナのエントリーポイントをオーバーライドしてscriptブロックを実行する際に必要です。

ステップ5:GitHub ActionsとKanikoの統合

name: ビルドとプッシュ

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Kanikoでビルド
        uses: aevea/action-kaniko@master
        with:
          image: yourorg/yourapp
          tag: ${{ github.sha }}
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
          cache: true
          cache_registry: yourorg/yourapp-cache

実際によく遭遇する問題

ビルドコンテキストが大きすぎる

Kanikoがnode_modulesやビルド成果物を含むリポジトリ全体を取り込んでしまう場合は、.dockerignoreファイルを追加してください。KanikoはDockerと同様にこのファイルを参照します:

node_modules/
.git/
*.log
dist/
.env

レイヤーキャッシュが効かない

DockerのローカルレイヤーストアとはKanikoのキャッシュはリモートレジストリに保存されます。そのため、--cache-repoでキャッシュリポジトリを指定する必要があります。指定しないと、毎回コールドスタートになります。また、作成したレジストリ認証情報にそのキャッシュリポジトリへのプッシュ権限があることを確認してください。権限がない場合、ビルド中に無言で失敗することがあります。

パーミッションエラーでRUNコマンドが失敗する

Dockerfileにapt-get installなどのroot権限が必要なコマンドが含まれていても、Kanikoでは問題なく動作します。Kanikoはホストのroot権限なしに、独立した名前空間内でrootとしてそれらのコマンドを実行します。それでもエラーが発生する場合は、ベースイメージが実際のカーネルケーパビリティを必要としているか確認してください。NET_ADMIN、SYS_PTRACEなどのフラグはKanikoが提供できる範囲を超えています。

ビルドが本当にルートレスで動いているかを確認する

KanikoのJobをデプロイした後、特権昇格なしで動作していることを確認します:

# 実行中のKaniko PodのsecurityContextを確認する
kubectl get pod kaniko-build-xxxxx -n ci-build \
  -o jsonpath='{.spec.containers[0].securityContext}'

# 空か、allowPrivilegeEscalation: falseが返されるはず
# privileged: trueが表示された場合は問題あり

このルールをnamespaceレベルでPod Securityのstandardラベルとして適用することで、将来のJobが誤って特権モードに戻ることを防げます:

kubectl label namespace ci-build \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted

このラベルを適用すると、Kubernetesはその名前空間内で特権実行やホストソケットのマウントを試みるPodを拒否します。誰かが誤ってDinDに戻した場合も含めて保護されます。コストゼロで設定できるセーフティネットです。

ソケットマウントのDockerビルドからKanikoへの移行は、チームで約1日かかりました。その結果、セキュリティ監査担当者が承認できるパイプライン、共有マルチテナントクラスターと専用ビルド名前空間で同一に動作するビルド、そして次の監査が来ても慌てる必要のない体制が整いました。この移行コストは早めに払う価値があります。

Share: