Ruby x Node.js x yarnを利用した静的サイト生成用のDocker imageをできるだけ素朴に構築する

今回の話は Docker image の build 初心者が静的サイト向けの Docker image をできるだけ素朴に構築する話。

development 環境ではなく、production 環境でもなく、サイトを build する環境というのがミソ。

静的サイトのCI/CDを巡る問題

Netlify や Render などを使っていればここは考える余地がなかったりするんだけど、今回は Google Cloud Build を念頭に置いて image の作成を行う。これは Firebase Hosting や GAE などを念頭に置いた選択なので、ホスティング次第ではこの部分の情報は役に立たないと思うが、とは言え他の CI サービス や GitHub Actions でも基本的には大きく変わらないと思う。

ビルド時間の問題

静的サイトを何で作っているか次第ではあるが、例えば Jekyll を使っている場合、まず Ruby の環境と依存パッケージを準備する必要がある。つまり、ビルド時間には

  1. Ruby の環境の準備
  2. 利用する Rubygems の準備
  3. サイトの生成

が含まれる。これにモダンなフロントエンドを組み合わせたらその分もプラスされる。

このように、静的サイトの生成は、手元でくり返し利用している場合に比べて CI/CD では都度とんでもなく時間が掛かってしまう要素に満ちている。

この問題への対策は、例えば Netlify や Render などホスティングサービスにおいては独自に賢いキャッシュシステムを持っている。CircleCI なんかもキャッシュを制御する構文を用意してくれている。

まぁ、いずれにせよ何らかの対策が必要になる。

これに対し、今回は Ruby と Node.js および依存パッケージをすべて含む docker image を用意することで上の 1 と 2 の時間をできるだけ削減することを目指す。

docker imageの容量問題

さて上記のように環境を準備する必要があるが、CI/CD の環境は昨今は VM ではなく Docker で処理されることが多い。

そこで docker image を準備することになるが、例えば native extension を含む rubygems を問題なくインストール可能な環境はそれなりに大物のイメージになってしまうし、静的サイトのビルドは普通のファイルシステムと sh の動作を期待する部分が少なくない。つまり

alpine を使って docker image の容量を削減しました! みたいな手法は使えない

そこでベース image の極端な最小化は目指さないが、さすがに全部入りで GB 超えになるようなことは避け、ほどほどに小さい image を目指すこととする。

※ 残念ながら Official Jekyll image はその alpine を使っているので独自の処理が少しでも入るとアウトである。実際ハマった。

envygeeks/jekyll-docker: ⛴ Docker images, and CI builders for Jekyll.

multi-stage buildとは

マルチステージビルドの利用 | Docker ドキュメント

Docker Engine 17.05 から導入された。

2022 年 5 月現在だと docker image 作成の際の容量削減方法の一つとして multi-stage build が恐らく最もポピュラーだと思う。

従来の容量削減方法

  • できるだけ小さい image をベースに置いて、自分でビルドに必要なツールなどを追加する
  • ビルドが終わったらそれらを削除する

みたいなことがよく行われていた。

※ 余談だが、実は FROM ディレクティブは当初から複数記述できたらしい。

multi-stage build ( 17.05 ) 以降

以下の機能が追加された。

  1. FROMas を加えて名前を付け、その名前を COPY --from で src に利用できる
  2. --target で特定の stage の image を build できる

multi-stage build の COPY の部分については恐らく従来もどうにかしてホスト側のファイルシステムやネットワーク上の何かを利用すれば可能だったのではないかと思うが、COPY --from の方が簡単で分かりやすい。

この結果、build 時には build に必要な諸々を備えた image を利用し、実行時にはその成果物だけを利用する、といった使い方が簡単にできるようになった。

実際にmulti-stage buildの恩恵に与る

基本的な考え方

  • Dockerfile の中身を build stage と runtime stage に分けて考える
  • multi-stage build は stage ( FROM から次の FROM か終端まで ) 間で artifact の COPY を行える
  • これを利用して runtime に必要な native extension を含む依存パッケージを開発環境込みの build stage で build し、runtime の stage に COPY して持ってくる
  • こうすることで最終的にできあがりのサイズを削減しつつ、必要なバイナリとライブラリの揃っている runtime image を build できる

注意点

  • 言語ランタイムは特に Node.js については扱いが比較的簡単(大きな 1 バイナリになっている)
  • Ruby の場合は実行時に必要なファイルがいくつもある
  • 依存パッケージについては bundle install は local にも global にも対応できるが、yarn は global install できない

stageと利用するベースimageの設計

  • 最終的な image のベースは Ruby に寄せる(その方が COPY するものが少ないから)
  • どうせ依存パッケージの build に必要なものはいろいろあるので build 時には full image を利用する
    • multi-stage build を利用するなら別に build のタイミングのために手でアレコレ追加しなくてよい

ということで

image目的
node:16.15-slimNode.js と yarn の実行バイナリをコピーしてくる
ruby:3.1-bullseyeRuby の実行バイナリと native extension の build に必要なもろもろ
ruby:3.1-slimNode.js, yarn および native extension 含むパッケージをコピーして置く最終 image

のような形で base image を利用することとする。

最終 image を alpine ベースにすることも可能と言えば可能だが、library に非互換があるのと今回は サイト生成時に一部通常のコマンドを実行する ので sh とパスの互換性や permission の問題を避けるために Debian slim を採用している。結果、build 環境も Node.js のバイナリもすべて Debian ベースで揃えることにした。

手順

  1. node slim image 上で node および yarn のパスの確認
    • which node, which yarn, ls -l /usr/local/bin/node, ls -l /usr/local/bin/yarn
    • yarn は symbolic link であり、実態は /opt/yarn-<version> 以下にあることが分かる
  2. build 用の bullseye image に環境を作る
    • /usr/local/bin/node, /opt/yarn-<version/bin/yarnCOPY
    • /usr/local/bin/yarn -> /opt/yarn-<version>/bin/yarn の link を生成
    • 適当なディレクトリを掘り、bundle と yarn で依存パッケージをプロジェクト local に保存する
  3. runtime 用の slim image 上で
    • もう一度 node, yarn および保存した依存パッケージを COPY し、ln を張る
    • 2 でインストールした依存パッケージも COPY する

利用方法

  1. Cloud Build ではソースコードを /workspace 以下に mount するので、ここに image 内に保存してある依存パッケージを cp
  2. 念のためもういちど bundle ( install ) を実行
    • なぜか intall する gem がまだある(?)
  3. 以降は普通に bundle exec も yarn run も利用できる

Dockerfile は例えばこんな感じ

layer の最適化はしていない。ポイントは必要なファイルを COPY で使いまわしつつ依存パッケージも保存し、実際のサイト生成の準備を整えつつ、image が「不必要に」大きくなりすぎないようにすること。

Ruby と Node.js の環境は分けていてもそれなりに機能はする。ただしだいぶ前から Ruby の Web 系のツールには ExecJS という gem で Ruby から JavaScript を呼ぶ機能があるため、静的サイトを生成する build 環境で Ruby と Node.js は分かれていない方が考えることが減って楽になる。(一応 Duktape という gem を利用すると「やり過ごす」ことはできる。)

FROM node:16.15-slim as node
FROM ruby:3.1-bullseye as build

RUN mkdir -p /opt

COPY --from=node /usr/local/bin/node /usr/local/bin
COPY --from=node /opt/yarn-v1.22.18 /opt/yarn

WORKDIR /workspace

COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock

COPY package.json package.json
COPY yarn.lock yarn.lock

RUN bundle config set --local path vendor
RUN BUNDLE_WITHOUT=development bundle install

RUN ln -s /opt/yarn/bin/yarn /usr/local/bin
RUN yarn install

FROM ruby:3.1-slim

COPY --from=node /usr/local/bin/node /usr/local/bin
COPY --from=node /opt/yarn-v1.22.18 /opt/yarn
RUN ln -s /opt/yarn/bin/yarn /usr/local/bin

RUN mkdir -p /packages
COPY --from=build /workspace/vendor /packages/vendor
COPY --from=build /workspace/node_modules /packages/node_modules

RUN echo 'copy /packages to /workspace first !'

だいぶ生々しいんですよねぇ…。本当はこんなところに時間を割きたくない…。

cf.

Cloud Buildの設定例は以下のような感じ

steps:
  - id: build-website
    name: gcr.io/$PROJECT_ID/ruby-and-node-image
    entrypoint: bash
    args:
      - -c
      - |
        cp -r /packages/vendor /workspace;
        cp -r /packages/node_modules /workspace;
        bundle config set --local path /workspace/vendor;
        bundle;
        yarn;
        bundle exec rake build

パッケージを復元したあとに bundle や yarn を叩いているのは、build 用の image と実際のコードに差分がある(パッケージの追加や削除を含む変更が入っている)場合に備えて。

分かったこと

今回ゼロから docker build をうんうん唸りながら試してみていろいろ分かってきた。

  • Docker で build する image はその「用途」を意識することが重要
    • development 環境か production 環境かという違いではなく、build 用途なのか runtime 用途なのか
    • build 用途はある程度内容が詰まってて重くても仕方ない
    • runtime 用途なら軽さや attack surface の話が出てくる(対外的に晒される環境なので)
  • Docker はあくまでテクノロジーであって「用途」などは概念が異なるのでやりたいことと Docker 用語が直感的に結びつかない問題がある
  • multi-stage build は思っていたより簡単
  • 静的サイトの生成は runtime でありながら build 用途に当たり、build 用途の image は作成時だけでなく実際のサイト生成の実行時も含めて考えることは多い
  • こうなると確かに single binary で動き、その binary を作るのがめちゃくちゃ楽な Go が楽そう(楽だから楽なのです)

いちばんの学びは Docker 用語は目的と直接関係しないので、参考情報を探すのであれば「やりたいことを明示してある情報」を探すようにした方が早そうということかな。Docker を避けてきたので歴史を知らずに苦労した部分もあるけど、どちらかというとツール自体が目的化していて、ベース image の選び方も「闇雲な最小化」だったり「セキュリティ」の話は見つかるけど、まだまだ用途から語られることは少ないのかも?と感じた。

参考

.dockerignoreに触れていない件について

上の例では build context について特にケアを行っておらず、.gitignore にも何も触れていない。これは手元の環境および Cloud Build の環境での検証に基づくもので、2022年5月現在、

  • Docker Desktop for Mac
  • docker on colima
  • Cloud Build docker builder ( Ubuntu )

いずれも experimental feature は true になっており、どうやら

この辺の中で build context にあるリソースがすべて docker build 時に読み込まれるという、転送時間とメモリ消費を増大させる原因となっていた挙動が改善されているようです。確認した環境は以下の通り。(version は docker version コマンドの出力したもの)

  • Docker Desktop for Mac ( version 20.10.13 )
  • Homebrew docker ( version 20.10.14 )
  • docker on colima ( version 20.10.11 )
  • gcr.io/cloud-builders/docker ( version 20.10.14 )

ということでいわゆるベストプラクティスには従っていないですが、恐らくこの1年くらいでより簡単で知識の要らない状態になっているんでしょう、きっと。

More