Skip to main content

Docker を使用して Node.js Web アプリケーションをコンテナ化するためのベストプラクティス 10 項目

wordpress-sync/feature-node.js-cheat-sheet

2022年9月15日

0 分で読めます

編集者注:

2022 年 9 月 14 日: Docker で Node.js ウェブアプリケーションをコンテナ化するための、新たに改善されたチートシートをご覧いただけます。

ウェブアプリケーションで Node.js の Docker イメージをビルドする方法のベストプラクティスをお探しの場合は、この記事が参考になります。

この記事では、最適化された安全な Node.js Docker イメージをビルドする本番稼働グレードのガイドラインを提供しています。ビルドする Node.js アプリケーションに関わらず、きっとお役に立つはずです。この記事は、以下のような場合に役立ちます。

  • React のサーバーサイドレンダリング (SSR) Node.js の機能を使用して、フロントエンドアプリケーションをビルドする場合

  • FFastify、NestJS、またはその他のアプリケーションフレームワークを実行して、マイクロサービス用の Node.js Docker イメージを適切にビルドする方法についてアドバイスを求めている場合

Node.JS Docker ウェブアプリケーションのコンテナ化についてのガイドを作成した理由

Node.js アプリケーションのための Docker イメージをビルドする方法についてよくある記事のように思われるかもしれませんが、ブログで見られる多くの例は非常に単純化されており、Node.js Docker イメージをビルドするためのセキュリティやベストプラクティスは深く考察ぜず、単にアプリケーションを動かす基本の説明のみが目的になっています。

ここでは、Node.js ウェブアプリケーションをコンテナ化する方法を、シンプルでも動作する Dockerfile から始めて、Dockerfile のディレクティブごとに落とし穴や不安な要素について理解し、修正しながら、段階的に学んでいきます。チートシートはこちらからダウンロードできます

wordpress-sync/NodeJS-cheat-sheet

シンプルな Node.js の Docker イメージ

私たちが目を通したほとんどのブログ記事では、Node.js Docker イメージをビルドする以下のような基本的な Dockerfile 命令で始まり、終わっています。

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

これを Dockerfile というファイルにコピーし、ビルドして実行します。

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

これはシンプルで動きはしますが、

何が問題なのでしょうか?Node.js の Docker イメージのビルドに誤りや悪いプラクティスがたくさん含まれています。上記の方法は絶対に避けてください。

この Dockerfile を改善して、Node.js ウェブアプリケーションの Docker のビルドを最適化しましょう。

このチュートリアルに沿って、このリポジトリを複製できます。

次の 10 の手順に従い、Node.js ウェブアプリケーションの Docker のビルドを最適化しましょう。

  1. 明示的かつ決定的な Docker ベースイメージタグを使用する

  2. Node.js の Docker イメージに本番環境の依存関係のみをインストールする

  3. Node.js ツールを本番環境向けに最適化する

  4. コンテナを root で実行しない

  5. Node.js Docker ウェブアプリケーションを安全に終了する

  6. Node.js ウェブアプリケーションを正常にシャットダウンする

  7. Node.js の Docker イメージのセキュリティ脆弱性を検出して修正する

  8. マルチステージビルドを使用する

  9. Node.js Docker イメージから不要なファイルを除外する

  10. Docker のビルドイメージにシークレットをマウントする

1.明示的かつ決定的な Docker ベースイメージタグを使用する

node Docker イメージをベースにしてイメージをビルドするのは当然のように思えますが、実際にイメージをビルドする際に何を取り込んでいるのでしょうか。Docker イメージは常にタグで参照され、タグを指定しない場合は、デフォルトの :latest タグが使用されます。

実際、Dockerfile に以下のように指定することで、常に Node.js Docker ワーキンググループでビルドされた最新版の Docker イメージをビルドできます。

FROM node

デフォルトの node イメージをもとにビルドする方法には、以下の欠点があります。

  1. Docker イメージのビルドに一貫性がありません。npm パッケージをインストールする際、lockfiles を使って npm install の動作を決定的にするのと同様に、Docker イメージのビルドも決定的にしたいと考えています。node からイメージをビルドする場合、実質的には node:latest タグからビルドする場合、ビルドのたびに node の新しくビルドされた Docker イメージが使用されることになります。このような非決定的な動作は好ましくありません。

  2. node Docker イメージは、本格的なオペレーティングシステムをベースにしているため、Node.js ウェブアプリケーションを実行するために必要なライブラリやツールがたくさん含まれています。これには 2 つのデメリットがあります。第一に、イメージが大きいとダウンロードサイズも大きくなり、ストレージの容量が増えるだけでなく、ダウンロードとイメージのリビルドに時間がかかります。第二に、ライブラリやツールにセキュリティの脆弱性が存在していた場合、イメージに入り込む可能性があるということです。

実際、node Docker イメージはかなり大きく、種類や重大度の異なる数百ものセキュリティ脆弱性が含まれています。このイメージを使用する場合、デフォルトでは 642 件あるセキュリティ脆弱性のベースラインと、プルやビルドのたびにダウンロードされる数百メガバイトのイメージデータが出発点になります。

wordpress-sync/blog-container-image-vulnerabilities

優れた Docker イメージのビルドのための推奨事項は以下のとおりです。

  1. Docker イメージを小さくします。これにより、Docker イメージのソフトウェアのフットプリントが小さくなり、潜在的な脆弱性のベクトルが減少し、サイズが小さいことによりイメージのビルドプロセスが高速化されることになります。

  2. Docker イメージの静的な SHA256 ハッシュとなる Docker イメージダイジェストを使用します。これにより、ベースイメージから決定的な Docker イメージのビルドが得られます。

最適な Node.jsDocker のイメージの選び方についてまとめた記事があります。この記事では、Node.js ランタイムバージョンを長期的にサポートしている最新の Debian's slim ディストリビューションが理想的な選択となる理由について詳しく説明しています。

推奨される Node.js Docker イメージは次のとおりです。

FROM node:20.9.0-bullseye-slim

この Node.js Docker イメージタグは、現在最新の長期サポートに対応する特定バージョンの Node.js ランタイム (「`16.17.0`」) を使用しています。これは、Debian 11 の現在の安定版であり、サポート終了日がかなり先の「`bullseye`」イメージバリアントを使用しています。そして最後に、「`slim`」イメージバリアントを使用してオペレーティングシステムのソフトウェアフットプリントを小さくし、Node.js ランタイムとツールを含めてイメージサイズを 200MB 未満にします。

とはいえ、一般的な無知なプラクティスの 1 つは、ベースイメージに対する次の Docker 命令を引用するチュートリアルやガイドです。

FROM node:alpine

これらの記事では、Node.js の Alpine Docker イメージを使用するようになっていますが、これは本当に理想的といえるのでしょうか。Node.js Alpine Docker イメージのソフトウェアフットプリントが小さいことが使用されている主な理由ですが、他の特性が大幅に異なり、Node.js アプリケーションランタイムの本番環境用のベースイメージとしては最適とはいえません。

Node Alpine とは

Node.js Alpine は、Node.js Docker チームが管理する非公式な Docker コンテナイメージのビルドです。Node.js イメージには、最小限の busybox ソフトウェアツールと muslC ライブラリ実装を搭載した Alpine オペレーティングシステムがバンドルされています。Node.js Alpine イメージのこの 2 つの特徴により、Docker イメージが Node.js チームによって非公式ながらサポートされています。加えて、多くのセキュリティ脆弱性スキャナーでは、Node.js Alpine イメージ上のソフトウェアアーティファクトやランタイムを容易に検出できないため、コンテナイメージを保護する取り組みにおいては逆効果となります。

Node.js Alpine イメージタグの使用に関わらず、ワードエイリアスの形でベースイメージディレクティブを使うことで Docker イメージタグを変更でき、そのタグの新しいビルドをプルできます。この Node.js タグの Docker HubSHA256 ハッシュがあります。または、ローカルにこのイメージをプルしてから以下のコマンドを実行し、出力の Digest フィールドを調べることができます。

$ docker pull node:20.9.0-bullseye-slim
20.9.0-bullseye-slim: Pulling from library/node
ca426296fe92: Pull complete
0d5f60f923bb: Pull complete
cc6fa81c4559: Pull complete
ec5e8e3b63b3: Pull complete
ca7cb04b0758: Pull complete
Digest: sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
Status: Downloaded newer image for node:20.9.0-bullseye-slim
docker.io/library/node:20.9.0-bullseye-slim

SHA256 ハッシュを調べる別の方法として、以下のコマンドを実行できます。

$ docker images --digests
REPOSITORY                                   TAG                     DIGEST                                                                    IMAGE ID       CREATED         SIZE
node                                         20.9.0-bullseye-slim    sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8   9ea15fe618bd   7 days ago      200MB

ここで、この Node.js Docker イメージの Dockerfile を次のように更新できます。

FROM node@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

ただし、上記の Dockerfile では、イメージタグを使用せずに Node.js Docker イメージ名のみを指定しています。これにより、どのイメージタグが使用されているのかがあいまいになり、読みづらく、管理が大変で、デベロッパーエクスペリエンスもよくありません。

これを修正して、SHA256 ハッシュに対応する Node.js のバージョンの完全なベースイメージタグを指定するように Dockerfile を更新しましょう。

FROM node:20.9.0-bullseye-slim@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Docker イメージダイジェストを使用すると、決定的なイメージが保証されますが、解釈方法がわからないイメージスキャンツールにとっては、混乱の原因になったり、逆効果になったりするかもしれません。そのため、`16.17.0` のような明示的な Node.js ランタイムバージョンを使用することをお勧めします。理論的には変更可能で上書き可能ですが、実際にはセキュリティやその他のアップデートを受け取る必要がある場合、`16.17.1` のような新しいバージョンにプッシュされるため、決定的ビルドを想定しても安全です。

そのため、この段階でお勧めする Dockerfile は以下のようになります。

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

詳細については、安全なコンテナイメージをビルドするためのヒントとベストプラクティスをご覧ください。

2.Node.js の Docker イメージに本番環境の依存関係のみをインストールする

以下の Dockerfile ディレクティブにより、アプリケーションの動作には不要な devDependencies を含め、すべての依存関係がコンテナにインストールされます。これにより開発依存パッケージによる不要なセキュリティリスクが追加されるだけでなく、イメージサイズが不必要に膨らんでしまうことになります。

RUN npm install

以前のガイド NPM セキュリティベストプラクティス 10 項目をご覧になった方は、npm ci を使った決定的ビルドを適用する必要性についてご理解いただけていることと思います。この方法では、ロックファイルから逸脱が発生すると停止するため、継続的インテグレーション (CI) フローで予期しない事態が発生するのを防ぐことができます。

本番環境の Docker イメージをビルドする場合、本番環境の依存関係のみを決定的にインストールするため、コンテナイメージに npm の依存関係をインストールする場合のベストプラクティスとして、以下のようにすることが推奨されています。

RUN npm ci --only=production

この段階では、以下の内容で Dockerfile を更新します。

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

詳しくは、ソフトウェア依存関係についての記事をご覧ください。

3.Node.js ツールを本番環境向けに最適化する

Node.js Docker イメージを本番環境向けにビルドする場合、すべてのフレームワークとライブラリがパフォーマンスとセキュリティのために最適な設定を使用していることを確認する必要があります。

このため、以下の Dockerfile ディレクティブを追加します。

ENV NODE_ENV production

一見すると、これは冗長に思えます。npm install フェーズですでに本番環境の依存関係のみを指定しているためです。では、なぜこれが必要なのでしょうか?

開発者は、NODE_ENV=production 環境変数の設定といえば、本番環境に関連する依存関係のインストールを連想しますが、この設定により思わぬ影響が生じることに注意する必要があります。

一部のフレームワークやライブラリでは、NODE_ENV 環境変数が production に設定されている場合に限り、本番環境に最適化された設定が有効になります。このフレームワークのプラクティスが良いか悪いかという点はさておき、このことを知っておくことは重要です。

たとえば、Express のドキュメントでは、パフォーマンスやセキュリティ関連の最適化を有効にするため、この環境変数を設定することの重要性を説明しています。

wordpress-sync/blog-snyk-docker-optimize-node.js-tooling

NODE_ENV 変数がパフォーマンスに与える影響は非常に大きいものとなる可能性があります。

Dynatrace の親切なスタッフが、Express アプリケーションで NODE_ENV を省略した場合の劇的な影響について、ブログ投稿で詳しく説明しています。

依存関係にある他のライブラリの多くも、この変数が設定されていることを想定している可能性があるため、Dockerfile でこれを設定します。

環境変数 NODE_ENV の設定を組み込んだ Dockerfile の更新は以下のようになります。

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4.コンテナを root で実行しない

最小権限の原則は、Unix の初期からのセキュリティ原則であり、コンテナ化された Node.js ウェブアプリケーションを実行するときは常にこれに従います。

脅威の評価は非常に単純です。もし、ハッカーがウェブアプリケーションに侵入してコマンドインジェクションディレクトリパストラバーサルができるなら、アプリケーションプロセスを所有するユーザーで起動できるためです。そのプロセスが root の場合、コンテナのエスケープ試行権限昇格など、コンテナで実質的にすべてのことを実行できます。リスクを冒す必要がどこにあるでしょうか?そうです。その必要はどこにもありません。

これは重要なポイントです。「友達にはコンテナを root で実行させてはいけません」。

公式の node Docker イメージ、および alpine などのバリアントには、同じ名前の最小権限ユーザー nodeが含まれています。ただし、プロセスを node として実行するだけでは十分ではありません。たとえば、以下のようなものはアプリケーション機能として理想的ではありません。

USER node
CMD "npm" "start"

なぜなら、USER Dockerfile ディレクティブは、プロセスの所有者が node ユーザーであることを保証するだけだからです。先ほどの COPY 命令でコピーしたすべてのファイルについては、root で所有されています。これが Docker のデフォルトの動作です。

権限を削除するには、以下の方法で完全かつ適切に行う必要があります。また、この時点までの最新の Dockerfile プラクティスも示しています。

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5.Node.js Docker ウェブアプリケーションを安全に終了する

Docker コンテナで実行する Node.js アプリケーションのコンテナ化についてのブログ記事などで見られる最も一般的な間違いの 1 つに、プロセスを呼び出す方法があります。以下のすべてとそのバリアントは避けるべき悪いパターンです。

  • CMD “npm” “start”

  • CMD [“yarn”, “start”]

  • CMD “node” “server.js”

  • CMD “start-app.sh”

詳しく調べてみましょう。欠陥のある呼び出しプロセスのそれぞれについて調べ、避けるべき理由を説明していきます。

Node.js Docker アプリケーションを適切に実行し、終了するためのコンテキストを理解するには、以下の点について考えることが重要です。

  1. Docker Swarm や Kubernetes、Docker エンジンなど、オーケストレーションエンジンでは、コンテナのプロセスにシグナルを送信する方法が必要です。そのほとんどは、SIGTERMSIGKILL などのアプリケーションを終了させるシグナルです。

  2. プロセスは間接的に実行されることがあり、その場合、これらの信号を常に受信するとは限りません。

  3. Linux カーネルは、プロセス ID 1 (PID) として実行されるプロセスを、他のプロセス ID とは異なる方法で扱います。

その点を踏まえて、コンテナのプロセスを呼び出す方法について調べてみましょう。まず、ビルドしている Dockerfile の例から始めます。

CMD "npm" "start"

ここでの注意点は 2 つあります。まず、npm クライアントを直接呼び出すことで、node アプリケーションを間接的に実行しています。npm CLI がすべてのイベントを node ランタイムに転送すると、どうしていえるでしょうか。実際には転送しておらず、それは簡単にテストできます。

Node.js アプリケーションで、イベントを送信するたびにコンソールにログを記録する SIGHUP シグナルのイベントハンドラーを設定していることを確認してください。簡単なコード例は以下のようになります。

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

次にコンテナを実行し、コンテナが起動したら、docker CLI と特別な --signal コマンドラインフラグを使用して、SIGHUP シグナルを指定して送信します。

$ docker kill --signal=SIGHUP elastic_archimedes

何も起こらなかったのではないでしょうか?ということは、npm クライアントは生成した node プロセスにシグナルを転送していないのです。

もう一つの注意点は、Dockerfile で CMD ディレクティブを指定する方法の違いにあります。2 つの方法があり、同じではありません。

  1. shellform 表記では、コンテナがプロセスをラップするシェルインタプリターを生成します。この場合、シェルはシグナルをプロセスに適切に転送しない可能性があります。

  2. execform 表記では、シェルにラップせずに直接プロセスを起動します。JSON 配列表記を使用し、CMD [“npm”, “start”] のように指定します。これにより、コンテナに送信されたシグナルは、プロセスに直接送信されます。

その点を踏まえて、以下のように Dockerfile プロセス実行ディレクティブを改善してみましょう。

CMD ["node", "server.js"]

こうすることで、ノードプロセスを直接呼び出して、シェルインタープリターにラップすることなく、ノードプロセスに送信されたすべてのシグナルを確実に受信できるようにしています。

ただし、これには別の落とし穴があります。

プロセスが PID 1 として実行されると、事実上 init システムの責任の一部を引き受けることになります。これは通常、オペレーティングシステムとプロセスの初期化を担当しています。カーネルでは、PID 1 は他のプロセス ID とは異なる方法で扱われます。カーネルでこのように特別に扱う理由は、実行中プロセスに対する SIGTERM シグナルの処理において、プロセスハンドラーが設定されていない場合でも、そのプロセスを強制終了するというデフォルトのフォールバック動作を呼び出さないようにするためです。

Node.js Docker ワーキンググループのアドバイスは、次のようなものです。  「Node.js は PID 1 として実行するように設計されていないため、Docker で実行すると予期しない動作が発生します。たとえば、PID 1 として実行される Node.js プロセスは、SIGINT (CTRL-C) や同様のシグナルには応答しません」。

そこで、PID 1 で呼び出すツールには init プロセスのように動作するものを使用してください。次に、Node.js アプリケーションを別のプロセスとして起動し、その Node.js プロセスをプロキシとしてすべてのシグナルが送信されるようにします。可能ならツールのフットプリントはできるだけ小さくして、コンテナイメージにセキュリティの脆弱性が追加されるリスクを避けます。

Snyk では、静的にリンクされ、フットプリントが小さいという理由から、そのようなツールの 1 つとして dumb-init を使用しています。設定方法は以下のとおりです。

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

これにより、最新の Dockerfile が以下のように表示されます。Docker のレイヤーキャッシュを利用できるように、イメージの宣言の直後に dumb-init パッケージのインストールを配置しています。

FROM node:20.9.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

RUN apt-get update && apt-get install のように、Docker の RUN 命令を使用してソフトウェアを追加すると、Docker イメージにいくつかの情報が残ってしまいます。このコマンドの後にクリーンアップを行うには、以下のように拡張することで、スリムな Docker イメージを維持できます。

FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

ヒント: 最初のビルド段階のイメージに dumb-init ツールをインストールしてできた /usr/bin/dumb-init ファイルを最終的なコンテナイメージにコピーすると、そのイメージをクリーンに保つことができます。Docker のマルチステージビルドについては、このガイドの後半で詳しく説明しています。

お役立ち情報: docker killdocker stop コマンドは PID 1 のコンテナプロセスにのみシグナルを送信します。Java アプリケーションを実行するシェルスクリプトを実行している場合、シェルインスタンス (たとえば /bin/sh など) は子プロセスにシグナルを転送しないため、アプリケーションでは SIGTERM が取得されないことに注意してください。

6.Node.js ウェブアプリケーションを正常にシャットダウンする

これまで、アプリケーションを終了させるプロセスシグナルについて見てきました。次は、ユーザーを混乱させることなくシャットダウンを適切かつ正常に行う方法を見ていきます。

Node.js アプリケーションで SIGINTCTRL+C などの割り込みシグナルを受信すると、イベントハンドラーの設定で別の動作の処理が起こらない限り、プロセスはただちに強制終了されます。これは、ウェブアプリケーションに接続されているクライアントがすぐに切断されてしまうことを意味します。ここで、何百もの Node.js ウェブコンテナが Kubernetes によってオーケストレーションされ、拡張やエラー管理の必要性に応じてアップダウンを繰り返している様子を想像してみてください。気持ちの良いユーザーエクスペリエンスではありません。

この問題は簡単にシミュレートできます。これは、エンドポイントに対して 60 秒の固有の遅延応答を設定したストック Fastify ウェブアプリケーションの例です。

fastify.get('/delayed', async (request, reply) => {
 const SECONDS_DELAY = 60000
 await new Promise(resolve => {
     setTimeout(() => resolve(), SECONDS_DELAY)
 })
 return { hello: 'delayed world' }
})

const start = async () => {
 try {
   await fastify.listen(PORT, HOST)
   console.log(`*^!@4=> Process id: ${process.pid}`)
 } catch (err) {
   fastify.log.error(err)
   process.exit(1)
 }
}

start()

このアプリケーションを実行したら、このエンドポイントに単純な HTTP リクエストを送信します。

$ time curl https://localhost:3000/delayed

実行中の CTRL+C コンソールウィンドウで CTRL+C を押すと、curl リクエストが突然終了します。これにより、コンテナが破棄されたときにユーザーが受けるのと同じエクスペリエンスをシミュレートできます。

エクスペリエンスを改善するために、以下のようにできます。

  1. SIGINTSIGTERM などのさまざまな終了シグナルのイベントハンドラーを設定します。

  2. ハンドラーでは、データベース接続や進行中の HTTP リクエストなどのクリーンアップ処理を待機します。

  3. その後、ハンドラーは Node.js プロセスを終了します。

特に Fastify では、ハンドラーが fastify.close() を呼び出すことで、待機するという約束を返します。また Fastify では、すべての新しい接続に対して HTTP ステータスコード 503 の応答を返し、アプリケーションが利用できないことを通知します。

イベントハンドラーを追加してみましょう。

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)

   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)

確かに、これは Dockerfile に関連するというより、一般的なウェブアプリケーションの問題になるとはいえ、オーケストレーション環境では重要なポイントになります。

7.Node.js の Docker イメージのセキュリティ脆弱性を検出して修正する

前述のとおり Node.js アプリケーションで Docker ベースイメージを小さくすることが重要です。この点を実践でテストしてみましょう。

Snyk CLI を使用して Docker イメージをテストします。Snyk の無料アカウントはこちらから登録できます。

$ npm install -g snyk
$ snyk auth
$ snyk container test node:20.9.0-bullseye-slim --file=Dockerfile

最初のコマンドで Snyk CLI をインストールし、続いてコマンドラインから API キーを取得する簡単なサインインフローを実行し、その後、コンテナにセキュリティ上の問題がないかどうかをテストすることができます。結果は以下のとおりです。

Organization:      lirantal
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:20.9.0-bullseye-slim
Platform:          linux/arm64
Base image:        node:lts-bullseye-slim
Licenses:          enabled

Tested 97 dependencies for known issues, found 44 issues.

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

Snyk は、Node.js ランタイム実行可能ファイルを含む 97 件のオペレーティングシステムの依存関係を検出しましたが、ランタイムの脆弱性のあるバージョンは見つかりませんでした。ただし、コンテナイメージの一部のソフトウェアには、44 件のセキュリティ脆弱性が存在します。これらの依存関係のうち 43 件は重大度の低い問題ですが、1 件は重大な zlib ライブラリ関連の脆弱性です。

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:lts-bullseye-slim)

Critical severity vulnerability found in zlib/zlib1g
  Description: Out-of-bounds Write
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
  Introduced through: meta-common-packages@meta
  From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
  Fixed in: 1:1.2.11.dfsg-2+deb11u2

Docker イメージの脆弱性の修正

Docker イメージのソフトウェアを安全に保つ、効果的で迅速な方法の 1 つは、Docker イメージのリビルドです。取得する更新はアップストリームの Docker ベースイメージに依存することになります。もう 1 つの方法は、パッケージの OS システム更新 (セキュリティ修正を含む) を明示的にインストールすることです。

公式の Node.js Docker イメージでは、イメージを更新するチームの対応が遅くなる可能性があり、Node.js Docker イメージ 16.17.0-bullseye-slim または lts-bullseye-slim をリビルドしても効果がありません。もう 1 つの選択肢は、Debian の最新のソフトウェアを使用して独自のベースイメージを管理することです。Dockerfile で以下を実行できます。

RUN apt-get update && apt-get upgrade -y

新しく追加した RUN 命令を使用して Node.js Docker イメージをビルドした後、Snyk セキュリティスキャンを実行してみましょう。

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:20.9.0-bullseye-slim)
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image

結果として OS の依存関係が 1 つ追加されたものの (97 件から 98 件に増加)、この Node.js Docker イメージに影響を与える 43 件のセキュリティ脆弱性はすべて深刻度が低くなり、重大な zlib セキュリティ脆弱性が修正されています。これは大成功です。

FROM node のベースイメージディレクティブを使っていたらどうなったでしょうか?さらに言えば、このような、より具体的な Node.js Dockerベースイメージを使ったとしましょう。

FROM node:14.2.0-slim

✗ High severity vulnerability found in node
  Description: Memory Corruption
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.4.0

High severity vulnerability found in node
  Description: Denial of Service (DoS)
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.11.0

Organization:      snyk-demo-567
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:14.2.0-slim
Platform:          linux/amd64
Base image:        node:14.2.0-slim

Tested 78 dependencies for known issues, found 82 issues.

Base Image        Vulnerabilities  Severity
node:14.2.0-slim  82               23 high, 11 medium, 48 low

Recommendations for base image upgrade:

Minor upgrades
Base Image         Vulnerabilities  Severity
node:14.15.1-slim  71               17 high, 7 medium, 47 low

Major upgrades
Base Image        Vulnerabilities  Severity
node:15.4.0-slim  71               17 high, 7 medium, 47 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:14.15.1-buster-slim   55               12 high, 4 medium, 39 low
node:14.15.3-stretch-slim  71               17 high, 7 medium, 47 low

FROM node:14.2.0-slim のように Node.js ランタイムバージョンを指定する方法は、特定のバージョン (`14.2.0`) を指定し、小さなコンテナイメージを使用していることから (`slim` イメージタグ)、十分なように思われますが、Snyk では 2 つの主要なソースからセキュリティの脆弱性を検出できます。

  1. Node.js ランタイム自体。上記のレポートに主に 2 つのセキュリティ脆弱性があることに気づかれましたか?これらは、Node.js ランタイムの既知のセキュリティ問題です。これらをすぐに修正するには、Snyk が通知する新しい Node.js バージョンにアップグレードする必要があります。Snyk では、どのバージョンで修正したかも通知されます (今回の出力では 14.11.0)。

  2. この Debian ベースイメージにインストールされるツールやライブラリ。glibc、bzip2、gcc、perl、bash、tar、libcrypt など。コンテナに含まれる脆弱性のあるバージョンはただちに脅威をもたらさないかもしれませんが、使用しないのになぜ含まれているのでしょうか?

今回の Snyk CLI レポートの要点は何でしょうか?Snyk では、他のベースイメージへの切り替えも推奨してくれるため、自分で考える必要はありません。代替イメージを見つけるのは非常に時間がかかるため、Snyk を使用することで手間を省くことができます。

この段階でのアドバイスは以下のとおりです。

  1. Docker Hub や Artifactory などのレジストリで Docker イメージを管理している場合、簡単に Snyk にインポートして、プラットフォームで脆弱性を調べることができます。これにより、Snyk UI で推奨事項のアドバイスが提供されるだけでなく、新たに発見されたセキュリティの脆弱性について継続的に Docker イメージをモニタリングすることもできます。

  2. Snyk CLI を使用して CI を自動化します。CLI は非常に柔軟性に富んでおり、まさに私たちが開発した理由はそこにあります。柔軟性があるため、既存のカスタムワークフローに適用できるのです。また、Snyk for GitHub Actions も用意されています。

コンテナイメージの脆弱性を管理するその他の方法については、コンテナセキュリティガイドをご覧ください。

8.マルチステージビルドを使用する

マルチステージビルドは、単純でも間違っている可能性がある Dockerfile から、Docker イメージのビルドのステップを分離することで、機密情報の漏洩を防ぐことができる優れた方法です。それだけでなく、大きな Docker ベースイメージを使用して依存関係をインストールし、必要に応じてネイティブの npm パッケージをコンパイルし、これらすべてのアーティファクトを alpine のような小さな本番環境のベースイメージにコピーすることができます。

機密情報の漏洩を防止する

機密情報の漏洩を防ぐためのユースケースは、案外多いものです。

仕事で Docker イメージをビルドしている人は、プライベートで npm パッケージも管理している可能性が高いといえます。もしそうなら、秘密の NPM_TOKEN を npm インストールで利用できるようにする何らかの方法が必要だったのではないでしょうか。

ここでは、その一例をご紹介します。

FROM node:20.9.0-bullseye-slim

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

ただし、この場合、Docker イメージに秘密の npm トークンを含む .npmrc ファイルが残ります。以下のように、後から削除することで改善を試みることも可能です。

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
RUN rm -rf .npmrc

これにより .npmrc ファイルは Docker イメージの別のレイヤーで利用できるようになっています。この Docker イメージが公開されていたり、誰かが何らかの方法でアクセスすることができれば、このトークンは危険にさらされることになります。これは以下の方法で改善できます。

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

ここでの問題は、秘密の npm トークンが含まれているため Dockerfile 自体を秘密のアセットとして扱う必要があるということです。

幸いなことに、Docker ではビルドプロセスに引数を渡す方法がサポートされています。

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

そして、以下のようにビルドします。

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

これで終わりかと思ったら、残念でした。これで終わってはいけません。

セキュリティはそういうものです。普通に見えるところに、落とし穴があることがあります。

では、何が問題なのでしょうか?このように Docker に渡されたビルド引数は、履歴ログに残ります。自分の目で確かめてみましょう。次のコマンドを実行します。

$ docker history nodejs-tutorial

そうすると、以下のように表示されます。

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b4c2c78acaba   About a minute ago   CMD ["dumb-init" "node" "server.js"]            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG NPM_TOKEN                                   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . . # buildkit                             15.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV NODE_ENV=production                         0B        buildkit.dockerfile.v0

npm の秘密のトークンを見つけましたか?そういうことです。

コンテナイメージのシークレットを管理する優れた方法がありますが、今回は、この問題の対策としてマルチステージビルドを行い、最小のイメージをビルドする方法についてご紹介します。

Node.js Docker イメージのマルチステージビルドを行う

ソフトウェア開発における「関心の分離」の原則と同じように、Node.js Docker イメージをビルドする場合にも、同じ考え方を適用します。Node.js アプリケーションの実行で必要になるすべてのものをビルドするために 1 つのイメージを用意します。これは、Node.js の世界では、npm パッケージをインストールし、必要に応じてネイティブ npm モジュールをコンパイルすることを意味します。それが第 1 段階です。

2 つ目の Docker イメージは、Docker ビルドの第 2 段階を表すもので、本番環境の Docker イメージになります。この第 2 段階が最後の段階ですが、イメージを実際に最適化し、レジストリがある場合はそこに公開します。この最初のイメージは build イメージと呼ばれ、ビルドした Docker ホストにぶら下がったイメージとして残され、その後クリーンアップされます。

ここでは、これまでの進捗を表す Dockerfile を 2 段階に分けて更新しています。

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

ここでは build 段階で大きなイメージを選択しています。ネイティブ npm パッケージのコンパイルや、その他のニーズのために、gcc (GNU Compiler Collection) などのツールが必要になる可能性があるためです。

第 2 段階では、node_modules/ フォルダーをビルド Docker イメージからこの新しい本番環境ベースイメージにコピーする COPY ディレクティブの特別な表記があります。

そして、ここで NPM_TOKEN がビルド引数として build 中間 Docker イメージに渡されたことにお気づきでしょうか?本番環境の Docker イメージには存在しなくなるため、docker history nodejs-tutorial コマンドの出力には表示されていません。

9.Node.js Docker イメージから不要なファイルを除外する

不要なファイルや潜在的に機密性の高いファイルで git リポジトリが汚染されることを避けるために、.gitignore ファイルが用意されています。同じことが Docker イメージにも当てはまります。

Docker 無視ファイルとは

Docker には .dockerignore が用意されており、その中の glob パターンに一致するものを Docker デーモンに送信しないようになっています。ここでは、Docker イメージに入れてしまいがちなファイルのうち、できれば避けたいものをリストしています:- .dockerignore-node_modules-npm-debug.log-Dockerfile-.git-.gitignore

ここでは、node_modules/ がスキップされることが非常に重要です。これが無視されないとすると、最初に使用した単純な Dockerfile バージョンでローカルの node_modules/ フォルダーがそのままコンテナにコピーされてしまいます。

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

さらに、Docker のマルチステージビルドを行っている場合は、.dockerignore ファイルを用意することが重要です。第 2 段階の Docker ビルドをどのようにしていたか、復習しておきましょう。

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

.dockerignore を用意するのが重要なのは、第 2 段階の Dockerfile で COPY . /usr/src/app を実行する際にローカルの node_modules/ も Docker イメージにコピーされてしまうからです。node_modules/ で変更したソースコードを上書きしてコピーする可能性もあるため、これは大きな問題となります。

さらに、COPY . をワイルドカードで使用しているため、認証情報やローカル設定を含む機密ファイルを Docker イメージにコピーしてしまう可能性もあります。

.dockerignore ファイルについての要点は以下のとおりです。

  • Docker イメージで変更されたかもしれない node_modules/ のコピーはスキップしましょう。

  • .envaws.json ファイルの内容に含まれる認証情報が Java Docker イメージに入り込んでしまうような、データ漏洩を防ぐことができます。

  • キャッシュ無効化の原因となるファイルを無視することで、Docker のビルドを高速化できます。たとえば、ログファイルまたはローカル環境設定ファイルを変更した場合、ローカルディレクトリを上書きしてコピーするレイヤーで Docker イメージキャッシュが無効になってしまいます。

10.Docker のビルドイメージにシークレットをマウントする

.dockerignore ファイルについて注意すべきことの 1 つは、これは全か無かのアプローチであり、Docker マルチステージビルドのビルドステージごとにオンにしたり、オフにしたりすることはできないということです。

なぜそれが重要なのでしょうか?理想的には、ビルド段階で .npmrc ファイルを使用することをお勧めします。プライベート npm パッケージにアクセスするための秘密の npm トークンが含まれているためです。おそらく、パッケージを取得するために特定のプロキシやレジストリの設定も必要になります。

つまり、build 段階では .npmrc ファイルを使用することに意味があります。ただし、第 2 段階の本番環境イメージではまったく必要ないばかりか、秘密の npm トークンなどの機密情報が含まれている可能性があるため、望ましくありません。

この .dockerignore の危険を緩和する方法として、ビルド段階で利用可能なローカルファイルシステムをマウントする方法がありますが、もっと良い方法があります。

Docker では Docker secrets と呼ばれる比較的新しい機能がサポートされており、今回 .npmrc を使う場合に活用できます。動作するしくみは以下のとおりです。

  • docker build コマンドを実行すると、新しいシークレット ID が定義され、シークレットのソースとしてファイルを参照するコマンドライン引数が指定されます。

  • Dockerfile では、RUN ディレクティブにフラグを追加して、本番環境の npm をインストールします。これにより、シークレット ID によって参照されるファイルがターゲット (ローカルディレクトリの .npmrc ファイルが保管される場所) にマウントされます。

  • .npmrc ファイルはシークレットとしてマウントされ、Docker イメージにコピーされることはありません。

  • 最後に、.npmrc ファイルを .dockerignore ファイルの内容に追加し、ビルドイメージにも本番環境イメージにも一切入らないようにしましょう。

すべてがどのように動作するか見てみましょう。まず、.dockerignore ファイルを更新します。

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

次に、完全な Dockerfile で RUN ディレクティブを更新し、npm パッケージをインストールする際に .npmrc マウントポイントを指定します。

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

そして最後に、Node.js Docker イメージをビルドするコマンドです。

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

注: Secret は Docker の新機能ですが、古いバージョンを使用している場合は、以下のように Buildkit を有効にする必要があるかもしれません。

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

まとめ

ここまでで、最適化された Node.js Docker ベースイメージを作成しました。お疲れ様でした。

最後のステップで、Node.js Docker ウェブアプリケーションのコンテナ化に関するこのガイド全体を振り返ります。パフォーマンスとセキュリティ関連の最適化を考慮し、本番稼働グレードの Node.js Docker イメージを確実にビルドできるようにしておきましょう。

ぜひ確認していただきたい補足資料を以下に挙げます。

Node.js アプリケーションの安全でパフォーマンスの高い Docker ベースイメージを作成したら、無料の Snyk アカウントを使用してコンテナの脆弱性を見つけて修正しましょう。