Node.jsコンテナイメージを極限まで軽量化! サイズを1/10以下に
はじめに
SHIFT DAAE の shinagawa です。表題の通りNode.jsで作成したコンテナのイメージサイズの軽量化に挑戦しました。
背景
近年の多様化・高速化するビジネスに対応するITシステムの構築を実現する「クラウドネイティブ」の構成要素の一つとして 「コンテナ」という仮想化技術が存在し、当部門でも活用を進めております。
このコンテナイメージを作成するにはアプリケーションコードやライブラリ・モジュールなどの依存物、ランタイム等を1つのイメージとして組み立てて作成しますが、 この構成要素が増えるとイメージサイズが肥大化し保管時のストレージのコストの増加やイメージの転送、環境への展開に時間がかかることになります。 従ってイメージのサイズを削減することは、これらの点を改善することにつながります。
ここではネット上で紹介されている、あらゆる打ち手を組み合わせてコンテナイメージの軽量化に挑戦しました。
注意
本記事で紹介している実装やサンプルコードは本来行うべきセキュリティ対策等は省いており、 プロダクション環境で利用するには相応しく無いものが含まれるため参考にされる際にはご注意ください。
試した環境
以下の環境で検証を行いました。
Node.js 18.12.0
Docker version 20.10.21, build baeda1f
Express 4.18.2
webpack 5.75.0
pkg 5.8.0
改善対象のアプリケーション
まずは 改善対象のアプリケーションとして、nodejs.org で紹介されている「Node.js Web アプリケーションを Docker 化する」を試してみます。
このサンプルではExpressを利用したAPIを作成しており、複数のモジュールを利用しています。
作業環境の初期化
まずは以下のコマンドで作業環境を作成していきます。
$ mkdir app
$ cd app
$ npm init -y
$ npm install express
$ touch app.js
$ touch Dockerfile
$ touch .dockerignore
app.js
// app.js
"use strict";
const express = require("express");
const PORT = 8080;
const HOST = "0.0.0.0";
const app = express();
app.get("/", (req, res) => {
res.send("Hello World");
});
app.listen(PORT, HOST, () => {
console.log(`Running on http://${HOST}:${PORT}`);
});
Dockerfile
# Dockerfile
FROM node:18
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install --only=production
COPY . .
EXPOSE 8080
CMD [ "node", "app.js" ]
.dockerignore
# .dockerignore
node_modules
npm-debug.log
これで作業環境の初期化は完成です。
サイズの計測
この時点でのnode_modulesを含んだソースコードのサイズを見てみます。
$ du -sk
3128 .
3128KBありました。
$ docker build . -t foo/node-web-app
$ docker images foo/node-web-app
REPOSITORY TAG IMAGE ID CREATED SIZE
foo/node-web-app latest 0a77752ba5d3 48 seconds ago 1.02GB
ビルドしたdockerイメージは1.02GBありました。 ここから、あの手この手でゴリゴリ削っていきます。
検証1: モジュールバンドラの導入
モジュールバンドラとは複数のJSファイルを1つのファイルに束ねることでできるツールで、 WebページのHTTPリクエスト数を減らす目的で利用されたりします。
今回の検証では、webpackとesbuildを試します。
webpack
以下のコマンドで既存プロジェクトにwebpackを追加します。
$ npm install -g webpack
$ npm install webpack webpack-cli --save-dev
$ touch webpack.config.js
webpackの設定を定義します。
webpack.config.js
// webpack.config.js
const path = require("path");
module.exports = {
mode: "production",
entry: "./app.js",
output: {
path: path.join(__dirname, "dist"),
publicPath: "/",
filename: "app.js",
},
target: "node",
};
以下のコマンドでバンドル化します。
$ npx webpack
$ du -sk dist/app.js
584 dist/app.js
3128KBから584KBになりました。
Dockerfileもwebpackに対応します。
Dockerfile
# Dockerfile
FROM node:18
WORKDIR /usr/src/app
COPY package*.json ./
COPY ./*.js ./
RUN npm install &&\
npx webpack &&\
ls|egrep -v '^dist$'|xargs rm -r
EXPOSE 8080
CMD [ "node", "dist/app.js" ]
$ docker images foo/node-web-app
REPOSITORY TAG IMAGE ID CREATED SIZE
foo/node-web-app latest fc81d34e4919 22 seconds ago 949MB
こちらも、50MB程度削減できました。
esbuild
esbuildはGo言語製のバンドルツールでwebpackと比べて速くビルドできると言われています。
今回のアプリケーションの構成では次のようにビルドを行いました。
ビルドするコマンド
$ npx esbuild app.js --bundle --platform=node --minify --outfile=./dist/app.js
バンドル結果
$ du -sk dist/app.js
800 dist/app.js
高速でビルドできる点は大変満足でしたが、 ビルドした生成物のサイズ自体はwebpackに及ばず今回の目的には沿わないため webpackを採用して進めることにしました。
検証2: ベースイメージの変更
DockerHubで公開されているイメージを利用する
DockerHubで公開されているNode.jsのオフィシャルのコンテナイメージは、 開発に必要な様々なパッケージを含んだものから最小限まで余分なものが省かれたものまで、 いくつかのバリエーションがあります。
ここでは検証1の手順を継承してベースイメージの変更を行い比べてみました。 そして結果は以下の通りとなりました。
node:18 949MB
node:18-buster 915MB
node:18-bullseye-slim 242MB
node:18-slim 242MB
node:18-alpine 172MB
結果としてalpineベースが最も軽量となりましたが、 Top 4 Tactics To Keep Node.js Rockin’ in Docker - Docker やNode.js Rocks in Docker, DockerCon 2022 Edition - YouTube で指摘されているような問題点があり特にプロダクション環境での利用は注意が必要だと思われます。
そんな中で distrolessと呼ばれるGoogleが提供しているイメージを利用しているケースを見つけたのでチャレンジしてみることにしました。
distrolessを利用する
distrolessは、アプリケーションとそのランタイムの依存関係のみで構成されていることが特徴的な軽量イメージです。
なのでshell環境等も存在しません。
2022年11月時点では次のイメージが利用できます。
$ docker images | grep gcr.io/distroless
gcr.io/distroless/static latest 1fa3b6b7eabc 52 years ago 2.34MB
gcr.io/distroless/base latest 4786dda84dd9 52 years ago 17.3MB
gcr.io/distroless/cc latest fd978f0dc9db 52 years ago 19.6MB
この中でも static が最も軽量なコンテナで、手元の環境だと2.34MBでした。
次いで、サイズが小さい base はstatic に加え glibc、libssl、openssl が含まれるものであり、 この base イメージに libgcc1を加えたものが cc になります。
それ以外のイメージは各ランタイム用のイメージとなります。
ここではNode.js用コンテナを利用してみることにしました。
まずはgcr.io/distroless/nodejs:18を利用するようにDockerfileを調整していきます。
Dockerfile
# Dockerfile
FROM node:18-slim as builder
WORKDIR /app/app
COPY package*.json ./
COPY ./*.js ./
RUN npm install && npx webpack
FROM gcr.io/distroless/nodejs:18
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/dist/app.js ./app.js
USER nonroot
EXPOSE 8080
CMD [ "app.js" ]
この中で、しれっとマルチステージビルド(FROMを2つ定義する方法)を利用しています。 これはビルド用と成果物用のコンテナに分けて、ビルド用コンテナでビルドしたものを最終的に成果物用コンテナに移すことで、 RUNコマンド内を && で結合するような複雑な手続きを行わず、簡潔な方法で小さなイメージを作ることができます。
$ docker images foo/node-web-app
REPOSITORY TAG IMAGE ID CREATED SIZE
foo/node-web-app latest 8799d9c4949a 11 seconds ago 157MB
157MBまで小さくすることができました。
ここまでアプリケーションコード部分の小手先のテクニックでやれそうなことはやってみましたが、これだけでは満足できず、 どうせなら100MBを切りたいと思い他に削るところが無いか考えてみました。
そこで注目したのは、Node.jsのようなスクリプト言語の場合ランタイム環境のサイズが大きく、 これを削ることができれば小さくできるのでは無いかと思い、この部分の削減を考えてみました。
その結果、シングルバイナリ化する方法(実行可能な単一のファイルを生成する方法)が活用できるのでは無いかと思い挑戦してみることにしました。
検証3: シングルバイナリ化
Node.jsのシングルバイナリ化の方法はいくつかあります。
例えば boxednode vs enclose vs js2bin vs nar vs nexe vs pkg | npm trends で挙げられているような vercel/pkg や nexe 、 boxednode を利用する方法があります。
また、src: add initial support for single executable applications by RaisinTen · Pull Request #45038 · nodejs/node のようなNode.js同梱の機能として提供される話も進んでおり、将来的に標準で利用できるようになるかもしれません。 (興味がある人は自分でソースビルドしてみてください)
加えて、今回の検証の対象外ではありますが、Deno と呼ばれる近年注目されているランタイム環境では、 準備段階としての位置付けではあるもののcompileオプションをサポートしており、サードパーティなパッケージを導入することなくシングルバイナリを生成することができます。
その中でも今回はインストール実績や開発体制からある程度信頼性があり、また当方の環境(M1 Mac)でもビルドできるものとしてvercel/pkgを利用してみることにしました。
まずはDockerfileを以下のように調整します。
Dockerfile
# Dockerfile
FROM node:18-slim as builder
# note: 実行環境に合わせて調整する
# see https://github.com/vercel/pkg
# x64アーキテクチャの場合: node18-linux-x64
# arm64アーキテクチャの場合: node18-linux-arm64
ARG PKG_PLATFORM node18-linux-arm64
WORKDIR /app
COPY package*.json ./
COPY ./*.js ./
RUN npm install -g pkg
RUN npm install && npx webpack
RUN pkg dist/app.js -t ${PKG_PLATFORM}
FROM gcr.io/distroless/cc
ENV NODE_ENV production
WORKDIR /app
COPY --from=builder /app/app /app/app
EXPOSE 8080
CMD [ "/app/app" ]
アプローチとしては検証1と同様にアプリケーションコードのバンドル化を最初に行います。 バンドル化したファイルをpkgでランタイム付属のバイナリ化を行います。
ちなみにこの時のファイルサイズは46MBとなっていました。
$ npx pkg dist/app.js -t node18-linux-arm64
$ du -sk ./app
46148 app
ベースイメージにはdistroless/ccを利用しています。 staticやbaseイメージでも試しましたが動かすことができず、libgccと依存関係があると考えられたからです。
ビルドした結果、最終的に以下のような状態となりました。
$ docker images foo/node-web-app
REPOSITORY TAG IMAGE ID CREATED SIZE
foo/node-web-app latest a9396f76ae84 19 minutes ago 65.2MB
65.2MBまで削ることができました。
ここまで小さくなると「本当に挙動するの?」と疑ってしまいましたが、一応レスポンスを返していることは確認できました。
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
6845f65cbdfc foo/node-web-app "/app/app" 5 seconds ago Up 4 seconds 0.0.0.0:8080->8080/tcp pedantic_bose
$ curl http://localhost:8080
Hello World
まとめ
本記事では、Node.jsの公式サイトで紹介されている構築のサンプルに対して、 あらゆる方法を組み合わせてコンテナイメージの削減を行いました。
削減の手段として、 distrolessと呼ばれる軽量化イメージを利用し、 アプリケーションコードに対してはバンドル化・シングルバイナリ化を適用しました。 また、余分なレイヤーを増やさないようにマルチステージビルドといった基本的な対策も適用しました。
これにより当初は1GBを超えていたイメージサイズを65MBまで削減することができました。
参考にさせていただいたサイト
さて、明日の「SHIFTアドベンドカレンダー」は
どんな記事が公開されるでしょうか?お楽しみに!
\もっと身近にもっとリアルに!DAAE公式Twitter/
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/