Happy New Year!
年末、年始があっという間に終わり、明日は成人の日。
来週からコーディングのオンラインクラスを受けることになった。4−6ヶ月になりそうであるが、無事乗り切れるのか、少々不安も。javascriptを習得するコースなため、最終的にnode.jsのサーバーサイドでのコーディングもできるようになるまでの知識を得られるよう頑張ろう。node.jsの環境構築に不可欠ともいえるdocker。
今回は、10 best practices to containerize Node.js web applications with Docker
の翻訳記事のご紹介です。
今回は、特に翻訳に苦労しました。読みにくい部分もあると思いますが、どうぞ最後までお付き合いください。
#Dockerでnode.jsウェブアプリケーションをコンテナ化するための10のベストプラクティス
LiranTal、Yoni Goldberg
2021年1月13日
Webアプリケーション用のNode.js の Dockerイメージの構築方法に関するベストプラクティスについて説明します。
本記事では、本番環境を想定した安全かつ最適化されたNode.js の Dockerイメージを構築するためのガイドラインを提供します。Node.jsアプリケーションを構築する場合において、以下のようなケースで役立つ内容を説明します。
- React にサーバーサイドレンダリング(SSR)Node.js 機能を使用してフロントエンドアプリケーションを構築することを考えている
- Fastify や NestJS、その他のアプリケーションフレームワークを実行させるマイクロサービス用のNode.js の Dockerイメージの正しいビルド方法についてアドバイスがほしい
######このガイドを書いた理由
Node.jsアプリケーション用のDockerイメージを構築する方法は他のブログなどでも見つけることができます。しかし、多くの記事は単に Node.js の Docker イメージにてアプリケーションを実行することしか説明しておらず、セキュリティやベストプラクティスについての慎重な検討がされていませんでした。
本記事では、Node.js の Web アプリケーションをコンテナ化する方法をステップ・バイ・ステップで学習することができます。まず、シンプルに機能する Dockerfile から始めて、 Dockerfile ディレクティブ における落とし穴や危険性について理解した後、修正をしていきます。
###シンプルな 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 イメージを構築する際の間違いや悪習慣が含まれていますので、上記方法は絶対に避けてください。
では、 Docker にて最適化された Node.js Web アプリケーションを構築できるように、このDockerfileの改善を始めましょう。
このリポジトリをクローンすることで、チュートリアルを進める事ができます。
##1. 明示的で決定論的な Docker ベースイメージタグを使用
node
Docker イメージをベースにイメージを構築するのは当然のことのように思えるかもしれませんが、イメージを構築するときに実際には何をプルしているのでしょうか?Dockerイメージは常にタグで参照され、タグを指定しない場合はデフォルトである :latest
タグが使用されます。
そのため、実際には、Dockerfile に以下のように指定することで、Node.js の Docker ワーキンググループでビルドされた最新版の Docker イメージを常にビルドすることができます。
######node からビルド
デフォルトの node
イメージをベースにビルドする場合の欠点は以下の通りです。
- Docker イメージのビルドの一貫性が保てない。ちょうど、
npm
パッケージをインストールするときに、常に決定論的なnpm install
の動作を行うためにlockfiles
を使うように、決定論的な docker イメージビルドを得取する方法を取りたいと考えます。イメージをnodeからビルドした場合、これはnode:latest
タグがあることを意味し、ビルドするたびにnode
の新たにビルドされた Docker イメージをプルしてしまいます。このような非決定論的な動作が伴う導入は推奨できません。 - node の Docker イメージは Node.js の Web アプリケーションを実行するために必要なライブラリやツールが満載であるフルサイズのオペレーティングシステムをベースにしています。これには2つのデメリットがあります。まず、イメージが大きくなるということです。ダウンロードサイズも大きくなり、ストレージの必要性が増すだけでなく、イメージのダウンロードと再構築にも時間がかかります。次に、これらのライブラリやツールに存在するかもしれないセキュリティ上の脆弱性をイメージに取り込んでしまう可能性もあります。
実際、 node
の Docker イメージは非常に大きく、様々な種類と深刻度が入り混じった数百ものセキュリティの脆弱性を含んでいます。以下の表にもあるように、node を使っている場合、デフォルトで 642個のセキュリティ脆弱性があるイメージが出発点となり、プルやビルドのたびに何百メガバイトものイメージデータがダウンロードされることになります。
######より優れた Docker イメージを構築するための推奨事項:
- 小さな Docker イメージを使用する
これにより、Docker イメージのソフトウェアフットプリントが小さくなり、潜在的な脆弱性が減少します。また、サイズが小さくなるため、イメージの構築プロセスが高速化されます。 - イメージの静的 SHA256 ハッシュである Docker イメージダイジェストを使用する
これにより、ベースイメージから決定論的な Docker イメージビルドを確実に取得できます。
この方針に基づいて、長期サポート (LTS) バージョンの Node.js を使用し、イメージ上のサイズとソフトウェアのフットプリントが最小になるように、最小限のalpine
イメージタイプを使用するようにしましょう。
######FROM node:lts-alpine
これでもまだ、このベースイメージディレクティブは、そのタグの新しいビルドをプルします。その SHA256
ハッシュは、このNode.jsタグの Docker Hubで見つけることができます。また、このイメージをローカルに取り込んだ後、次のコマンドを実行して、出力に Digest フィールドを配置します。
$ docker pull node:lts-alpine
lts-alpine: Pulling from library/node
0a6724ff3fcd: Already exists
9383f33fa9f3: Already exists
b6ae88d676fe: Already exists
565e01e00588: Already exists
Digest: sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
Status: Downloaded newer image for node:lts-alpine
docker.io/library/node:lts-alpine
SHA256
ハッシュを見つけるための別の方法として、 次のコマンドを実行します。
$ docker images --digests
REPOSITORY TAG DIGEST IMAGE ID CREATED SIZE
node lts-alpine sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a 51d926a5599d 2 weeks ago 116MB
これで、このNode.js の Docker イメージの Dockerfile を次のように更新できます。
FROM node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
しかし、上記の Dockerfile では、イメージタグを使わずに Node.js の Docker イメージの名前だけを指定しているため、実際どのイメージタグが使われているのかが曖昧になっています。これでは可読性に欠けるため、メンテナンスが難しく、デベロッパにとって良いユーザーエクスペリエンスとはいえません。
この問題を解決するために、Dockerfile を更新し、 SHA256 ハッシュに対応する Node.js のバージョンの完全なベースイメージタグを提供しましょう。
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
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
ビルドの必要性をご理解していただけたと思います。lockfile から逸脱すると停止するため、継続的インテグレーション(CI)フローでの予期しない挙動を防ぐことができます。
本番用のDockerイメージを構築する場合は、本番用の依存関係のみを確実にインストールしたいので、コンテナイメージに npm の依存関係をインストールする際のベストプラクティスとして以下を推奨します。
######RUN npm ci --only=production
この段階で更新された Dockerfile の内容は以下の通りです。
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a 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 documentation では、パフォーマンスやセキュリティ関連の最適化を有効にするために、この環境変数を設定することの重要性が説明されています。
NODE_ENV
変数はパフォーマンスに大きく影響します。
Dynatrace のデベロッパたちが、ExpressアプリケーションでNODE_ENVを省略した場合の劇的な影響についての詳細をブログ記事で説明しています。
依存している他のライブラリの多くもこの変数が設定されていることを想定している可能性もあるため、Dockerfile にこの変数を設定する必要があります。
アップデートした Dockerfile は、NODE_ENV
環境変数の設定が組み込まれた以下のような内容になります。
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
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 の Web アプリケーションを実行する際には、常にこれに従うべきです。
脅威の評価は非常にわかりやすいものです。攻撃者が、コマンドインジェクションやディレクトリパストラバーサルを可能にする方法で web アプリケーションを侵害することができた場合、これらはアプリケーション・プロセスを所有するユーザで起動されます。そのプロセスがたまたま root であった場合、攻撃者はコンテナ内で事実上すべてのことを行うことができ、コンテナのエスケープや権限昇格を試みることも可能となります。わざわざ、そのようなリスクを冒す必要はありません。
######非常に重要:「コンテナを root として実行させないこと!」
公式の node
Dockerイメージや、alpine
のようなバリアントには、同じ名前の最小権限ユーザーである node
が含まれています。しかし、 node
としてプロセスを実行するだけでは十分ではありません。例えば、次のような場合、アプリケーションがうまく機能するためには理想的ではないかもしれません。
USER node CMD "npm" "start"
理由は、USER
Dockerfile ディレクティブは、プロセスの所有者が node
ユーザーであることを保証するだけだからです。それ以前に COPY
命令でコピーしたすべてのファイルについて考えてみましょう。それらは root が所有しています。これが Docker のデフォルトの動作です。
権限の完全かつ適切な削除方法は以下の通りで、この時点までの最新のDockerfileプラクティスについても示しています。
`FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
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 Webアプリケーションを安全に終了させる
Node.js アプリケーションを Docker コンテナで実行する際のコンテナ化に関するブログや記事でよく見かける間違いの1つは、プロセスの呼び出し方法です。以下や類似する方法は、全て避けるべき悪いパターンです。
CMD "npm" "start"
CMD ["yarn", "start"]
CMD "node" "server.js"
CMD "start-app.sh"
それでは、それぞれの違いと、なぜ全て避けるべきパターンなのかを説明します。
Node.jsのDockerアプリケーションを適切に実行・終了させるための背景を理解るためには、以下の点が重要になります。
- Docker Swarm や Kubernetes、あるいは Docker エンジン自体などのオーケストレーションエンジンには、コンテナ内のプロセスにシグナルを送る方法が必要です。ほとんどの場合、これらは SIGTERM や SIGKILL のような、アプリケーションを終了させるためのシグナルです。
- プロセスは間接的に実行される可能性があり、その場合、これらのシグナルを受け取ることは必ずしも保証されません。
- Linuxカーネルでは、プロセスID1(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プロセスにシグナルを転送していないからです。
もう1つの注意点は、Dockerfileに CMD
ディレクティブを指定する方法が異なることです。2つの方法があり、それらは同じではありません。
- シェルフォーム表記では、コンテナがプロセスをラップするシェルインタープリタを起動します。この場合、シェルはシグナルをプロセスに適切に転送しない可能性があります。
- execform 表記:シェルをラップせずに直接プロセスを起動します。次のようなJSON 配列表記で指定します。
コンテナに送られたシグナルは、そのままプロセスに送られます。例:CMD ["npm", "start"]
その知識に基づいて、Dockerfile のプロセス実行指令を次のように改善します。
######CMD ["node", "server.js"]
これで node プロセスを直接起動することになり、シェルインタプリタにラップされることなく、送られてきたすべてのシグナルを確実に受け取ることができます。
しかし、これには別の落とし穴があります。
プロセスが PID 1として実行されると、事実上、OSやプロセスの初期化を担当するinit システムの責任の一部を負うことになります。カーネルは、PID 1を他のプロセス識別子とは異なる方法で扱います。カーネルがこのように特別な扱いをするということは、実行中のプロセスに対する SIGTERM
シグナルの処理では、プロセスがまだハンドラーを設定していない場合、プロセスを停止するというデフォルトのフォールバック動作が呼び出されないことを意味します。
Node.jsのDockerワーキンググループの勧告を引用がこちらです。「Node.jsはPID 1で動作するように設計されていないため、Docker 内で動作する際に予期せぬ動作を引き起こします。例えば、PID 1として実行されているNode.js プロセスは、SIGINT (CTRL-C) や同様のシグナルに反応しません。」
このため、init プロセスのような動作をする、PID 1で呼び出され、Node.js アプリケーションを別のプロセスとして起動し、すべてのシグナルが Node.js プロセスにプロキシされるようにするツールを使用します。可能であれば、コンテナイメージにセキュリティの脆弱性が追加されないように、ツールのフットプリントをできるだけ小さくする必要があります。
Snyk では、スタティックリンクでフットプリントが小さい dumb-init を使っています。ここでは、その設定方法を紹介します。
RUN apk add dumb-init
CMD ["dumb-init", "node", "server.js"]
これで、以下のような最新の Dockerfile ができあがりました。イメージ宣言の直後にdumb-init
パッケージのインストールを配置しています。これにより、Docker のレイヤーのキャッシュを利用することができます。
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add 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 kill
や docker stop
コマンドは、PID 1のコンテナプロセスにしかシグナルを送らないということです。シェルスクリプトで Node.js アプリケーションを実行している場合、例えば /bin/sh のようなシェルインスタンスは子プロセスにシグナルを転送しないので、アプリケーションが SIGTERM を受け取ることはありません。
##6. Node.js ウェブアプリケーションの適切なシャットダウン
アプリケーションを終了させるプロセス・シグナルについてはすでに説明しましたが、ユーザーに迷惑をかけずに適切にシャットダウンしていることを確認しましょう。
Node.js アプリケーションが、SIGINT
、CTRL+C
のような割り込み信号を受信すると、別の動作で処理するようにイベントハンドラが設定されていない限り、突然の kill プロセスが行われます。これは、ウェブアプリケーションに接続されているクライアントが直ちに切断されることを意味します。ここで、Kubernetes によってオーケストレーションされた何百もの
Node.js の Web コンテナが、スケールアップやエラー管理のために必要に応じてアップダウンする様子を想像してみてください。決して良いユーザーエクスペリエンスとは言えません。
この問題を簡単にシミュレーションすることができます。ここでは、Fastify Web アプリケーションの例として、エンドポイントの応答が 60 秒遅れることを想定しています。
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() を呼び出し、待ち受けるプロミスを返すようにできます。また、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.exit()
}
process.on('SIGINT', closeGracefully)
process.on('SIGTERM', closeGracefully)
上記は Dockerfile に関連するというよりも、一般的な Web アプリケーションに関する問題ですが、オーケストレーション環境ではさらに重要になります。
##7. Node.jsのDockerイメージのセキュリティ脆弱性を見つけて修正する
Node.js アプリケーションのための小さな Docker ベースイメージの重要性について説明しました。このテストを実践してみましょう。
Snyk CLI を使ってDockerイメージをテストしてみようと思います。Snykの無料アカウントは こちらからサインアップできます。
$ npm install -g snyk
$ snyk auth
$ snyk container test node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a --file=Dockerfile
最初のコマンドでSnyk CLIをインストールし、続いてAPI キーを取得するためにコマンドラインから簡単なサインインフローを行い、セキュリティ上の問題がないかコンテナをテストします。以下はその結果です。
Organization: snyk-demo-567
Package manager: apk
Target file: Dockerfile
Project name: docker-image|node
Docker image: node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
Platform: linux/amd64
Base image: node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
✓ Tested 16 dependencies for known issues, no vulnerable paths found.
Snyk は、当社の Node.js ランタイム実行ファイルを含む 16 のオペレーティングシステムの依存関係を検出し、脆弱性のあるバージョンは見つかりませんでした。
では、以下のような FROM node
ベースイメージディレクティブを使っていたらどうなっていたでしょうか?
######FROM node:14.2.0-slim
Node.js のバージョンを明確にし、 slim
タイプのイメージを使用しているので、Docker イメージ内の依存関係のフットプリントが小さくなっています。では、Snyk でテストしてみましょう。
…
✗ 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 ランタイムバージョンであれば、安全であるように見えますが、Snyk は2つの主要なソースからセキュリティの脆弱性を見つけました。
1.Node.js ランタイム自体の脆弱性 — Snyk のレポートには 2つの主要なセキュリティの脆弱性が表示されています。これらは、Node.js ランタイムについての既知のセキュリティの問題で、Node.js バージョンをアップグレードすることで即時に修正可能です。Snyk はレポートで修正されたバージョン (14.11.0) についても通知しています。
2.glibc、bzip2、gcc、perl、bash、tar、libcrypt など、この debian ベースイメージにインストールされているツールやライブラリなどです。コンテナ内のこれらの脆弱なバージョンはすぐには脅威にならないかもしれませんが、使わないのに持っている理由を考える必要があります。
この Snyk CLI レポートの一番いいところは、他のベースイメージに切り替えることも Snyk が推奨してくれるところです。代替のイメージを探すのは非常に時間がかかる可能性がありますが、Snyk はその手間を省いてくれます。
ここでの私の推奨は以下の通りです。
1.Docker Hub や Artifactory などのレジストリで Docker イメージを管理している場合は、それらを Snyk にインポートして、プラットフォームがこれらの脆弱性を検出できるようにします。これにより、新たに発見されたセキュリティ脆弱性について継続的に Docker イメージを監視するだけでなく、Snyk の UI で推奨アドバイスを提供することもできます。
2.CI の自動化に Snyk CLI を使いましょう。非常に柔軟性の高い Snyk CLI は、任意のカスタムワークフローに適用することができます。お好みに応じて Snyk for GitHub Actionsも用意しています。
##8. マルチステージ・ビルドの使用
マルチステージビルドは、シンプルでありながらエラーが発生する可能性のある Dockerfile から、Docker イメージを構築するステップを分離して、機密情報の漏洩を防ぐことができる優れた方法です。それだけでなく、より大きな Docker ベースイメージを使って依存関係をインストールし、必要であればネイティブの npm パッケージをコンパイルし、alpine の例のように、全てのアーティファクトを小さな本番ベースのイメージにコピーすることができます。
######機密情報漏洩の防止
ここで紹介する機密情報の漏洩回避のユースケースは、思ったよりも一般的です。
もしあなたが仕事でDockerイメージを構築しているなら、プライベートな npm パッケージも管理している可能性が高いでしょう。もしそうであれば、おそらくその秘密のNPM_TOKEN
を npm のインストール時に利用できるようにするための何らかの方法を見つける必要があるでしょう。
こちらがその例です。
FROM node:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add 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
しかし、今度は .npmrc
ファイルが Docker イメージの別のレイヤーで利用できるようになりました。もし、このDocker イメージが公開されていたり、誰かが何らかの方法でアクセスできてしまうと、あなたのトークンは危険にさらされます。より良い改善策は以下のようになります。
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
npm ci --only=production; \
rm -rf .npmrc
ここで問題となるのは、Dockerfile 自体が秘密の 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
<missing> About a minute ago RUN /bin/sh -c apk add dumb-init # buildkit 1.65MB buildkit.dockerfile.v0
そこにシークレットのnpmトークンがあるのがわかりましたか?そこが問題です。
コンテナイメージのシークレットを管理するための優れた方法はありますが、今回はこの問題の緩和策としてマルチステージビルドを導入するとともに、ミニマムイメージを構築する方法を紹介します。
######Node.jsのDockerイメージへのマルチステージビルドの導入
ソフトウェア開発における「関心事の分離」の原則と同じように、Node.js の Docker イメージを構築するためにも同じ考え方を適用します。Node.js の世界では、npm パッケージをインストールし、必要に応じてネイティブの npm モジュールをコンパイルします。これが最初のステージとなります。
Docker ビルドの第2ステージを表す2つ目のDocker イメージは、本番用のDockerイメージになります。この2番目で最後のステージは、実際に最適化してレジストリがあればそこに公開するイメージです。 build
イメージと呼ばれる最初のイメージは廃棄され、クリーンアップされるまでビルドしたDocker ホストにぶら下がったままのイメージとして残されます。
これまでの進捗状況を表す Dockerfile の更新は次のとおりですが、2つのステージに分かれています。
# --------------> The build image
FROM node:latest AS build
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:lts-alpine@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
RUN apk add dumb-init
ENV NODE_ENV production
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 には .dockerignore
があり、これはDocker デーモンへの glob パターンマッチの送信をスキップするようになっています。ここでは、Docker イメージに何を入れているのか、理想的には避けたいファイルのリストを紹介します。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
ご覧のとおり、 node_modules/
をスキップすることは実際には非常に重要です。なぜなら、もしこれを無視していなかったら、最初に行った単純な Dockerfile バージョンでは、ローカルの node_modules/
フォルダがそのままコンテナにコピーされてしまうからです。
FROM node@sha256:b2da3316acdc2bec442190a1fe10dc094e7ba4121d029cb32075ff59bb27390a
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"
実際、複数ステージの Docker ビルドを実践する際には、 .dockerignore
ファイルを用意することがさらに重要になります。第2ステージの Docker ビルドがどのようなものか、以下おさらいとして。
# --------------> The production image
FROM node:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
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
ファイルの要点は以下の通りです。
1.Docker イメージ内の node_modules/
の潜在的に変更されたコピーをスキップします。
2..env
や aws.json
ファイルの内容に含まれる認証情報が Node.js の Docker イメージに入ってしまうようなシークレットの暴露を防止します。
キャッシュ無効化の原因となるようなファイルを無視するため、Docker ビルドの高速化に貢献します。例えば、ログファイルが変更されたり、ローカル環境の設定ファイルが変更されたりすると、ローカルディレクトリをコピーした段階で、Docker イメージのキャッシュが無効になります。
##10. シークレットをDockerビルドイメージにマウントする
.dockerignore
ファイルについて注意すべき点は、オール・オア・ナッシングのアプローチしかできず、Docker マルチステージビルドにおいてビルドステージごとにオン/オフを切り替えることはできないということです。
なぜそれが重要になるのでしょうか? 理想的には、ビルド段階で .npmrc
ファイルを使用することが推奨されます。これは、プライベートな npm パッケージにアクセスするための秘密の npm トークンが含まれているため、必要になる場合があるためです。また、パッケージを取得するために、特定のプロキシやレジストリの設定が必要になることもあります。
つまり、 build
ステージで .npmrc
ファイルを利用できるようにしておくことは理にかなっていますが、本番イメージのための第2ステージでは全く必要ありませんし、シークレット npm トークンなどの機密情報が含まれている可能性があるため利用したくありません。
この .dockerignore
の注意点を軽減する一つの方法は、ビルドステージで利用可能なローカルファイルシステムをマウントすることですが、もっと良い方法があります。
Docker は Docker secrets と呼ばれる比較的新しい機能をサポートしており、.npmrc
で必要とされるケースに適しています。その仕組みを紹介します。
-
docker build
コマンドを実行する際には、新しいシークレット ID を定義し、シークレットのソースとなるファイルを参照するコマンドライン引数を指定します。 - Dockerfileでは、
RUN
ディレクティブにフラグを追加して、本番 npm をインストールします。この npm は、シークレットIDで参照されたファイルを、目的の場所(ローカルディレクトリの.npmrc ファイル
)にマウントします。 -
.npmrc
ファイルはシークレットとしてマウントされ、Docker イメージにコピーされることはありません。 - 最後に、
.npmrc
ファイルを.dockerignore
ファイルの内容に追加することを忘れないでください。そうすれば、ビルドイメージでも本番イメージでも、イメージには一切組み込まれなくなります。
これらがどのように機能するか見てみましょう。まず、更新された .dockerignore
ファイルです。
.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc
次に、完全な Dockerfile と、 .npmrc
マウントポイントを指定しながら npm パッケージをインストールするために更新された RUN ディレクティブです。
# --------------> The build image
FROM node:latest AS build
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:lts-alpine
RUN apk add dumb-init
ENV NODE_ENV production
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
注:Secrets は 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 イメージを確実に構築することができます。
フォローアップの資料をぜひご覧ください。
10 Docker image security best practices→参考翻訳記事
Docker for Java Developers: 5 things you need to know not to fail your security→参考翻訳記事
Node.js アプリケーション用に安全でパフォーマンスの高い Docker ベースイメージを構築したら、Snyk の無料アカウントでコンテナの脆弱性を見つけて修正しましょう。
######最後まで、読んでいただきありがとうございました!!!
Contents provided by:
Jesse Casman, Fumiko Doi, Content Strategists for Snyk, Japan, and Randell Degges, Community Manager for Snyk Global