イメージビルドのベストプラクティス

読む時間の目安: 5 分

セキュリティスキャン

イメージをビルドしたらdocker scanコマンドを実行して、イメージにセキュリティぜい弱性がないかどうかをスキャンする。 これがベストプラクティスです。 Docker は Snyk 社と連携してセキュリティスキャンサービスを提供しています。

メモ

イメージをスキャンするには、Docker Hub にログインしておくことが必要です。 docker scan --loginコマンドの実行後に、docker scan <image-name>を実行して、イメージのスキャンを行います。

たとえば本チュートリアルの初期の頃に生成した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 のいずれからでも確認できます。

Docker Hub のぜい弱性スキャン

イメージのレイヤー管理

イメージがどのように構成されているかを確認できるのはご存知でしたか? 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
    

    各行はイメージ内のレイヤーを表わしています。 この出力結果はベースイメージが最下段に、そして最も新しいレイヤーが最上段に示されます。 このコマンドを用いると、各レイヤーのサイズを簡単に知ることができるので、大容量イメージの調査に役立てることができます。

  2. 上の表示においては何行かが省略(truncate)表示されているのがわかります。 --no-truncフラグをつければ、省略せずに表示することができます。 (--no-truncって英単語が省略されたフラグを使っておきながら、省略されていない結果を得ようなんて、どういうことなんでしょうねぇ。)

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

レイヤーのキャッシュ処理

レイヤー管理の様子をじかに見ましたので、次に重要なレッスンに進みます。 コンテナーイメージのビルド時間を減らす方法についてです。

1 つのレイヤーに変更が入ると、それ以降に続く全レイヤーは再生成されます。

利用してきた Dockerfile をもう一度見てみます...

# syntax=docker/dockerfile:1
FROM node:12-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]

image history コマンドの出力結果に戻ってみると、Dockerfile の各コマンドがイメージ内の 1 つのレイヤーに対応していることがわかります。 覚えてるでしょうか。 イメージに変更を加えたときに yarn による依存パッケージが再インストールされていました。 ではこれを変える方法ってあるんでしょうか。 ビルドのたびに同じ依存パッケージを何度も導入することになるなんて、無駄なことですよね?

これを変更するには Dockerfile の記述を組み立て直して、依存パッケージをキャッシングするようにします。 Node ベースのアプリケーションの場合、そういった依存パッケージはpackage.jsonファイルに定義されます。 そこでこのファイルのコピーを一番最初に行っておいて、その後に依存パッケージのインストールとその他ファイルのコピーを行うようにしたら、どうなるでしょう。 package.jsonに対する変更があったときだけ、yarn による依存パッケージの再生成を行うようにするということです。 どうなるでしょう?

  1. Dockerfile において最初にpackage.jsonをコピーするようにし、その後で依存パッケージのインストールとその他ファイルのコピーを行うように修正します。

     # syntax=docker/dockerfile:1
     FROM node:12-alpine
     WORKDIR /app
     COPY package.json yarn.lock ./
     RUN yarn install --production
     COPY . .
     CMD ["node", "src/index.js"]
    
  2. Dockerfile と同じフォルダー内に.dockerignoreという名前のファイルを生成して、その内容を以下とします。

     node_modules
    

    .dockerignoreファイルを使うと、イメージに関係するファイルのみを選別してコピーするという方法を簡単に実現できます。 この点に関しては こちら に説明しています。 今の場合node_modulesフォルダーは 2 番めのCOPYにおいては処理対象からはずす必要があります。 そうしないと、RUNにおけるコマンド実行によって生成されたファイルを上書きしてしまう可能性があるからです。 Node.js アプリケーションにおいてなぜこういったことが推奨されるか、あるいはその他のベストプラクティスについて、Dockerizing a Node.js web app にガイドが示されているのでご覧ください。

  3. 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 を大きく変更したのですから、そうなるのも当たり前です。

  4. そこでsrc/static/index.htmlファイルに変更を加えます(たとえば<title>を「The Awesome Todo App」にするとか)。

  5. もう一度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
    

    まずなによりもビルドが格段に速くなったことがわかります。 そして Step 1 から 4 はUsing cacheと表記され、キャッシュが利用されていることがわかります。 やりました。 ビルドキャッシュを利用することができました。 つまりこのイメージのプッシュとプル、あるいはイメージ更新がぐっと速くなるということです。 お疲れさま。

マルチステージビルド

本チュートリアルではさほど深く突き詰めていませんが、極めて強力なツールとしてマルチステージビルドがあります。 イメージの生成に複数ステージを利用するというものです。 これにはいくつかの利点があります。

  • ビルド時の依存パッケージと実行時の依存パッケージを分離します。
  • アプリとして実行する必要のあるもの だけ を作り出すことによって、イメージ全体のサイズを削減します。

Maven/Tomcat の例

Java ベースのアプリケーションをビルドするには、ソースコードを Java のバイトコードにコンパイルするために JDK が必要になります。 しかし JDK は本番環境では必要ありません。 またアプリのビルドに Maven や Gradle といったツールを利用するかもしれませんが、こういったものも最終イメージ内には不要のものです。 そこでマルチステージビルドが役立ちます。

# 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と呼ぶ)を使って Maven を利用した Java ビルドを実現します。 2 つめのステージ(FROM tomcatから始まるところ)において、buildステージの生成ファイルをコピーします。 最終イメージは、この 2 つめに作り出されたステージです(これは--targetフラグを使えば上書き可能です)。

React の例

React アプリケーションをビルドするには JS コード(通常 JSX)、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 コンテナーにコピーしています。 すばらしい。

まとめ

イメージがどのように構成されているかについて何となく理解できたところで、イメージビルドをより速く、変更はより少なくすることができました。 イメージをスキャンしておけば、実行および配布するイメージの安全性が確実になります。 マルチステージビルドを使えば、イメージサイズ全体を小さくできることもわかりました。 ビルド時の依存パッケージを実行時の依存パッケージから切り離すことができるので、最終コンテナーの安全性が増すことになります。

get started, setup, orientation, quickstart, intro, concepts, containers, docker desktop