一覧に戻る
    ECR イメージの node_modules だけ symlink で使い回す——Jenkins 最適化の試行錯誤
    開発ラボ
    PRこの記事には広告が含まれています

    ECR イメージの node_modules だけ symlink で使い回す——Jenkins 最適化の試行錯誤

    17 分で読める

    前回の記事(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.groovysetupNodeEnvironment() に集約
    • ECR イメージ: 週次 cron で定期ビルド

    考え方はシンプルです。

    // 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_modulesdist って性質が全然違うよな、と。

    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 だけ走っています。狙い通り。

    まとめ

    試行錯誤の流れをまとめると:

    1. node_modules も dist も両方 symlink → 検証ロジック(require())が副作用で必ずこける
    2. --help フラグで検証 → ECR イメージが古くて flag を知らない
    3. check サブコマンドを追加 → これも ECR イメージを更新しないと使えない(鶏卵問題)
    4. node_modules だけ symlink、dist は毎回ビルドpackage.json 比較で検証、副作用なし ✅

    「node_modules と dist は変更頻度もコストも違う」という当たり前のことに、少し迂回してから辿り着きました。

    参考

    Jenkins パイプラインの設計や Docker コンテナの実践的な運用について体系的に学びたい方には、以下の書籍が参考になります。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について