📦

Node.js Docker baseイメージには alpine < distroless < ubuntu+slim 構成がよさそう

2022/06/14に公開1

はじめに

この記事は、DockerCon 2022 で発表された Bret Fisher の "Node.js Rocks in Docker, DockerCon 2022 Edition" を参考にしています。
base イメージの選択肢に関する話は、動画の前半一部分だけですが、他にも Node.js で Dockerfile を書く時のベストプラクティスが数多くまとまっているので、是非チェックしてみてください。

node:alpine イメージを使わない

base イメージサイズを小さく保ちたい、という点で気軽に利用される事が多い alpine イメージですが、Official の README には下記の記載があります。

This variant is useful when final image size being as small as possible is your primary concern. The main caveat to note is that it does use musl libc instead of glibc and friends, so software will often run into issues depending on the depth of their libc requirements/assumptions.

Alpine Linux では標準 C ライブラリとして glibc の代わりに musl libc が使われています。

https://wiki.musl-libc.org/functional-differences-from-glibc.html

上記のように、それぞれで異なる挙動となる仕様が存在していて、多くの場合あまり問題になることはないかもしれませんが、クリティカルなプロダクション用途の場合には思わぬバグの元とならないよう、注意が必要です。

また、musl libc は Node.js がサポートする libc バージョンにおいて Experimental サポートに留まっていることにも注意が必要です (Tier 1 ではない)。

https://github.com/nodejs/node/blob/master/BUILDING.md#platform-list

他に、Alpine Linux の問題として、インストールされるパッケージの pinning が難しいという点があります。

https://gitlab.alpinelinux.org/alpine/abuild/-/issues/9996

上記の Open issue で Maintaner から返信されているように、

We don't at the moment have resources to store all built packages indefinitely in our infra. Thus we currently keep only the latest for each stable branch, and has always been like that.

とされていて、つまり過去のパッケージはあるタイミングでリポジトリから削除され、パッケージマネージャーから見えなくなってしまいます。これではパッケージの (exact) pinning を行っている場合、将来のビルドで同じパッケージのインストールに成功する保証がない事になります。再現性が重要視される Docker ビルドにおいて、これは少々ネックとなってしまいます (Deterministic Builds)。

最大の売りであるイメージサイズについても、近年では他の人気イメージ (後述) に比べてあまり大きな差がなく、上記で説明したような内容も踏まえると、node:alpine は基本的に使うのを避ける、というのがベストプラクティスとなってきています。

node:<version>node:latest イメージを使わない

当然ですが、これらのイメージには、通常必要としないであろう大量の追加パッケージが含まれており、イメージサイズも約1GBほどに膨れ上がっています。もちろんその分、イメージが含んでいる脆弱性の数も増えてしまいます。
Node.js で Hello World するには問題ないでしょうが、通常これらをプロダクション環境で利用する機会はほとんどないでしょう。

選択肢 1. slim 系イメージ (手軽)

Debian ベースの軽量イメージです。名前の通り、無駄なパッケージが極力省かれています。
node:16 までは、slim == Debian 10 (buster) へのエイリアスとなっています。
よりセキュアなイメージを使うには、bullseye-slim イメージを使うほうがよいでしょう。こちらは最新の Debian 11 (bullseye) がベースになっていて、含まれる脆弱性の数もより少なくなっています。

node:18 からは slim == Debian 11 (bullseye) となっているようです。

選択肢 2. distroless イメージ (上級者向け)

Google が公開している超軽量イメージです。
パッケージマネージャーや Shell さえも使うことができませんが、その代わりとしてイメージに含まれるファイル数が極限まで削られており、Node.js のイメージでは、slim 系のイメージがおよそ~20万ファイルを含むのに対し、distroless では2000ファイル程度と、およそ100分の1です。

このため、イメージサイズだけでなく、セキュリティ面でも不要なリスクを減らす事ができるとして、近年人気が高まっています。

ただしデメリットとして、distroless を利用した Dockerfile を書くのは通常よりも少しコツが求められます。distroless は通常、multi-stage ビルドの中で最後の stage として新たな base イメージとして利用されると思います。
そのため、COPY によって distroless の中にそれまでの stage で得た必要ファイルを置くのはもちろん、ENV, WORKDIR, ENTRYPOINT, USER など、他のオプションも一通りこの stage の中で指定し直すことになると思います。標準イメージでは Shell も使えないため (debug イメージを使えば可能)、デバッグにも一手間かかります。
この辺りを考慮すると、distroless はやや上級者向けの選択肢となるかもしれません。

また、distroless の Docker イメージは、Node.js の minor/patch バージョンを指定することができず、メジャーバージョンでしかタグを指定できません。イメージの sha256 ハッシュを調べて Dockerfile にハードコードしておけば対策はできますが、バージョン管理がやや面倒になってしまいます。ある程度マニュアルで追跡、記録を残しておかないと、過去に遡ってトラシュが必要になった時に、ハッシュだけを頼りに調べるのがしんどそうです。地味に辛そう。

base となっている Debian のバージョンを明確にするためも、distroless イメージを指定する時には、Debian バージョンを明確に含めたほうがよいかもしれません。

  • gcr.io/distroless/nodejs-debian10:16
  • gcr.io/distroless/nodejs-debian11:16
  • ...etc

参考: distroless を利用した Dockerfile サンプル

選択肢 3. ubuntu + slim イメージ (バランス ◎)

distroless が人気の一方、利便性とイメージサイズ、セキュリティのバランスを考えると、multi-stage ビルドを活用して ubuntu + slim をベースに、それぞれのいいとこ取りをしたイメージ作成も捨てがたいです。

うまく組み合わせることで、slim よりも小さいイメージサイズ、かつセキュアなイメージを作ることができます。

Bret は動画の中で、彼自身 distroless よりもこちらの方法を通常好むと述べています。
理由としては先に挙げた、distroless における Node.js の minor/patch バージョン指定ができない問題や、distroless を正しく運用するために必要なナレッジ、そしてセキュリティ上の優位性が ubuntu + slim 構成と比べて大きく変わらない点などを挙げています (両者に対する CVE スキャンなどの結果)。また、critical な脆弱性が見つかった際の対応の速さは、歴史的に見ると Debian よりも Ubuntu の方が早い事が多く、Ubuntu を base イメージとすることをセキュリティ上の大きなデメリットとは考えない、という点にも言及しています。

Ubuntu を base イメージとして利用するとして、当然 Node.js をインストールしないといけません。ただし、apt installnodejs をインストールしてしまうと、おそらくかなり古い Node.js バージョンがインストールされてしまうので、使えません。

NodeSource のようなリポジトリを追加して最新の Node.js バージョンをインストールすることも可能ですが、この方法のデメリットとしては Node.js のインストールにわざわざ Python3 (minimal) が必要になってしまうこと、そして Alpine の話と少し重複しますが、NodeSource も同様に古いバージョンはリポジトリから削除していくため、Dockerfile 内で Node.js バージョンを pinning していたとしても、将来的に利用できなくなる可能性、などがあります。

そこで、multi-stage ビルドを使って Node.js の実行環境だけ node:*-slim 系イメージから拝借してくる、という手段を取ることができます。

下記はその例ですが、COPY --from=node によって node:18.3.0-slim に既に含まれているバイナリ類を ubuntu の base イメージにそのままコピーしています。

#### base ####
# cache our node version for installing later
FROM node:18.3.0-slim as node
FROM ubuntu:focal-20220531 as base
ENV NODE_ENV=production
# Avoid running nodejs process as PID 1 (use tini)
# You may also need development tools to build native npm addons:
# apt-get install gcc g++ make
RUN apt-get update \
    && apt-get -qq install -y --no-install-recommends \
    tini \
    && rm -rf /var/lib/apt/lists/*
EXPOSE 3000

# new way to get node, let's copy in the specific version we want from a docker image
# this avoids depdency package installs (python3) that the deb package requires
COPY --from=node /usr/local/include/ /usr/local/include/
COPY --from=node /usr/local/lib/ /usr/local/lib/
COPY --from=node /usr/local/bin/ /usr/local/bin/
# reset symlinks
RUN corepack disable && corepack enable

# create node user and group, then create app dir
RUN groupadd --gid 1000 node \
    && useradd --uid 1000 --gid node --shell /bin/bash --create-home node \
    && mkdir /app \
    && chown -R node:node /app

WORKDIR /app
USER node
COPY --chown=node:node package*.json yarn*.lock ./
RUN npm ci --only=production && npm cache clean --force

#### dev ####
# no source to be added, and assumes bind mount
FROM base as dev
ENV NODE_ENV=development
ENV PATH=/app/node_modules/.bin:$PATH
RUN npm install --only=development && npm cache clean --force
CMD ["nodemon", "index.js"]

#### source ####
FROM base as source
COPY --chown=node:node . .

#### test (as needed) ####
# FROM source as test
# ENV NODE_ENV=development
# ENV PATH=/app/node_modules/.bin:$PATH
# COPY --from=dev /app/node_modules /app/node_modules
# RUN npx eslint .
# RUN npm test
# CMD ["npm", "run", "test"]

#### prod ####
FROM source as prod
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["node", "index.js"]

その他

上記の Dockerfile の例はその他にも紹介されていたセキュリティプラクティスなどを適用しているため少々長くなっています。主なものとしては、

  • multi-stage ビルドの活用 (dev / prod と必要に応じて target を分けられる)
  • USER を明示的に指定して、root 以外のユーザーで実行する。その際には必要なファイルに対して chown して、それらのパーミッションも実行ユーザーに合わせておく。
  • NODE_ENV 環境変数を stage によって明示的に指定する。
  • tini を使って node プロセスを起動する。参考: Node.jsをDockerで動かすときのPID 1問題について動作確認する
  • パッケージのインストールには npm ci --only=production を使う。

などなど。

Discussion

inductorinductor

--mount=type=cache の活用もプラスで言及されているとよさそうです。

https://zenn.dev/kou64yama/articles/powerful-docker-build-cache

また、GitHub Actionsの場合は docker/build-push-action に cache-to/cache-from で type=gha が指定できるので、組み合わせることで余計なレイヤをイメージからは消しつつCIの速度が爆速になります。他のCIの場合でもオブジェクトストレージの活用がBuildKit 0.11からできるようになっているので、やりやすくなっていると思います。

https://docs.docker.com/build/cache/backends/gha/