イメージ構築のベストプラクティス

安全性の検査(security scanning)

イメージの構築時、イメージのセキュリティ脆弱性を 検査(scan) するために docker scan コマンドを使うベストプラクティスがあります。Docker は脆弱性検査サービスを提供する Snyk と提携しています。

注釈

イメージの検査には Docker Hubへのログインが必要です。 docker scan --login コマンドを実行してから、 docker scan <イメージ名> を使ってイメージを検査します。

たとえば、これまでのチュートリアルの始めに作成した getting-started イメージを検査するには、次のコマンドを実行するだけです。

$ docker scan getting-started

検査には定期的に更新される脆弱性データベースを使いますので、様々な新しい脆弱性が発見されたと表示されるでしょう。表示されるのは、以下のようなものです。

✗ Low severity vulnerability found in freetype/freetype
  Description: CVE-2020-15999
  Info: https://snyk.io/vuln/SNYK-ALPINE310-FREETYPE-1019641
  Introduced through: freetype/freetype@2.10.0-r0, gd/libgd@2.2.5-r2
  From: freetype/freetype@2.10.0-r0
  From: gd/libgd@2.2.5-r2 > freetype/freetype@2.10.0-r0
  Fixed in: 2.10.0-r1

✗ Medium severity vulnerability found in libxml2/libxml2
  Description: Out-of-bounds Read
  Info: https://snyk.io/vuln/SNYK-ALPINE310-LIBXML2-674791
  Introduced through: libxml2/libxml2@2.9.9-r3, libxslt/libxslt@1.1.33-r3, nginx-module-xslt/nginx-module-xslt@1.17.9-r1
  From: libxml2/libxml2@2.9.9-r3
  From: libxslt/libxslt@1.1.33-r3 > libxml2/libxml2@2.9.9-r3
  From: nginx-module-xslt/nginx-module-xslt@1.17.9-r1 > libxml2/libxml2@2.9.9-r3
  Fixed in: 2.9.9-r4

出力の一覧には、脆弱性のタイプ、URL には詳細、そして重要な、脆弱性を修正するのに妥当なライブラリのバージョンがあります。

他にもいくつかのオプションがあり、 docker scan のドキュメント から確認できます。

コマンドライン上で新しく構築するイメージを検査するのと同じように、 Docker Hub の設定 でも、直近に送信したイメージすべてを自動的に検索できます。そして、その結果は Docker Hub と Docker Desktop の両方で確認できます。

Dockre Hub 脆弱性検査

イメージの 階層化(layering)

どのようにしてイメージが構成されたのか、調べる方法があるのを知っていますか。 docker image history コマンドを使うと、イメージ内の各レイヤーが作成時に使われたコマンドを表示できます。

  1. docker image history コマンドを使い、チュートリアルのはじめの方で作成した getting-started イメージ内のレイヤーを見ます。

    $ docker image history getting-started
    

    すると、次のような出力が見えるでしょう(日付や ID は異なるでしょう)。

    IMAGE               CREATED             CREATED BY                                      SIZE                COMMENT
    a78a40cbf866        18 seconds ago      /bin/sh -c #(nop)  CMD ["node" "src/index.j…    0B
    f1d1808565d6        19 seconds ago      /bin/sh -c yarn install --production            85.4MB
    a2c054d14948        36 seconds ago      /bin/sh -c #(nop) COPY dir:5dc710ad87c789593…   198kB
    9577ae713121        37 seconds ago      /bin/sh -c #(nop) WORKDIR /app                  0B
    b95baba1cfdb        13 days ago         /bin/sh -c #(nop)  CMD ["node"]                 0B
    <missing>           13 days ago         /bin/sh -c #(nop)  ENTRYPOINT ["docker-entry…   0B
    <missing>           13 days ago         /bin/sh -c #(nop) COPY file:238737301d473041…   116B
    <missing>           13 days ago         /bin/sh -c apk add --no-cache --virtual .bui…   5.35MB
    <missing>           13 days ago         /bin/sh -c #(nop)  ENV YARN_VERSION=1.21.1      0B
    <missing>           13 days ago         /bin/sh -c addgroup -g 1000 node     && addu…   74.3MB
    <missing>           13 days ago         /bin/sh -c #(nop)  ENV NODE_VERSION=12.14.1     0B
    <missing>           13 days ago         /bin/sh -c #(nop)  CMD ["/bin/sh"]              0B
    <missing>           13 days ago         /bin/sh -c #(nop) ADD file:e69d441d729412d24…   5.59MB
    

    それぞれの行がイメージ内のレイヤーに相当します。この表示が示すのは、一番下が 土台(base) となり、最新のレイヤーが一番上にあります。これを使えば、各レイヤーの容量も素早く見られるため、大きなイメージの特定に役立ちます。

  1. いくつかの行が 省略されている(trancated) のに気が付くでしょう。 --no-trunc フラグを使えば、全てを表示できます(それにしても……省略を意味する "trancated" フラグを使って、省略されていない出力をするのは、面白いですね?)。

    $ docker image history --no-trunc getting-started
    

レイヤーのキャッシュ

これまでレイヤーがどのようになっているかを見てきました。次は、コンテナ イメージの構築回数を減らすために役立つ、重要な知見を学びます。

注釈

あるレイヤーを変更すると、 以降に続く(downstream) 全てのレイヤーも同様に再作成されます。

それでは、使用していた Dockerfile をもう一度見てみましょう……。

# syntax=docker/dockerfile:1
FROM node:12-alpine
RUN apk add --no-cache python2 g++ make
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]

イメージ履歴の出力にさかのぼると、 Dockerfile の各命令が、イメージ内の新しいレイヤーになりました。イメージに変更を加えたとき、yarn の依存関係も再インストールされたのを覚えていますでしょうか。これを修正する方法はないでしょうか。使おうとする度に、毎回同じ依存関係を構築するのはイマイチではないでしょうか?

これに対応するには、依存関係のキャッシュをサポートするのに役立つように、 Dockerfile を再構成する必要があります。Node をベースとするアプリケーションでは、各依存関係は package.json ファイルで定義されています。そのため、何よりもまず第一にこのファイルをコピーし、依存関係をインストールし、「それから」他の全てをコピーします。そうすると、 package.json を変更した時だけ、 yarn の依存関係を再作成します。わかりましたか?

  1. package.json ファイルを第一にコピーし、依存関係をインストールし、以降で他に必要な全てのものをコピーするよう、 Docker ファイルを更新します。

# syntax=docker/dockerfile:1
FROM node:12-alpine
RUN apk add --no-cache python2 g++ make
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --production
COPY . .
CMD ["node", "src/index.js"]
  1. Dockerfile と同じディレクトリ内に .dockerignore という名前でファイルを作成し、内容を以下のようにします。

    node_modules
    

    イメージに関係あるファイルだけ選んでコピーするには、 .dockerignore ファイルの利用が簡単です。 こちら で詳しく読めます。今回の場合、2つめの COPY ステップで node_modulers フォルダは無視されます。これは、そうしなければ、 RUN ステップ中の命令で作成されるファイルにより、上書きされる可能性があるためです。どうして Node.js アプリケーションにこのような推奨をするのかや、他のベストプラクティスといった詳細は、Node.js のガイド Dockerizing a Node.js web app をご覧ください。

  1. docker build を使って新しいイメージを構築します。

    $ docker build -t getting-started .
    

    次のような出力が見えるでしょう……

    Sending build context to Docker daemon  219.1kB
    Step 1/6 : FROM node:12-alpine
    ---> b0dc3a5e5e9e
    Step 2/6 : WORKDIR /app
    ---> Using cache
    ---> 9577ae713121
    Step 3/6 : COPY package.json yarn.lock ./
    ---> bd5306f49fc8
    Step 4/6 : RUN yarn install --production
    ---> Running in d53a06c9e4c2
    yarn install v1.17.3
    [1/4] Resolving packages...
    [2/4] Fetching packages...
    info fsevents@1.2.9: The platform "linux" is incompatible with this module.
    info "fsevents@1.2.9" is an optional dependency and failed compatibility check. Excluding it from installation.
    [3/4] Linking dependencies...
    [4/4] Building fresh packages...
    Done in 10.89s.
    Removing intermediate container d53a06c9e4c2
    ---> 4e68fbc2d704
    Step 5/6 : COPY . .
    ---> a239a11f68d8
    Step 6/6 : CMD ["node", "src/index.js"]
    ---> Running in 49999f68df8f
    Removing intermediate container 49999f68df8f
    ---> e709c03bc597
    Successfully built e709c03bc597
    Successfully tagged getting-started:latest
    

    すべてのレイヤーが再構築されるのが見えるでしょう。Dockerfile に少し手を加えただけで、全て完全に作り直されました。

  1. 次は src/static/index.html に変更を加えます( <title> を「The Awesome Todo App」のように変えます )。

  1. docker build -t getting-started . を使って Docker イメージを再構築します。今回は、先ほどとは出力が変わります。

    Sending build context to Docker daemon  219.1kB
    Step 1/6 : FROM node:12-alpine
    ---> b0dc3a5e5e9e
    Step 2/6 : WORKDIR /app
    ---> Using cache
    ---> 9577ae713121
    Step 3/6 : COPY package.json yarn.lock ./
    ---> Using cache
    ---> bd5306f49fc8
    Step 4/6 : RUN yarn install --production
    ---> Using cache
    ---> 4e68fbc2d704
    Step 5/6 : COPY . .
    ---> cccde25a3d9a
    Step 6/6 : CMD ["node", "src/index.js"]
    ---> Running in 2be75662c150
    Removing intermediate container 2be75662c150
    ---> 458e5c6f080c
    Successfully built 458e5c6f080c
    Successfully tagged getting-started:latest
    

    まず、かなり構築が早くなったのが分かるでしょう! そして、ステップ1~4がすべて Using cache (キャッシュを使用中)になっています。やりました! 構築キャッシュを使ったのです。このイメージを更新するための送信や取得が、より早くなりました! やったね!

マルチステージ ビルド(multi-stage build)

このチュートリアル内ではあまり深く扱いませんが、イメージ作成時に複数の 段階(stage) を使える大変強力なツールが マルチステージ ビルド(multi-stage build) です。いくつかの利点があります。

  • 構築時の依存関係と、実行時の依存関係を分離できる

  • アプリケーションが実行に必要なもの「だけ」送るので、イメージ全体の容量を削減できる

Maven/Tomcat 例

Java をベースとしたアプリケーションの構築時、ソースコードを Java バイトコードにコンパイルするため JDK が必要です。ですが、JDK は本番環境では不要です。また、 Maven や Grandle のようなツールをアプリの構築に使うかもしれません。ですが、これらは最終イメージでは不要です。マルチステージ ビルドは、このような場面で役立ちます。

# syntax=docker/dockerfile:1
FROM maven AS build
WORKDIR /app
COPY . .
RUN mvn package

FROM tomcat
COPY --from=build /app/target/file.war /usr/local/tomcat/webapps

この例では、1つめのステージ( build と呼びます)で、実際に Java の構築を Maven を使って処理します。2つめのステージ( FROM tomcat で始まります)に、 build ステージからファイルをコピーします。最終イメージには、最後のステージに作成されたものだけです( --target フラグを使い、上書きできます)。

React 例

React アプリケーションの構築時、 JS コード(通常は JSC)、SASS スタイルシート、その他 HTML、JS、CSS を Node 環境にコンパイルする必要があります。サーバ側でのレンダリングをしないのであれば、本番環境の構築で Node 環境は不要です。どうして静的なリソースを静的な nginx コンテナに入れないのでしょうか。

# syntax=docker/dockerfile:1
FROM node:12 AS build
WORKDIR /app
COPY package* yarn.lock ./
RUN yarn install
COPY public ./public
COPY src ./src
RUN yarn run build

FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html

ここでは、 node:12 イメージを使って構築(レイヤーのキャッシュを最大限活用)を処理し、それから出力を nginx コンテナにコピーします。すごいでしょ?

まとめ

イメージがどのようにして構築されているかを少々学びましたので、ちょっとした変更でも、イメージを早く構築し、送り出せるようになります。イメージの検査によって、コンテナの実行や配布が安全だという信頼性をもたらします。また、マルチステージ ビルドによって、構築時の依存関係と実行時の依存関係を分けられるため、イメージ全体の容量を減らしたり、最終コンテナの安全を高められます。

参考

Image-building best practices

https://docs.docker.com/get-started/09_image_best/