Skip to main content

DockerでNode.jsウェブアプリケーションをコンテナ化するためのベストプラクティス10選

feature cheat sheet

2022年9月15日

0 分で読めます

ウェブアプリケーション向けのNode.js Dockerイメージを構築するベストプラクティスをお探しですか?この記事がお役に立ちます。

この記事では、最適化され、安全なNode.js Dockerイメージを構築するための、本番環境向けガイドラインを紹介します。どのようなNode.jsアプリケーションを構築する場合にも役立つ内容です。次のような方におすすめです。

  • ReactのNode.js機能を使って、サーバーサイドレンダリング(SSR)を行うフロントエンドアプリケーションを構築したい。

  • Fastify、NestJSなどのアプリケーションフレームワークを使ったマイクロサービス向けに、Node.js Dockerイメージを適切に構築する方法を知りたい。

Node.jsウェブアプリケーションのコンテナ化ガイドを書いた理由

Node.jsアプリケーション向けのDockerイメージの構築方法を解説する、よくある記事のひとつに思えるかもしれません。しかし、ブログで見かける多くの例は非常に単純で、Node.js Dockerイメージでアプリケーションを実行する基本を説明するだけです。セキュリティや、Node.js Dockerイメージを構築する際のベストプラクティスについて、十分に考慮されていません。

シンプルで動作するDockerfileから始め、各Dockerfileディレクティブに潜む落とし穴やセキュリティ上の問題を理解し、修正するところまで、Node.jsウェブアプリケーションをコンテナ化する手順を順を追って説明します。

シンプルな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を改善し、Dockerで最適化されたNode.jsウェブアプリケーションを構築できるようにしましょう。

こちらのリポジトリをクローンして、このチュートリアルを一緒に進めることができます。

Dockerで最適化されたNode.jsウェブアプリケーションを構築するため、次の10の手順に従いましょう。

  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 working groupがビルドした最新のDockerイメージを常に使うことになります。

FROM node

デフォルトのnodeイメージをベースにビルドする場合の問題点は、次のとおりです。

  1. Dockerイメージのビルド結果に一貫性がありません。npmパッケージをインストールするたびに決定論的なnpm installの動作を得るためにlockfilesを使うのと同様に、Dockerイメージのビルドにも一貫性を持たせたいものです。nodeをベースにイメージをビルドすると、実質的にはnode:latestタグを使うことになり、ビルドのたびに新しくビルドされたnodeのDockerイメージを取得します。このような非決定論的な動作は避ける必要があります。

  2. node Dockerイメージは、Node.jsウェブアプリケーションの実行に必要かどうかわからないライブラリやツールが多数含まれた、フル機能のオペレーティングシステムをベースにしています。これには2つのデメリットがあります。1つ目は、イメージが大きいとダウンロードサイズも大きくなるため、ストレージ要件が増えるだけでなく、ダウンロードや再ビルドにも時間がかかることです。2つ目は、こうしたライブラリやツールに存在する可能性のあるセキュリティ脆弱性を、イメージに持ち込んでしまうおそれがあることです。

実際、node Dockerイメージはかなり大きく、種類や深刻度の異なるセキュリティ脆弱性が数百件含まれています。これを使う場合、最初から642件のセキュリティ脆弱性を抱え、イメージをプルしてビルドするたびに数百MBのデータをダウンロードすることになります。

node、postgres、nginx、httpd、mongo、mysql、couchbase、memcachedの公式コンテナイメージに含まれる脆弱性を、2018年と2019年で比較した棒グラフと

より優れたDockerイメージを構築するための推奨事項は次のとおりです。

  1. 小さなDockerイメージを使う。Dockerイメージに含まれるソフトウェアの量が減って脆弱性の潜在的な侵入経路を抑えられるうえ、サイズも小さくなり、イメージのビルドを高速化できます。

  2. Dockerイメージの静的なSHA256ハッシュであるダイジェストを使う。これにより、ベースイメージから一貫性のあるDockerイメージをビルドできます。

最適なNode.js Dockerイメージの選び方について、詳しい記事を公開しています。最新のDebian slimディストリビューションと、長期サポート対象のNode.jsランタイムバージョンを選ぶべき理由を解説しています。

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

FROM node:20.9.0-bullseye-slim

このNode.js Dockerイメージタグは、現在の最新の長期サポート対象に対応する特定のNode.jsランタイムバージョン(20.9.0)を使います。イメージのバリアントにはbullseyeを使っており、十分先のサポート終了日が設定された、現行の安定版Debian 11です。さらにslimバリアントを使い、オペレーティングシステムのソフトウェア構成を小さくしています。これにより、Node.jsランタイムとツールを含めてもイメージサイズは200MB未満になります。

とはいえ、よく見かける誤ったプラクティスのひとつに、ベースイメージとして次の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ソフトウェアツールと、Cライブラリ実装のmuslを採用したAlpineオペレーティングシステムがバンドルされています。こうした2つの特徴があるため、Node.js AlpineイメージはNode.jsチームによる公式サポートの対象外です。さらに、多くのセキュリティ脆弱性スキャナーではNode.js Alpineイメージ内のソフトウェアアーティファクトやランタイムを簡単に検出できません。これは、コンテナイメージのセキュリティ確保に逆行します。

Node.js Alpineのイメージタグを使う場合でも、ベースイメージの指定に単語のエイリアスを使うと、Dockerイメージのタグは変更可能なため、新しいビルドが取得される可能性があります。このタグのSHA256ハッシュは、Docker HubのNode.jsタグページで確認できます。または、イメージをローカルにプルした後、次のコマンドを実行し、出力にある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イメージ名だけを指定しています。そのため、実際に使われるイメージタグが曖昧になり、読みづらく、保守しにくいうえ、開発者にとって使いやすいものではありません。

Node.jsのバージョンに対応するSHA256ハッシュと、ベースイメージの完全なタグを指定して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イメージのダイジェストを使うと一貫性のあるイメージを作成できますが、解釈方法がわからないイメージスキャンツールもあり、混乱や逆効果を招くことがあります。そのため、20.9.0のようにNode.jsランタイムのバージョンを明示するのがおすすめです。理論上は変更や上書きが可能ですが、セキュリティ更新などが必要になった場合は20.9.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のドキュメントでは、パフォーマンスとセキュリティ関連の最適化を有効にするために、この環境変数を設定する重要性が説明されています。

NODE_ENV="production"のメリットとして、テンプレートとCSSのキャッシュ、簡潔なエラーメッセージなどを強調したExpressのドキュメント。

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"

その理由は、DockerfileのUSERディレクティブで設定されるのは、プロセスの所有者が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”

詳しく見ていきましょう。誤ったプロセス起動方法を1つずつ取り上げ、避けるべき理由を説明します。

Node.jsのDockerアプリケーションを適切に実行し、終了する方法を理解するには、次の点が重要です。

  1. Docker SwarmやKubernetes、あるいはDockerエンジン自体のようなオーケストレーションエンジンは、コンテナ内のプロセスにシグナルを送る必要があります。多くの場合、SIGTERMやSIGKILLなど、アプリケーションを終了するためのシグナルです。

  2. プロセスが間接的に実行されている場合、こうしたシグナルが確実に届くとは限りません。

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

この点を踏まえて、コンテナ内でプロセスを起動する方法を調べていきましょう。まず、作成中のDockerfileにある例から始めます。

CMD "npm" "start"

ここには2つの注意点があります。まず、npmクライアントを直接起動してNode.jsアプリケーションを間接的に実行しています。npm CLIがすべてのイベントをNode.jsランタイムに転送するかどうかは分かりません。実際には転送されないため、簡単にテストできます。

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.jsプロセスにシグナルを転送しないためです。

もう1つの注意点は、DockerfileでCMDディレクティブを指定する方法が複数あることです。方法は2つありますが、それぞれ異なります。

  1. シェル形式では、コンテナがシェルインタープリターを起動し、その中でプロセスを実行します。この場合、シェルがプロセスにシグナルを正しく転送しない可能性があります。

  2. exec形式では、シェルでラップせずにプロセスを直接起動します。JSON配列形式で指定し、たとえば次のように記述します。CMD [“npm”, “start”]。コンテナに送信されたシグナルは、プロセスに直接届きます。

この点を踏まえて、Dockerfileのプロセス実行ディレクティブを次のように改善します。

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

これでNode.jsプロセスを直接起動でき、シェルインタープリターを介さずにすべてのシグナルを受け取れるようになります。

ただし、これによって別の落とし穴が生じます。

PID 1として実行されるプロセスは、通常はオペレーティングシステムやプロセスを初期化するinitシステムの役割の一部を担います。カーネルはPID 1をほかのプロセスIDとは異なる方法で扱います。この特別な扱いにより、実行中のプロセスがSIGTERMシグナル用のハンドラーを設定していない場合でも、プロセスを終了するデフォルトの動作は実行されません。

この点についてNode.js Dockerワーキンググループの推奨事項を引用すると、「Node.jsはPID 1として実行するように設計されていないため、Docker内で実行すると予期しない動作につながります。たとえば、PID 1として実行されるNode.jsプロセスは、SIGINT(CTRL-C)などのシグナルに応答しません。」

そのため、initプロセスのように動作するツールを使います。PID 1として起動し、別のプロセスとして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は次のようになります。イメージ宣言の直後にdumb-initパッケージのインストールを配置している点に注目してください。Dockerのレイヤーキャッシュを活用できます。

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"]

DockerのRUN命令でRUN apt-get update && apt-get installのようにソフトウェアを追加すると、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"]

DockerfileのENTRYPOINTを使ってdumb-initを設定し、実行するNode.jsプロセスをコマンドライン引数として渡すことで、さらに改善できます。上記のDockerfileの記述を次のように更新します。

FROM node:16.17.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
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

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

参考:docker killコマンドとdocker stopコマンドがシグナルを送るのは、PID 1のコンテナプロセスだけです。Node.jsアプリケーションを実行するシェルスクリプトを使っている場合、/bin/shなどのシェルは子プロセスにシグナルを転送しません。そのため、アプリケーションはSIGTERMを受け取れません。

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

アプリケーションを終了させるプロセスシグナルについて説明したところで、ユーザーに影響を与えず、適切かつスムーズにシャットダウンできるようにしましょう。

Node.jsアプリケーションが割り込みシグナル(SIGINTまたはCTRL+C)を受け取ると、別の動作を行うイベントハンドラーが設定されていない限り、プロセスは突然終了します。その結果、ウェブアプリケーションに接続中のクライアントは即座に切断されます。Kubernetesでオーケストレーションされた何百ものNode.jsウェブコンテナが、需要に応じたスケーリングやエラー対応のために起動・停止を繰り返す状況を想像してください。ユーザーにとって望ましい体験とは言えません。

この問題は簡単に再現できます。次に示すのは、あるエンドポイントの応答が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

実行中のNode.jsコンソールウィンドウでCTRL+Cを押すと、curlリクエストが突然終了することが分かります。これは、コンテナの停止時にユーザーが経験する状況と同じです。

より良い体験を提供するには、次のようにします。

  1. SIGINTやSIGTERMなど、各種終了シグナルのイベントハンドラーを設定します。

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

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

具体的には、Fastifyの場合、ハンドラーからfastify.close()を呼び出せます。この関数はPromiseを返すため、完了を待機できます。また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ベースイメージを使う重要性について説明したのを覚えていますか?実際に試してみましょう。

DockerイメージのテストにはSnyk CLIを使います。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件のOS依存関係を検出しましたが、ランタイムに脆弱なバージョンは見つかりませんでした。一方、コンテナイメージ内のソフトウェアには、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イメージの20.9.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ランタイムに関する公知のセキュリティ問題です。すぐに解決するには、より新しいNode.jsバージョンにアップグレードします。Snykは、修正されたバージョンが14.11.0であることも出力に示しています。

  2. このDebianベースイメージにインストールされたglibc、bzip2、gcc、perl、bash、tar、libcryptなどのツールやライブラリです。コンテナ内の脆弱なバージョンがすぐに脅威になるとは限りませんが、使っていないものを残しておく必要はあるでしょうか?

このSnyk CLIレポートの優れた点は、切り替え先となるほかのベースイメージもSnykが推奨してくれることです。自分で候補を探す必要はありません。代替イメージを見つけるには時間がかかることもありますが、Snykを使えばその手間を省けます。

ここでのおすすめは次のとおりです。

  1. DockerイメージをDocker HubやArtifactoryなどのレジストリで管理している場合は、簡単にSnykにインポートできます。Snykプラットフォームが脆弱性を検出し、Snyk UIで修正の推奨事項を確認できるほか、新たに発見されたセキュリティ脆弱性がないかDockerイメージを継続的に監視できます。

  2. CI自動化にSnyk CLIを使いましょう。CLIは柔軟性が高く、独自のワークフローに適用できるように作られています。GitHub Actionsを使う場合は、Snyk for GitHub Actionsもご利用いただけます。

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

8. マルチステージビルドを使う

マルチステージビルドを使うと、シンプルながら誤りが潜む可能性のあるDockerfileから、Dockerイメージのビルドを複数のステップに分ける構成へ移行でき、機密情報の漏えいを防げます。また、依存関係のインストールや必要に応じたネイティブnpmパッケージのコンパイルには大きめのDockerベースイメージを使い、生成物をalpineの例のような小さな本番用ベースイメージにコピーすることもできます。

機密情報の漏えいを防ぐ

機密情報の漏えいを防ぐこのユースケースは、思っている以上によくあります。

業務でDockerイメージをビルドしているなら、プライベートなnpmパッケージも管理している可能性が高いでしょう。その場合、シークレットであるNPM_TOKENをnpm installで使えるようにする方法を考える必要があったはずです。

具体例を見てみましょう。

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"]

しかし、この方法ではシークレットのnpmトークンが含まれた.npmrcファイルがDockerイメージに残ってしまいます。後から削除して改善しようとしても、次のようになります。

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

しかし今度は、Dockerイメージの別のレイヤーで.npmrc ファイルが利用可能になっています。このDockerイメージが公開されていたり、何らかの方法で誰かがアクセスできたりすれば、トークンが漏えいしてしまいます。より良い方法は次のとおりです。

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

ただし、Dockerfile自体にシークレットのnpmトークンが含まれるため、秘密情報として扱う必要があります。

幸い、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モジュールのコンパイルが該当します。これが最初のステージです。

2つ目のDockerイメージは、Dockerビルドの第2ステージにあたる本番用イメージです。この最後のステージのイメージを最適化し、レジストリがある場合はそこに公開します。buildイメージと呼ぶ最初のイメージは破棄され、ビルドを実行したDockerホスト上に未使用イメージとして残り、クリーンアップされるまで保持されます。

ここまでの進捗を反映し、2つのステージに分けたDockerfileがこちらです。

__# --------------> 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ステージでは、COPYディレクティブに特別な記法を使い、ビルド用Dockerイメージからnode_modules/フォルダーを新しい本番用ベースイメージにコピーします。

さらに、build中間Dockerイメージにビルド引数として渡したNPM_TOKENが確認できますか?本番用Dockerイメージには存在しないため、docker history nodejs-tutorialコマンドの出力にはもう表示されません。

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

不要なファイルや機密情報になり得るファイルがGitリポジトリに入らないように、.gitignoreファイルを使っていますよね。Dockerイメージでも同じことができます。

Dockerのignoreファイルとは?

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ファイルのポイントは次のとおりです。

  • 変更された可能性のあるnode_modules/のコピーがDockerイメージに含まれるのを防ぎます。

  • .envやaws.jsonファイル内の認証情報などのシークレットがNode.jsのDockerイメージに入り込むのを防ぎます。

  • キャッシュ無効化の原因となるファイルを除外することで、Dockerビルドを高速化できます。たとえば、ログファイルやローカル環境設定ファイルが変更されると、ローカルディレクトリをコピーするレイヤーでDockerイメージのキャッシュが無効になります。

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

.dockerignoreファイルについて注意すべき点は、対象を一括で指定する方式であり、Dockerのマルチステージビルドでステージごとに有効・無効を切り替えられないことです。

なぜ重要なのでしょうか。理想的には、ビルドステージで.npmrcファイルを使いたいところです。プライベートなnpmパッケージにアクセスするためのシークレットnpmトークンが含まれている可能性があるからです。パッケージの取得に特定のプロキシやレジストリの設定が必要な場合もあります。

つまり、.npmrcファイルはbuildステージでは利用可能にするのが適切です。一方、本番用イメージの第2ステージでは必要ありません。シークレットのnpmトークンなどの機密情報が含まれている可能性があるため、そこに置くべきでもありません。

この.dockerignoreの制約を緩和する方法の1つは、ビルドステージから利用できるローカルファイルシステムをマウントすることですが、もっと良い方法があります。

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

次に、.npmrcのマウント先を指定するようRUNディレクティブを更新した、Dockerfileの全体です。

__# --------------> 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

注: 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のWebアプリケーションをコンテナ化するためのガイドは終了です。パフォーマンスとセキュリティの最適化を考慮し、本番環境に対応できるNode.jsのDockerイメージをビルドする方法を紹介しました。

ぜひ、次のリソースもご覧ください。

Node.jsアプリケーション向けに、安全で高性能なDockerベースイメージをビルドしたら、無料のSnykアカウントを作成してコンテナの脆弱性を検出・修正しましょう。

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

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