
ECR イメージの node_modules だけ symlink で使い回す——Jenkins 最適化の試行錯誤
前回の記事(Docker × ECR × Jenkinsfile の「root 問題」誤診記)では、Docker エージェントのユーザー権限周りで盛大に詰まった話を書きました。
今回はその続きというか、同じプロジェクトで直面した別の問題です。テーマは「ECR に事前ビルドした成果物があるのに、毎回 npm install している」というもったいなさをどう解消するか。
結論から言うと、「node_modules だけ symlink で使い回して、dist は毎回ビルドする」という落としどころに辿り着きました。ただそこに至るまで、検証ロジックで3回くらい迷走したので、その記録です。
背景:ECR イメージに成果物が焼いてあるのに毎回ビルドしている
このプロジェクトでは、Jenkins の Docker エージェントに ECR の事前ビルド済みイメージを使っています。イメージ内には /workspace/node_modules と /workspace/dist が含まれていて、本来ならジョブ起動時のビルドをスキップできるはずです。
それなのに setupNodeEnvironment() という共通関数が、毎回こんな処理を走らせていました。
sh '''
npm ci --include=dev // 毎回12秒+ネットワーク消費
npm run build // 毎回tscを実行
'''イメージを使っている意味が薄い状態です。
課題をまとめると:
- 1ジョブあたり Setup に数分かかっている
- npm install がネットワークと CPU を無駄に消費している
- 全14ジョブが同じ冗長処理を抱えている
そこで Issue #833 として、「symlink で成果物を再利用する方式」への切り替えを進めることにしました。
前提:環境の整理
- Jenkins: Docker エージェント方式(各ジョブが ECR イメージを pull してコンテナ実行)
- 言語: TypeScript(
tscでビルド →dist/に出力) - 共通処理:
jenkins/shared/common.groovyのsetupNodeEnvironment()に集約 - ECR イメージ: 週次 cron で定期ビルド
最初の実装:node_modules も dist も両方 symlink
考え方はシンプルです。
// ECRイメージ内の成果物をJenkinsワークスペースからsymlinkで参照
if ([ -d /workspace/node_modules ] && [ ! -e node_modules ]); then
ln -s /workspace/node_modules node_modules
fi
if ([ -d /workspace/dist ] && [ ! -e dist ]); then
ln -s /workspace/dist dist
fiただ、symlink が張れたからといって成果物が使えるとは限らない。ECR イメージが古い場合、dist/index.js がソースコードと乖離している可能性があります。そこでセーフティネット検証を追加しました。
// 成果物の整合性を確認
if node -e "require('./dist/index.js')" 2>/dev/null; then
echo "ECR image artifacts verified successfully. Skipping npm install & build."
else
echo "WARNING: ECR image artifacts verification failed. Falling back..."
rm -f node_modules dist
npm ci --include=dev
npm run build
fi実行結果:常にフォールバックに入る
実際に Jenkins で動かしてみると、こうなりました。
+ node -e require('./dist/index.js')
+ echo WARNING: ECR image artifacts verification failed. Falling back to full install & build...
WARNING: ECR image artifacts verification failed. Falling back to full install & build...
+ rm -f node_modules dist
+ npm ci --include=dev
...(npm installが走る)あれ、symlink は成功しているのに検証で必ずこける。
原因: dist/index.js のエントリポイントが require() した瞬間に runCli() を実行するんです。引数なしで commander が走って、コマンドが見つからずにエラー終了していました。2>/dev/null で潰していたので気づくのが遅れました。
修正1:--help フラグを使う
副作用のない検証に切り替えました。
if node dist/index.js --help >/dev/null 2>&1; then
echo "ECR image artifacts verified successfully."
else
...--help はコマンド実行せず、正常終了コード 0 を返します。これは ecr-verify ジョブでも使っていた検証パターンです。
実行結果:まだフォールバックに入る
+ node dist/index.js --help
+ echo WARNING: ECR image artifacts verification failed...原因: node dist/index.js が参照しているのは、symlink で繋いだ /workspace/dist/index.js——つまり ECR イメージ内のビルド済み成果物です。このイメージは週次 cron でビルドしたもので、--help サブコマンドを追加する前のバージョンです。
ECR イメージが古いから、check 機能を持っていない。フォールバック自体は正しく動いているのですが、目的(npm install スキップ)は達成できていません。
修正2:check サブコマンドを追加
「--help は将来的に副作用が入るリスクもあるし、CI 検証専用のサブコマンドを作ろう」という話になりました。
// src/main.ts
program
.command('check')
.description('Verify that build artifacts are loadable (used by CI setup)')
.action(() => {
process.stdout.write('ok\n');
});シンプルに ok を出力して終了するだけのコマンドです。
if node dist/index.js check >/dev/null 2>&1; then
echo "ECR image artifacts verified successfully. Skipping npm install & build."
else
...ローカルでビルドして確認。
$ node dist/index.js check
okテストも書いて PR 出しました。
実行結果:またフォールバックに入る(3回目)
+ node dist/index.js check
+ echo WARNING: ECR image artifacts verification failed. Falling back to full install & build...原因: check コマンドを追加したコードを ECR イメージに焼くのは次のビルドタイミング。今動いているイメージは check を知らないので、unknown command としてエラー終了します。
つまり「ECR イメージを更新しないと使えない機能で、ECR イメージの整合性を検証する」という鶏卵問題に入り込んでいました。
根本を考え直す:node_modules と dist の性質の違い
このあたりで少し立ち止まって考えました。
「ECR イメージを常に最新にしたい、でもビルドの CPU 消費も抑えたい」というジレンマがあったわけですが、そもそも node_modules と dist って性質が全然違うよな、と。
node_modules:
- package.json が変わらない限り同一
- 変更頻度: 低い(ライブラリ追加・更新時のみ)
- 再生成コスト: 高い(npm install → 12秒+ネットワーク+CPU)
dist:
- ソースが変わるたびに変わる
- 変更頻度: 高い(コードを書くたびに)
- 再生成コスト: 低い(tsc → 数秒)だとすれば、node_modules だけ symlink で使い回して、dist は毎回ビルドすればいい。
- npm install のスキップ: ECR イメージの鮮度に依存
- dist の生成: 常に最新ソースから、ECR イメージの鮮度は関係ない
ECR イメージへの依存を最小化しつつ、重い処理だけ省ける。
最終実装
// node_modules: ECRイメージから symlink で再利用
if [ -d /workspace/node_modules ] && [ ! -e node_modules ]; then
# package.json の dependencies が一致するか検証
if [ -f /workspace/package.json ] && \
node -e "
const a = require('/workspace/package.json');
const b = require('./package.json');
const eq = JSON.stringify(a.dependencies) === JSON.stringify(b.dependencies) &&
JSON.stringify(a.devDependencies) === JSON.stringify(b.devDependencies);
process.exit(eq ? 0 : 1);
" 2>/dev/null; then
echo "Linking pre-built node_modules from ECR image..."
ln -s /workspace/node_modules node_modules
else
echo "WARNING: package.json dependencies mismatch. Running npm ci..."
npm ci --include=dev
fi
elif [ ! -e node_modules ]; then
echo "WARNING: ECR image node_modules not found. Running npm install..."
npm install --include=dev
fi
# dist: 常に最新ソースからビルド
echo "Building TypeScript sources..."
npm run build検証ロジックが変わりました。「dist/index.js を実行して確認する」から「package.json の内容を比較する」へ。
この方式なら:
- コードを実行しないので副作用がない
- ECR イメージの
package.jsonとワークスペースのpackage.jsonを直接比較するだけ - ECR イメージが古くても、
package.jsonが一致していればnode_modulesは使える
実際に動いたログ
+ [ -d /workspace/node_modules ]
+ [ ! -e node_modules ]
+ [ -f /workspace/package.json ]
+ node -e ... ← dependencies 比較 → 一致(exit 0)
+ echo Linking pre-built node_modules from ECR image...
Linking pre-built node_modules from ECR image...
+ ln -s /workspace/node_modules node_modules
+ echo Building TypeScript sources...
Building TypeScript sources...
+ npm run build
> project@0.2.0 build
> tsc -p tsconfig.json && node ./scripts/copy-static-assets.mjs
[OK] Copied ...npm install がスキップされ、npm run build だけ走っています。狙い通り。
まとめ
試行錯誤の流れをまとめると:
- node_modules も dist も両方 symlink → 検証ロジック(
require())が副作用で必ずこける --helpフラグで検証 → ECR イメージが古くて flag を知らないcheckサブコマンドを追加 → これも ECR イメージを更新しないと使えない(鶏卵問題)- node_modules だけ symlink、dist は毎回ビルド →
package.json比較で検証、副作用なし ✅
「node_modules と dist は変更頻度もコストも違う」という当たり前のことに、少し迂回してから辿り着きました。
参考
Jenkins パイプラインの設計や Docker コンテナの実践的な運用について体系的に学びたい方には、以下の書籍が参考になります。
この記事は役に立ちましたか?

この記事が、何かの整理につながったら
コーヒー1杯分の応援をもらえると嬉しいです。
あなたへのおすすめ
