フューチャーアーキテクトアドベントカレンダーに投稿したサーバーサイドレンダリングの代替としてPrerenderを試してみたに引き続き、JS系?ウェブ系?なエントリーです。

ECSとかEKSとか出てきて、コンテナを使うと、一つの物理ホストで、複数のコンテナをさばいて効率を上げる、というのが簡単にできるようになってきました。そのため、Node.jsのアプリもDocker化して配りたいですよね?

次のスライドを見ると、サイズが小さいほうが良いとされています。中には静的リンクが云々みたいなトリッキーな技もありますが、そこまでがんばらない&黒魔術にならない程度でがんばる方向でサイズを小さくしてみたいと思います。

STEP1: Alpine + 標準ライブラリのみ

小さいというAlpine Linuxを使ってみます。クールなスクリプトを実行してみます。

index.js
console.log("🆒");

package.jsonのscriptsのstartにエントリポイントを書いて、npm runすればOKですね。

package.json
{
  "scripts": {
    "start": "node index.js"
  }
}

Dockerfileはこんな感じです。

Dockerfile
FROM alpine:latest

RUN apk add --no-cache nodejs
ADD package.json .
ADD index.js .
CMD npm run start

実行するとCOOLと表示します。


$ docker run --rm -it adbbd3a8b5db

> node@1.0.0 start /
> node index.js

🆒

この状態でdocker build .してから、docker imagesで確認すると49MBでした。

STEP2: トランスパイラしたい

標準ライブラリだけじゃあれですよね。npm installもしたいし、flowなりTypeScriptなりBabelなりを使いたいという需要もありますよね。僕はありませんが。

BabelでES6 modulesを使ったコードをビルドしてみます。コードを2つにわけます。

src/main.js
import { cool } from './sub';

cool();
src/sub.js
export function cool() {
  console.log("🆒");
}

設定ファイルがいらないBrowserifyを使ってみました。uglifyify以外にtinyifyを使ってもいいかもしれませんね。これは単なるサンプルなので、WebPackを使っても、トランスパイラが別のTypeScriptだったりしても問題はありません。

package.json
{
  "scripts": {
    "build": "browserify src/main.js -o index.js -t babelify -g uglifyify",
    "start": "node index.js"
  },
  "devDependencies": {
    "babel-core": "^6.26.0",
    "babel-preset-env": "^1.6.1",
    "babelify": "^8.0.0",
    "browserify": "^14.5.0"
  }
}

Babelの設定ファイルも忘れずに。

.babelrc
{
  "presets": [
    ["env", {
      "targets": {
        "node": "current"
      }
    }]
  ]
}

Dockerfileはやや複雑です。

Dockerfile
FROM alpine:latest

RUN apk add --no-cache nodejs
COPY package.json .
COPY .babelrc .
COPY src/ src/
RUN npm set progress=false && \
    npm config set depth 0 && \
    npm install && \
    npm run build
CMD npm run start

これでイメージを作ると・・・75.2MBになってしまいました。node_modules以下が25MBもあります。最近のNode.jsのモジュールはファイルサイズも大きいし、依存の数も多いし、容量がすぐ膨れてしまいます。

STEP3: マルチステージビルド

最近のDockerで使いやすくなったというマルチステージビルドを試してみます。

マルチステージビルドというのは、その名の通り、ビルドを多段階で行うものです。中間ファイル作成と、最終的な実行のみのイメージを分割して作成することで、余計なものを省くことができます。npm uninstallでbrowserifyとかを消しまくってもいいかもしれませんが、こちらの方が必要なファイルのみをホワイトリスト的にピックアップできるので行儀が良いでしょう。

今回はDockerfileのみの更新です。

Dockerfile
FROM alpine:latest as builder

RUN apk add --no-cache nodejs
ADD package.json .
ADD .babelrc .
ADD src/ src/
RUN npm set progress=false && \
    npm config set depth 0 && \
    npm install && \
    npm run build

FROM alpine:latest as runner

RUN apk add --no-cache nodejs
ADD package.json .
COPY --from=builder /index.js /index.js
CMD npm run start

FROMがふたつあるのがポイントで、それぞれに名前をつけておけます。builderの方でBrowserifyを使ったビルドを行い、成果物のファイルであるindex.jsのみを持ってきています。より複雑なビルドだと成果物がディレクトリ単位でできるかもしれませんが、それをまるごと持ってくるのもできます。

この状態では、builderの方のイメージは先程と同じ75MBですが、新しい方は49MBで、最初のイメージと同じサイズにまで縮小できました。

STEP4: 余計なファイルを消す

さてさて。Alpineのイメージは2MBぐらいと言われています。49MBでも結構大きく感じます。僕が最初に触ったパソコンにインストールされていたWindows 3.1の1.5倍です。OS一本よりも大きい。docker run -it イメージ shで中を探ってみます。

/usr/bin/nodeは20MBあります。結構でかいです。とうのも、ライブラリとか全部封入されているバイナリになっていたからだったと思います。で、のこりの30MBはどこにあるんでしょうか?もうちょっと探ってみると、/usr/lib/node_modules/npmが30MBあることがわかりました。

実行時にnpmを使っていますが、それをやめてnpmをアンインストールしてみます。
後半の部分だけ改変しています。npmで自殺できるというのは趣き深いですね。

Dockerfile
FROM alpine:latest as runner

RUN apk add --no-cache nodejs && \
    npm uninstall -g npm
ADD package.json .
COPY --from=builder /index.js /index.js
CMD node index.js

これで41.5MBで少しだけ小さくなりました。もう少し、簡単にできる方法でサイズ削減のアイディアがあったら教えてください。

落穂広い

node:alpineイメージとはなんなのか

今回は素のAlpineにパッケージマネージャのapkでNode.jsを入れましたが、NodeでAlpineというと、公式のnode:alpineもあります。最後のSTEP4をこれで作ると、77.4MBにもなります(apk add nodeは消します)。これとの違いを見比べてみます。

一旦入れて削除・・・の意味はよくわからないです。あとでもう少し詳細に見てみますかね。

ビルド不要のネイティブパッケージ

Pure JSならだいたいこんなものな気がしますが、問題はネイティブパッケージです。バイナリで有名なnode-sassをインストールしてみましょう。

> node-sass@4.7.2 install /node_modules/node-sass
> node scripts/install.js

Downloading binary from https://github.com/sass/node-sass/releases/download/v4.7.2/linux_musl-x64-57_binding.node
Download complete
Binary saved to /node_modules/node-sass/vendor/linux_musl-x64-57/binding.node
Caching binary to /root/.npm/node-sass/4.7.2/linux_musl-x64-57_binding.node

このメッセージを見れば分かるように、node-sassはバイナリをダウンロードしてくるのでビルドしないんですよね。

Browserifyはバイナリのモジュールを参照しようとするとエラーになってしまったりしますので、さっきのサンプルそのままじゃダメです。それは別途なんとか解決するとして、この手のパッケージはnpm installで簡単に入ります。--saveオプションでpackage.jsonのdependenciesに書いておいて、最後の実行用イメージの中でnpm install --productionってやれば問題ないと思います。

ネイティブパッケージのマルチステージビルド

マルチステージビルドの本命はたぶんこっちですね。ビルドに必要なパッケージ群をapkコマンドでインストールしてしまうと、軽く330MBを超えるイメージになってしまいます。

バイナリをローカルでコンパイルするパッケージのインストールを試してみます。ここではsharpという画像用パッケージをインストールします。

ここではまず、ビルドに必要なg++とかPythonを入れた後に、sharpのビルドで必要なライブラリ(vips/fftw)の開発版を別にいれてビルドしています。

次の実行用のイメージではまずlibc6-compatを入れています。これはmuclというAlpine Linux用のlibcレイヤーのライブラリがdlopenという動的なライブラリを読み込むのに必要が空実装であるということで、それを置き換えるものです。後は先程インストールしたライブラリのランタイムを入れています。

Dockerfile
FROM alpine:latest as builder

RUN apk add --no-cache nodejs \
        binutils-gold \
        curl \
        g++ \
        gcc \
        gnupg \
        libgcc \
        linux-headers \
        make \
        python && \
    apk add vips-dev fftw-dev --update-cache --repository https://dl-3.alpinelinux.org/alpine/edge/testing/
ADD .babelrc .
RUN npm set progress=false && \
    npm config set depth 0 && \
    npm install sharp

FROM alpine:latest as runner

ADD index.js .
RUN apk add --no-cache nodejs libc6-compat && \
    apk add vips fftw --update-cache --repository https://dl-3.alpinelinux.org/alpine/edge/testing/ && \
    npm uninstall -g npm
COPY --from=builder node_modules node_modules
CMD node index.js

ここでは、事前にビルドしたnode_modulesをまとめて新しいイメージに複製しています。

最後のindex.jsは単にsharpのrequireだけをするテストスクリプトで、細かい検証はしていませんが、正しくrequireが動作することが確認できました。