Skip to main content

開発者主導のワークフロー:Dockerfileのイメージスキャン、優先順位付け、修正

Blog Design Developer Driven Workflows updated

2021年3月26日

0 分で読めます

コンテナでアプリケーションをデプロイする際、開発者はOSレベルのセキュリティに関する責任も担うようになっています。こうした分野はなじみがないことも多く、以前は運用チームやセキュリティチームが担当していたケースも少なくありません。この新しい領域に圧倒されそうになるかもしれませんが、本番環境に問題が持ち込まれる前に検出して修正できるよう、ワークフローに取り入れられるツールや手法はさまざまあります。この記事では、さまざまなツールを使ってアプリケーションのコンテナイメージに潜む問題を特定し、優先順位を付けて修正する方法と、修正がデプロイ時にアプリケーションへ与える影響について解説します。

この記事で使用するサンプルアプリケーションは、必要に応じて手順を追いながら試すことができます。GitHubのhttps://github.com/snyk-snippets/dev-driven-workflowsで公開しています。


目次


適切に構成されたコンテナイメージが重要な理由

デプロイするコンテナの基盤は、主にDocker/OCIイメージによって決まります。そのため、コンテナイメージとは何か、どのように機能するのかを理解することが重要です。

コンテナイメージとは?

イメージは基本的に、ファイルシステムのアーカイブとメタデータ定義の集合です。それぞれがファイルシステムの一部と、コンテナの初期状態を定義する環境設定を含みます。各レイヤーは論理的に積み重ねられ、その内容が前のレイヤー(親レイヤーとも呼ばれます)に適用されます。各レイヤーは完全性を保証するために暗号学的ハッシュで識別され、イメージには各レイヤーのマニフェストと、イメージダイジェストと呼ばれるイメージ自体のハッシュが保持されます。

レイヤーおよびベースと表示された複数のレイヤーの上に読み書きレイヤーを重ね、SHA-256識別子を示した図。

コンテナを作成すると、コンテナに見えるファイルシステムは、イメージのレイヤーを統合し、その上に任意の読み書き可能レイヤーを重ねたものになります。イメージのレイヤーは不変であるため、コンテナが書き込むファイルは読み書き可能レイヤーに保存されます。イメージのレイヤーにすでに存在するファイルを変更する場合は、まず読み書き可能レイヤーにコピーし、そこで変更します。これはコピーオンライト動作と呼ばれます。

Dockerfileとは?

Dockerfileは、コンテナイメージの各レイヤーに含める内容を示す、シンプルな命令のリストです。

Dockerfileの例

次の例では、node:14.1.0のベースイメージからレイヤーを構築し、その後の各レイヤーには、各行のDockerfile命令によるメタデータまたはファイルシステムの変更が含まれます。この例は前述のGitHubリポジトリにあり、Dockerfile-initialという名前です。

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
ADD . /usr/src/goof
RUN cd /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT npm start

このDockerfileの例からdocker buildを実行すると、まず/usr/src/goofディレクトリを追加するレイヤーが構築され、続いて新しい/tmp/extracted_filesディレクトリを含むレイヤーが構築されます。

次のレイヤーでは、ビルドマシンの現在のディレクトリ(ビルドコマンドの.で示されます)の内容が、サブディレクトリも含めて、新しいレイヤーの/usr/src/goofディレクトリにコピーされます。

ファイルの最後まで、新しいレイヤーを追加しながらビルドが続きます。

イメージの各レイヤーは親レイヤーを基盤に構築されますが、構築後は不変です。前の手順で追加したファイルを削除する行を加えたとしても、そのファイルの内容は階層内に残り、新しいレイヤーには削除の差分だけが記録されます。これは、ファイルを削除するコミットをしても過去のコミットからは消えないgitなどのソースコードリポジトリと似た動作です。そのため、1MBのファイルをレイヤーに追加し、次のレイヤーで削除しても、コンテナから見えなくなるだけで、イメージには1MB分が残ります。

複合RUN文

一時ファイルがレイヤーのファイルシステムに残らないよう、クリーンアップ処理を最後に含めた複合文がよく使われます。今回のDockerfileにはありませんが、aptやyumなどでOSレベルのパッケージをインストールする際によく使われるパターンの例を紹介します。

RUN apt-get update -y && \
    apt-get install -y --no-install-recommends curl=7.68.0-1ubuntu2.4 && \
    rm -rf /var/lib/apt/lists/*

Ubuntuイメージに特定のバージョンのcurlをインストールし、aptのキャッシュがレイヤーに保存されないようにしています。

今回のDockerfileの例は、コンテナに移行する前にこのアプリケーションのビルドと実行で使われていた命令的な手順を表しています。既存アプリケーションをそのままコンテナに移行した場合に見られるものと大差ありません。最適化と強化の余地が十分にあるので、詳しく見ていきましょう。

最適化が必要なだけでなく、このDockerfileにはdocker buildを失敗させるエラーもあります。問題を確認し、自動化を使って修正する方法を見ていきましょう。

コンテナ化されたアプリケーションを最適化し、セキュリティを強化する10の方法

1. Dockerfileリンターを使う

Dockerfileを改善する際の一般的な最初のステップは、リンターを使うことです。リンターはファイルの内容を静的に解析し、修正を検討すべき問題を提案します。前述のとおり、既存のDockerfileにはビルドを失敗させるバグがあります。

$ docker build -t goof -f Dockerfile-initial .
[+] Building 11.3s (10/10) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile      0.0s
...
 => ERROR [6/6] RUN npm ci --only=production              1.0s
------
 > [6/6] RUN npm ci --only=production:
#10 0.936 npm ERR! code ENOENT
#10 0.937 npm ERR! syscall open
#10 0.937 npm ERR! path /package.json
#10 0.938 npm ERR! errno -2
#10 0.939 npm ERR! enoent ENOENT: no such file or directory, open '/package.json'
#10 0.939 npm ERR! enoent This is related to npm not being able to find a file.
#10 0.939 npm ERR! enoent 
#10 0.948 
#10 0.948 npm ERR! A complete log of this run can be found in:
#10 0.949 npm ERR!     /root/.npm/_logs/2021-03-17T17_19_44_616Z-debug.log
------
executor failed running [/bin/sh -c npm ci --only=production]: exit code: 254

HadolintはDockerfile向けの人気のオープンソースリンターです。手順を読み取り、構成に関する推奨事項を提示します。

$ hadolint Dockerfile-initial 
Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

検出された問題を確認してみましょう。

  1. ファイルやフォルダーにはADDではなくCOPYを使うDockerのDockerfileのベストプラクティスで示されているとおり、ローカルの非アーカイブファイルをコピーする場合は妥当な指摘です。修正する価値はありますが、ビルド失敗の原因ではありません。

  2. cdが失敗した場合に備えて「cd ... || exit」または「cd ... || return」を使う技術的には正しいものの、今回の問題ではありません。ひとまず対応を見送ります。

  3. ディレクトリの切り替えにはWORKDIRを使うこれが原因です。RUN cd /usr/src/goof行の効果は、その命令が構築するレイヤー内にしか及びません。実際、この行はイメージのビルドに関しては何もしないのと同じです。代わりに使うべきなのはDockerfileのWORKDIR命令です。これを使うと、その時点以降の現在の作業ディレクトリが明示的に設定され、実行時にも適用されます。これがないため、RUN npm…行でpackage.jsonファイルが見つからないのです。

  4. CMDおよびENTRYPOINTの引数にはJSON形式を使う「exec」形式とも呼ばれます。この点にはさまざまな意見がありますが、DockerのドキュメントではJSON形式の使用が推奨されています。

これらの問題をすべて修正したところ、サンプルリポジトリ内のDockerfile-hadolint-fixesという新しいDockerfileでは、リンターの問題がなくなり、ビルドも成功するようになりました。

FROM node:14.1.0

RUN mkdir /usr/src/goof
RUN mkdir /tmp/extracted_files
COPY . /usr/src/goof
WORKDIR /usr/src/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

$ hadolint Dockerfile-hadolint-fixes 

$ docker build -t goof -f Dockerfile-hadolint-fixes .
[+] Building 26.5s (11/11) FINISHED                                                                                                                                                
 => [internal] load build definition from Dockerfile-hadolint-fixes                0.0s
...
 => => writing image sha256:c365dec0cfdf64d8cfd43ebd8f2bcd534cfe7b71849796dfb3030b4fe5ff7f93               0.0s 
 => => naming to docker.io/library/goof:hadolint-fixes

2. コミットフックとしてリンターを実行し、Dockerfileの問題がコードベースに入り込むのを防ぐ

hadolintのような軽量ツールは、pre-commitフックに最適です。Dockerfileの問題がコードベースに入り込むのを防げます。以下は、リポジトリの.git/hooks/pre-commitに追加できるシンプルなgitスクリプトの例です。gitフックを初めて使う場合は、Git-SCMのドキュメントを参照してください。

#!/bin/sh
echo "Linting Dockerfile ...\c"
LINT=$(hadolint Dockerfile)
if [[ $? > 0 ]]
then
   echo " FAILED\n"
   echo "$LINT"
   echo "\nLinting failed, commit aborted."
   exit 1
fi
echo " PASSED"
exit 0

修正を適用する前の元のDockerfileに対して、このフックを実行した例を示します。

$ git commit -m 'Test'
Linting Dockerfile ... FAILED

Dockerfile:5 DL3020 error: Use COPY instead of ADD for files and folders
Dockerfile:6 SC2164 warning: Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Dockerfile:6 DL3003 warning: Use WORKDIR to switch to a directory
Dockerfile:11 DL3025 warning: Use arguments JSON notation for CMD and ENTRYPOINT arguments

Linting failed, commit aborted.

注:サンプルリポジトリでテストする場合は、Dockerfile-initialをDockerfileにコピーし、リポジトリでステージングして、フックの問題を発生させてください。

3. イメージをローカルで繰り返しテストする

ビルドエラーに対処し、いくつかの悪い慣行を修正しながら、イメージをビルドできるようにしました。次は、アプリケーションの実行に必要なものがイメージに実際に含まれていることを確認します。

「goof」の例は、MongoDBの永続化バックエンドに依存するNode.jsフロントエンドで構成された、シンプルな2層アプリケーションです。このアプリケーションをローカルで実行するには、次の手順を行います。

  1. docker runでイメージを簡単に確認します。(通常はDockerfileの作業中に繰り返し実施します。)接続先のデータベースがないためアプリケーションは失敗しますが、そこまで進めば少なくともコンテナが起動していることは確認できます。

  2. ビルドしたイメージにアクセスできるローカルKubernetesクラスターを実行します。利用できる選択肢はいくつかあります。代表的なものを紹介します。

    • Docker Desktop KubernetesDocker Desktopのダッシュボードで有効にし、起動するまで待ちます。

    • Kubernetes in Docker (KinD)KinDのクイックスタートで開始し、クラスターにイメージを読み込む手順も確認してください。また、Docker Desktop上でKinDを実行する場合は、goof-serviceのNodePortに合わせてホストマッピングを設定する必要があります。サンプルリポジトリに用意したkind-config.yamlファイルを利用できます。

    • MiniKubeMiniKubeのスタートガイドで開始し、イメージをクラスターにキャッシュする方法についてはハンドブックのページを参照してください。

    • リモートKubernetesクラスター利用可能で、イメージを取得できるクラスターであれば、どれでも問題ありません。クラスターが取得できるレジストリにイメージをプッシュする必要がある場合もあります。方法がわからない場合は、クラスター運用チームに確認してください。

Dockerでテストする

Dockerfileを作成する間、イメージが正しく構築されていることを確認しましょう。最も簡単な方法は、ときどきコンテナを実行して、スポットチェックすることです。

先ほどビルドしたイメージをgoofというタグで実行する場合、docker run --rm -it -p3001:3001 goofを使います。

これにより、そのイメージを基にコンテナが作成・起動され、ワークステーションのポート3001からコンテナ内のポート3001にトラフィックが送られます。--rmは停止後にコンテナを削除するようDockerに指示し、-itはTTYを使った対話モードで実行します。これにより出力を確認し、CTRL-Cで終了できます。

Node.jsアプリケーションの起動中に、データベースに接続できないというエラーを含め、さまざまな出力が表示されるはずです。その後、アプリケーションが終了するとコンテナも停止して削除されます。npmが見つからないなど、それ以外のエラーが表示された場合は、イメージ自体に問題があるとわかります。

ローカルKubernetesでテストする

アプリケーションのデプロイには、サンプルリポジトリのmanifests フォルダーにあるマニフェストファイルを使います。

goof-deployment.yaml

---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: frontend
 template:
   metadata:
     labels:
       app: goof
       tier: frontend
   spec:
     containers:
       - name: goof
         image: goof:latest
         resources:
           requests:
             cpu: 100m
             memory: 100Mi
         ports:
           - containerPort: 3001
           - containerPort: 9229
         env:
           - name: DOCKER
             value: "1"
---
apiVersion: apps/v1
kind: Deployment
metadata:
 name: goof-mongo
spec:
 replicas: 1
 selector:
   matchLabels:
     app: goof
     tier: backend
 template:
   metadata:
     labels:
       app: goof
       tier: backend
   spec:
     securityContext:
       runAsNonRoot: true
       runAsUser: 999
     containers:
       - name: goof-mongo
         image: mongo
         securityContext:
           capabilities:
             drop:
               - all
         ports:
           - containerPort: 27017

Kubernetes APIの詳しい説明はこの記事の範囲外ですが、概要としては、2つのDeploymentを宣言し、コンテナとデータベースを実行するPodをデプロイ、管理します。

さらに、goof-services.yamlファイルもデプロイします。このファイルはKubernetesのServiceを通じてPodを公開し、MongoDBインスタンスのサービスディスカバリーを可能にします。Docker Desktop、KinD、MiniKubeを使ってローカルマシンでKubernetesを実行している場合は、次のようにgoofコンテナの設定にimagePullPolicy: Neverを追加してください。

...
     containers:
       - name: goof
         image: goof:latest
         imagePullPolicy: Never
...

リモートクラスターを使用している場合、この手順は不要ですが、イメージをレジストリにプッシュし、これらのyamlファイル内のimage:タグを適宜変更する必要があります。不明な点があれば、クラスターの管理者に確認してください。

Kubernetesクラスターが稼働しており、kubectlの設定が完了していることを前提に、次はkubectl apply -f manifests/を実行し、kubectl get podsで確認しながら、ポッドがRunning状態になるのを待つだけです。

$ kubectl apply -f manifests/
deployment.apps/goof created                                                  deployment.apps/goof-mongo created
service/goof created
service/goof-mongo created

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
goof-6bf7cfb886-qjgdd         1/1     Running   0          6m7s
goof-mongo-66f98d594c-vsphr   1/1     Running   0          30m

最後に、ブラウザーで次のURLを開いてアプリケーションをテストします。

  • Docker DesktopまたはKinD: http://localhost

  • MiniKubeではminikube service goofを実行し、表示される最初のURLを使用します

  • その他kubectl get service goofの結果に外部IPが表示されている場合は、そのIPを使用します。または、クラスター内のいずれかのノードのIPアドレスにポート32301を付けてアクセスしてみてください。それでも解決しない場合は、クラスターの管理者にお問い合わせください。

ブラウザーに次のような画面が表示されます。

空のテキスト入力欄の上にある「Goof TODO」の見出し

TODOメモを追加して、Enterキーを押して保存してみてください。正常に動作していれば、ページ上のリストに追加されます。

繰り返し実践する:ローカル開発とテストを反復する

アプリケーションが起動したら、開発を続けながらイメージを再ビルドし、クラスターにプッシュできます。構築するアプリケーションの種類や使用するKubernetesディストリビューションによって、このプロセスには何百通りもの方法がありますが、一般的には次のようなワークフローになります。

  1. コードを変更する

  2. docker buildコマンドや使用言語のツールを使って、新しいイメージをビルドする

  3. 必要に応じて、イメージをクラスターまたはレジストリにプッシュする

  4. 実行中のKubernetesポッドを更新し、新しいイメージを使用するようにします。通常は実行中のポッドを削除し、クラスターに新しいイメージから新しいポッドを起動させていますが、新しいイメージに新しいタグ名が付いていれば、kubectl set imageを使って同じことができます。

  5. 新しいポッドが起動して準備完了したらテストする

イメージをさらに強化する

元のDockerfileのバグはいくつか修正しましたが、セキュリティの問題にはまだ対処できていません。アプリケーションを実行してテストする方法が整ったので、このイメージに潜む、より興味深いセキュリティ上の問題を見ていきましょう。

4. イメージでrootユーザーをデフォルトにしない

アプリケーションのコンテナに入って確認すると、nodeプロセスはrootユーザーとして実行されています。

$ kubectl exec -it goof-6bf7cfb886-slkxm -- id
uid=0(root) gid=0(root) groups=0(root)

プロセスは、Linuxのプロセス名前空間によってホストの他の部分から分離されたコンテナの内部で実行されています。それでも、UID 0で実行するのは望ましくありません。その理由はいくつかあります。よくある問題は、意図した以上の情報を公開するホストボリュームをコンテナにマウントしてしまうなど、デプロイ時の設定ミスによって発生します。コンテナ内のユーザーは、そのファイルシステム上で、ホスト上の同じUIDのユーザーと同じ権限を持ちます。たとえば、ホストの/etcディレクトリがコンテナにマウントされている場合、コンテナ内でrootとして実行されているプロセスは、ホストの/etcフォルダー内のすべてのファイルを読み書きできます。

レイヤーは不変で、レイヤー内のファイルを削除しても、親レイヤーからその内容が実際に削除されるわけではないと説明したのを覚えていますか?この性質が悪用される例として、誰かが/var/lib/dockerパスをコンテナにマウントしたために、コンテナ内のプロセスがホストのコンテナイメージのレイヤーファイルを読み取れるケースがあります。すべてのレイヤーがこのパスに保存されているため、悪意のあるプロセスはレイヤー内のソフトウェアをくまなく調べ、悪用できるものを探せてしまいます。(または/var/lib/containerdなど、コンテナランタイムがレイヤーを保存する場所も同様です。)幸い、公式nodeイメージのメンテナーは、UID 1000のnodeユーザーをすでに作成しています。これを使うには、DockerfileにUSER 1000の行を追加するだけです。ユーザー名ではなくUIDを指定するのは、Kubernetesなど一部のツールでは、コンテナの起動前にイメージのデフォルトユーザー名をUIDに対応付けられず、rootユーザーに関するポリシーを適用するためにその対応付けが必要になる場合があるからです。このようなポリシーの適用については、この後で説明します。

USER 1000
ENTRYPOINT ["npm", "start"]

選択したベースイメージにユーザーがあらかじめ作成されていない場合は、RUN行(たとえばadduser)を追加してユーザーを作成し、続けてUSER行を追加して、そのUIDに切り替える必要があります。アプリケーションを新しいユーザーで実行できるよう、必要に応じてイメージ内のファイルの所有者とアクセス権を設定してください。また、実行ファイルなどの所有者をrootにしつつ、ユーザーには読み取りと実行を許可するのが一般的です。これにより、誤動作または悪意のあるプロセスが実行時にファイルを変更するのを防げます。

イメージをビルドしてポッドを更新すると、状態はかなり改善されました。

$ kubectl exec -it goof-6bf7cfb886-qxb6w -- id
uid=1000(node) gid=1000(node) groups=1000(node)

5. デプロイ時にrootユーザーの使用を制御する

イメージが非rootユーザーで実行されるよう変更できたので、Kubernetesのデプロイメントマニフェストも変更し、この設定が維持されるようにしましょう。まず、goof-deployment.yamlファイルをSnyk IaCスキャナーでスキャンします。

$ snyk iac test manifests/goof-deployment.yaml 

Testing manifests/goof-deployment.yaml...

Infrastructure as code issues:
  ✗ Container is running with default set of capabilities [Medium Severity] [SNYK-CC-K8S-6] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > capabilities > drop

  ✗ Container is running without root user control [Medium Severity] [SNYK-CC-K8S-10] in Deployment
    introduced by input > spec > template > spec > containers[goof] > securityContext > runAsNonRoot
...

ご覧のとおり、いくつかの問題があり、中程度の重大度の問題の1つは、rootユーザーの使用が制御されていないことです。これは簡単に修正できます。securityContext内のpod:specまたはcontainer:specにrunAsNonRoot: trueを追加するだけです。ポッドレベルで設定すれば、明示的に上書きされない限り、ポッド内のすべてのコンテナに適用されるため、私はポッドレベルで設定するのが好みです。ポッドに別のコンテナが追加された場合も、この設定は自動的に適用されます。

...
 template:
   spec:
     securityContext:
       runAsNonRoot: true
     containers:
       - name: goof
...

これで、誰かがイメージをrootユーザーで実行する状態に戻そうとしても、テスト時にデプロイが失敗するため、それを防げます。サンプルリポジトリでは、manifests/good-deployment.yaml-nonrootにこの変更を適用しています。

6. 適切な場合は、実行時にユーザーを指定する

注意深い方は、2つ目のデプロイメントであるgoof-mongoにすでにsecurityContextが設定され、UIDも指定されていることに気づいたかもしれません。

...
  securityContext:
    runAsNonRoot: true
    runAsUser: 999
...

ここでrunAsUserフィールドを追加しているのは、Docker Hubの公式mongoベースイメージを変更せずに実行しているためです。つまり、USER行を設定するための別のDockerfileがありません。公式mongoイメージのUIDは変更されないものとしています。一方、独自のgoofアプリケーションイメージでは、DockerfileでUIDを管理できるため、デプロイメントマニフェストで再度指定するメリットはありません。むしろ、そうするとソフトウェアの原則であるDRYに反します。

なお、この記事の執筆時点では、Docker Hubのmongoイメージのドキュメントに、このユーザーやUIDについての記載はありません。確認するために、docker run --rm -it mongo idを実行し、実際にrootとして実行されていることを確認しました。次にdocker run --rm -it --entrypoint cat mongo /etc/passwdを実行すると、ファイルの末尾にmongodb:x:999:999と表示されました。簡単なテストではこのユーザーで動作しましたが、実際の環境でこのような文書化されていない変更を使う前に、さらに調査することをおすすめします。

7. アプリケーションで不要なケイパビリティを削除する

IaCスキャンの結果をもう少し見てみると、中程度の重大度の問題として、コンテナがデフォルトのケイパビリティ一式で実行されていることも報告されています。アプリケーションはシンプルで、ファイルの所有権を変更するようなカーネルレベルの関数を呼び出したり、ホストのネットワークを制御したりする必要はありません。コンテナランタイムにすべてのケイパビリティを削除するよう指示すれば、セキュリティを強化できます。これは、悪意のある攻撃者がコンテナへの侵入に成功した場合に備え、攻撃をさらに困難にするもう1つの方法です。そのためには、コンテナ仕様に「すべて削除」するブロックを追加します。

...
  containers:
    - name: goof
      image: goof:latest
      securityContext:
        capabilities:
          drop:
            - all
...

新しいマニフェストを適用し、アプリケーションを再テストします。サンプルリポジトリでは、manifests/good-deployment.yaml-nonroot-dropcapabilitiesファイルにこの変更を適用しています。

データベースにもこのようなケイパビリティは不要なため、goof-mongoのデプロイメントにも同じ変更がすでに適用されていることがわかります。

KubernetesのsecurityContext設定は複雑な場合があります。詳しくは、チートシート理解しておきたいKubernetesのセキュリティコンテキスト設定10選をご覧ください。

なお、Snyk IaCのスキャンでは、上の図には表示されていない、重大度の低い問題もいくつか見つかりました。これらも対処すべきですが、この記事の範囲外です。

ビルドの問題を修正できたので、変更を1つのコミットにまとめてしまわないよう、ここで変更をコミットしてGitHubにプッシュします。SnykでGitHubリポジトリをすでに監視しているため、自分のブランチからmainへのプルリクエストを開くと、Dockerfileに加えた変更に対してすばやく脆弱性スキャンが実行されます。この場合、変更によって新たなセキュリティ脆弱性やライセンス上の問題は追加されず、テストは合格しています。

すべてのチェックに合格し、マージの競合がなく、緑色の「プルリクエストをマージ」ボタンが表示されたGitHubのプルリクエスト

ここで変更をマージします。次は、イメージを保護するための次のテーマ、脆弱性スキャンについて見ていきましょう。

8. イメージの脆弱性スキャナーを使用する

設定に起因するセキュリティ上の問題に対処できたので、次は自分たちで直接管理していないイメージの部分、つまりベースイメージから引き継ぐパッケージや、追加する可能性のあるパッケージに注目しましょう。イメージ内でどのような悪用が起こり得るかを把握するため、脆弱性スキャナーを実行し、インストール済みパッケージに既知のCVEがないか調べます。

Snyk Containerでスキャンすると、このイメージからさまざまな重大度の問題が825件見つかります。

$ snyk container test goof: --file=Dockerfile
...
Tested 412 dependencies for known issues, found 825 issues.

Base Image   Vulnerabilities  Severity
node:14.1.0  825              189 high, 178 medium, 458 low

Recommendations for base image upgrade:

Minor upgrades
Base Image  Vulnerabilities  Severity
node:14.15  571              63 high, 64 medium, 444 low

Major upgrades
Base Image  Vulnerabilities  Severity
node:15     562              58 high, 61 medium, 443 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:current-buster-slim   58               10 high, 6 medium, 42 low
node:fermium-stretch-slim  75               18 high, 9 medium, 48 low
node:15.10-slim            75               18 high, 9 medium, 48 low
node:14.16.0-buster        325              35 high, 49 medium, 241 low

レポートによると、問題はすべて、ベースとして使用しているnode:14.1.0イメージに由来しています。Dockerfileではapt-getなどを使って追加パッケージをインストールしていないため、これは納得のいく結果です。

これほど多くの問題がある場合は、対応するSnykのWebコンソールで確認すると、優先順位を付けやすくなります。リポジトリはすでに監視されているので、先ほどプルリクエストをマージした際に実行されたスキャンを見てみましょう。

HTTPリクエストスマグリング、ホスト名検証の不備、メモリ破損に関するNode.jsの高深刻度の脆弱性とスコアを示す一覧。

ここでは、CVSSスコア、悪用の成熟度、修正プログラムの提供状況などの指標をもとに重み付けされた、脆弱性の優先順位付きテーブルを確認できます。また、CLIとWebのレポートには、既知の脆弱性がより少ない代替ベースイメージの推奨も表示されます。

Dockerのベースイメージのアップグレード、脆弱性の件数、深刻度、[修正PRを作成]オプションを比較した表。

このケースでは、ベースイメージをnode:fermium-buster-slimにアップグレードするのが、互換性を保てる最適な選択肢に見えます。「slim」タグ付きのイメージは構成が簡素化されているため、アプリケーションが含まれていないパッケージを使用している場合は、互換性を必ずテストしてください。

コードを変更して対応することもできますが、Web UIには各推奨項目の横にある「修正用PRを作成」ボタンから自動修正する機能もあります。今回はそれを使いましょう。

ericsmalling/goof(main)のDockerfile向け「Fix PR」ページ。アップグレードやパッチ適用のプルリクエストを作成するオプションがあります。

変更内容を詳しく説明する確認メッセージのページが表示されます。その後、GitHubのプルリクエストページに直接移動します。

SnykからのGitHubプルリクエスト。Dockerベースイメージのセキュリティアップグレード、脆弱性の一覧、チェック合格、マージのオプションが表示されています。

実際の運用では、このプルリクエストにも、他の変更と同様にコードレビューとアプリケーションテストのプロセスが適用されます。今回は、この変更をマージしましょう。

変更をローカルのgitリポジトリに取り込み、イメージを再ビルドしてスキャンすると、脆弱性の件数は58件に減りました。さらに、小さな「slim」ベースイメージに切り替えたことで、全体のサイズも1.03GBからわずか261MBに縮小しました。

REPOSITORY   TAG                   IMAGE ID       CREATED             SIZE
goof         old                   f2f1ca19cdc9   48 minutes ago      1.03GB
goof         slim                  865cee656a5f   8 seconds ago       261MB
node         fermium-buster-slim   d1bc1f4d4c5d   4 days ago          180MB
node         14.1.0                a511eb5c14ec   10 months ago       941MB

9. イメージをさらに大幅にスリム化する

コンテナの基本的な考え方は、プロセスが実行される環境を最小限に抑えることです。イメージの軽量化とセキュリティ強化はかなり進みましたが、本番環境へのデプロイにはまだ必要以上のものが含まれています。イメージを細かく調べ、Debianのベースディストリビューションに含まれる不要なファイルやパッケージをすべて取り除く方法もありますが、もっと簡単な選択肢もあります。ただし、それぞれにトレードオフがあります。

Alpineベースのイメージ

最も簡単な解決策のひとつは、セキュリティと軽量性を重視した別のベースOS、Alpineを使うことです。Alpineベースのイメージは、インストール済みのパッケージが非常に少ないため、サイズが小さいことで知られています。存在しないファイルは悪用できないため、セキュリティの観点からも好ましい選択です。Dockerfileを再構成し、ベースイメージにnode:14-alpineを使ってみましょう。

FROM node:14-alpine

USER node
RUN mkdir /home/node/goof
RUN mkdir /tmp/extracted_files
COPY . /home/node/goof
WORKDIR /home/node/goof

RUN npm ci --only=production
EXPOSE 3001
EXPOSE 9229
ENTRYPOINT ["npm", "start"]

ご覧のとおり、いくつかの項目の順序を入れ替え、Alpineベースイメージの構成に合わせてアプリを別のディレクトリに移しました。予想どおり、このバージョンのイメージをビルドすると、サイズが大幅に小さくなりました。

$ docker images
REPOSITORY   TAG                   IMAGE ID       CREATED          SIZE
goof         alpine                3aa775448685   11 minutes ago   197MB
goof         slim                  8b4795653de3   3 hours ago      261MB

新しいイメージをSnyk Containerでスキャンすると、どのような結果になるか見てみましょう。

$ snyk container test goof:alpine --file=Dockerfile

Testing goof:alpine...

Organization:      eric-smalling-snyk
Package manager:   apk
Target file:       Dockerfile
Project name:      docker-image|goof
Docker image:      goof:alpine
Platform:          linux/amd64
Base image:        node:14-alpine
Licenses:          enabled

✓ Tested 16 dependencies for known issues, no vulnerable paths found.

According to our scan, you are currently using the most secure version of the selected base image

脆弱なパスは見つかりませんでした!これ以上ない結果ですね!

では、なぜ常にAlpineベースのイメージを使わないのでしょうか。Nodeのイメージのドキュメントから引用します。

主な注意点は、musl libcを使用しており、glibcなどは使用していないことです。そのため、ソフトウェアがlibcに求める要件や前提の複雑さによっては、問題が生じることがあります。起こりうる問題やAlpineベースのイメージを使う場合のメリット・デメリットについて詳しくは、こちらのHacker Newsのコメントスレッドをご覧ください。

多くの場合、これはまったく問題ありません。特にNodeのように、Alpine向けにクロスコンパイルされるプラットフォームでは問題になりにくいでしょう。ただし、低レベルのCライブラリ一式をまったく別のものに置き換えることで、アプリケーションが動かなくなる場合もあります。そのため、glibcベースのイメージをスキャンした脆弱性スキャナーは、脆弱性の数を減らす選択肢としてAlpineを推奨しないことがあります。Alpineへの移行に失敗しやすい例として、JavaアプリケーションのJNI呼び出しや、cgoを使うGoアプリケーションなど、コンパイル済みライブラリに依存するレガシーアプリケーションが挙げられます。いずれにしても、別のLinuxベースのイメージからAlpineに切り替える場合は、アプリケーションの機能テストとパフォーマンステストを十分に実施してください。ある意味で、OSを変更することになるためです。

Distrolessイメージ

Alpineが適さない場合は、Googleが公開しているDistrolessプロジェクトがあります。ここでは通常Debianをベースに、必要最小限まで軽量化したイメージを提供しています。シェルさえ含まれていないこともあります。これらのイメージはGoogleのDistrolessチームがメンテナンスしており、詳しくはGitHubリポジトリをご覧ください。

Scratchベースのイメージを自作する

scratchベースイメージは、実際にはイメージではありません。Dockerfileの仕様で定められた特別なトークンで、完全に空のファイルシステムからイメージのビルドを開始します。シェルも、パッケージマネージャーも、何もありません。この種のイメージに追加したいものは、すべて自分でコピーする必要があります。通常、scratchはマルチステージビルドを使うDockerfileの最終ステージとして使われます。マルチステージビルドでは、複数のFROM行を指定でき、最後の行を最終イメージのベースにします。よく使われる方法は、「build」ステージでコンパイルし、生成された成果物を最終ステージにコピーすることです。最終ステージにscratchを使う場合、実行ファイルの動作に必要なものをすべてコピーする必要があります。そのため、Node.js、Java、Pythonのようなインタープリター型言語やランタイム環境が必要な言語で使われることはあまりありません。この方法に適しているのは、単一または少数のファイルにコンパイルできる言語です。C、C++、Goのアプリケーションでは、scratchイメージがよく使われます。

10. Dockerfileを使わないイメージビルダーを調べる

この記事では、最も広く使われているイメージビルドツールであるDockerfileベースのビルドプロセスを取り上げてきました。しかし、Dockerfileを使わずにイメージをビルドする方法もあります。よく使われるものとして、Bazel、jib、スクリプトを使ったBuildahなどがあります。Docker自体も、Dockerクライアントに同梱されるオプションのビルダー、BuildKitプロジェクトを通じて、ほかのビルドスクリプト手法をサポートしています。

まとめと参考情報

今回の例は、JavaScriptがインタープリター型言語であるため、比較的シンプルです。一方、コンパイルと実行の段階が明確に分かれている言語を使っている場合は、前述のマルチステージDockerfileをぜひご覧ください。Dockerfile内でビルドを続けながら、デプロイする最終イメージからコンパイラーとソースコードを除外できます。

コンテナでNode.jsを実行する方法について詳しくは、DockerでNode.jsのWebアプリケーションをコンテナ化するための10のベストプラクティスをご覧ください。Javaアプリケーション向けには、DockerでJavaコンテナを構築するための10のベストプラクティスもご用意しています。

開発者ファーストのコンテナセキュリティ

Snykは、コンテナイメージとKubernetesワークロードの脆弱性を検出し、自動的に修正します。

RedHatの代替コンテナツールを使っている方は、コンテナ向けコマンドラインツール — Buildah、Podman、SkopeoでSnykを使うに関するブログ記事をご覧ください。

前述のとおり、KubernetesのセキュリティコンテキストAPIは設定が難しい場合があります。理解しておきたいKubernetesのセキュリティコンテキスト設定10選では、各設定について解説し、アプリケーションに適した安全な選択をサポートします。これらの設定で使える強力な選択肢のひとつが、コンテナを読み取り専用モードで実行することです。コンテナのファイルシステムが悪意を持って変更されるのを防ぐうえで、非常に有効です。詳しくは、このチートシートをご覧ください。

Dockerイメージについてさらに詳しく知りたい方は、Adam Gordon Bellによる、イメージの構成を掘り下げたすばらしいブログ記事をご覧ください。また、第2回のDocker Community All-Handsウェビナーでは、数名のDocker CaptainがDockerイメージについてすばらしい講演を行いました。

このガイドが、コンテナイメージの仕組みや、ツールを活用してコンテナ化されたアプリケーションを安全に保ち、メンテナンスする方法を理解する一助となれば幸いです。