Mobile Factory Tech Blog

技術好きな方へ!モバイルファクトリーのエンジニアたちが楽しい技術話をお届けします!

pnpm fetch で Docker キャッシュを活かす

pnpm には Docker でキャッシュを利用しやすくする fetch というコマンドが用意されています

この記事では pnpm fetch を使ってキャッシュを利用しやすい Dockerfile を書いていく方法を紹介します

Docker のマルチステージビルドとキャッシュ

Docker にはマルチステージビルドという機能が存在し、単一の Docker イメージ下で実行するのではなく、ビルドやインストール, 本番実行等に分けて Docker イメージ を作成できます

Dockerfile でマルチステージビルドを行う際の例を示します

ARG NODE_VERSION=18.15.0

FROM node:$NODE_VERSION-buster as builder

# build 処理
RUN pnpm build

FROM node:$NODE_VERSION-slim as runner
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/dist /app/dist

CMD ["node", "dist/index.js"]

使い方としてはこんな感じですね

ステージを分ける目的としては、一般的に

  • ビルドステップでのみ必要な依存関係(devDependency)やバンドルする場合は node_modules 全体等を本番で使うコンテナに持って行きたくない
  • ビルドステップではフルイメージを利用したいが、runner ではできるだけ軽量にしたいため slim イメージを利用する
  • ネットワーク転送が原因で時間のかかる処理が複数あるときに、並列で実行したい (COPY 元が前のステージになっていない、かつ DOCKER_BUILDKIT=1 が設定されているとき並列で実行されます)

等があります

このステージ分けはキャッシュの単位としても機能していて、COPY 元のファイルがキャッシュキーになります

つまり

FROM node:$NODE_VERSION-buster as installer

COPY package.json pnpm-lock.yaml ./
RUN pnpm i --frozen-lockfile
RUN pnpm build

FROM node:$NODE_VERSION-buster as builder
# ...

の場合は、package.json と pnpm-lock.yaml に変更が入らなければ installer ステップはキャッシュを利用してスキップできることになります

ですので、pnpm に限らず npm や yarn 等の他のパッケージマネージャーを利用している場合でも package.json とロックファイルを installer としてステージを分離してあげるとキャッシュが適用されやすくなります

pnpm fetch

pnpm fetch は pnpm-lock.yaml から依存関係の取得するコマンドです

上の見出しで installer を分離するテクニックを紹介しましたが、pnpm fetch を使うことで、さらに package.json をキャッシュキーから取り除き、pnpm-lock.yaml のみをキャッシュキーとして依存関係をダウンロードできます

設定ファイルとして package.json を利用するツールも(最近は減った気がしますが)ありますし、scripts も package.json に書かれます

pnpm-lock.yaml のみから取得できると純粋に依存関係が変わらない限りキャッシュを利用し続けられる、ということになります

公式ドキュメントの Dockerfile が動かない

ここからが本記事の本題です

上記の説明は 公式ドキュメントの pnpm fetch のページ にサンプルの Dockerfile とセットで説明されています

公式の推奨するサンプルは以下の通りです

FROM node:14

RUN curl -f https://get.pnpm.io/v6.16.js | node - add --global pnpm

# pnpm fetchはロックファイルのみが必要
COPY pnpm-lock.yaml ./

# パッケージにパッチを当てた場合は、pnpm fetchを実行する前にパッチを含める
COPY patches patches

RUN pnpm fetch --prod ./
RUN pnpm install -r --offline --prod

EXPOSE 8080
CMD [ "node", "server.js" ]

しかしながら、これをそのまま手元で動かすと上手く行きませんでした

  • No projects found in "/app" といわれてインストールされず、node_modules 以下には .modules.yaml.pnpm のみ作成される
  • おそらく package.json がないことで install 時にプロジェクトを認識できていないことが原因なので、空の package.json を生成してみる
    • → 指定なしのときは pnpm-lock.yaml が空になる
  • frozen-lockfile でインストールしてみる
    • エラーになる

pnpm fetch 自体は意図通り動いていますが、おそらくバージョンアップ等で package.json がない状態での pnpm install -r --offline --prod が実行できなくなったものと思われます

fetcher step と builder step に分離する

install が動かなかったので、回避策として fetch, install と build ではなく、fetch と install, build に分離しました

node_modules 以下への展開は install で行われますが、fetch の時点でダウンロードは完了しているのでここまでキャッシュできれば install も十分高速に実行されることが期待できます

分離後の Dockerfile は以下の通りです

ARG NODE_VERSION=18.15.0

FROM node:$NODE_VERSION-buster as fetcher

WORKDIR /app

COPY pnpm-lock.yaml /app

RUN corepack enable pnpm
RUN corepack prepare pnpm@8.2.0 --activate

RUN pnpm fetch --prod ./

FROM node:$NODE_VERSION-buster as builder

WORKDIR /app

COPY --from=fetcher /root/.local/share/pnpm/store/v3 /root/.local/share/pnpm/store/v3
COPY --from=fetcher /app/node_modules /app/node_modules
COPY --from=fetcher /app/pnpm-lock.yaml /app/pnpm-lock.yaml
COPY package.json /app

RUN corepack enable pnpm
RUN corepack prepare pnpm@8.2.0 --activate
RUN pnpm i --offline --prod --frozen-lockfile

FROM node:$NODE_VERSION-slim as runner
# ...

pnpm fetch すると仮想ストアにダウンロードされるので仮想ストアが格納されるパスもコピーしてきます

パスは以下のようにして調べられます

$ docker run node:18.15.0-buster bash -c "corepack enable pnpm && corepack prepare pnpm@8.2.0 --activate && pnpm store path"
Preparing pnpm@8.2.0 for immediate activation...
/root/.local/share/pnpm/store/v3

これで「依存関係が変わらない限り依存関係のダウンロードをキャッシュする」ことができるようになりました

まとめ

このエントリでは pnpm fetch を使うことで、依存関係のダウンロードをキャッシュしやすい Dockerfile について紹介しました

install 処理は --offline であっても package.json がないと動かないので fetch までで STAGE を分離し、node_modules と Virtual Store をコピーすることで対応しました

pnpm をお使いの方はぜひお試しください