package.jsonのcaret(^)はどう動くのか — ロックファイルが抜けたDockerビルドが起こしたインシデント

2026-05-25 hit count image

コードを一切変更していないのに、ある日突然Dockerのproductionビルドが失敗しました。原因は 依存ツリーの奥深くにあるtransitiveパッケージが数日前にpublishした新しいpatchでした。 この記事ではインシデントを追いながら、package.jsonのcaretレンジが実際にどう動くのか、 そしてyarn.lockがどんな役割を果たすのかを整理します。

web

はじめに

ある普通の朝、コードは一切変更していないのにproduction deployが失敗したという通知が届きました。CIログを開くと、ビルド中の yarn install ステップで次のようなエラーが出ています。

error [email protected]: The engine "node" is incompatible with this module.
      Expected version ">=20". Got "18.17.0"
error Found incompatible module.

js-cookie というライブラリは、私たちのpackage.jsonに直接書いた覚えがありません。そして昨日まで同じコードで問題なくデプロイできていました。

この記事では、「コードを変えていないのにある日突然ビルドが壊れた」というタイプのインシデントがどう起きるのかを、上の事例を辿りながら分析していきます。結論を先に言うと、犯人は package.jsonのcaret(^)レンジと、ビルドコンテキストから抜けていたyarn.lockの組み合わせ でした。

ある日突然productionビルドが壊れた

状況を単純化すると次のとおりです。

  • Nuxt 2ベースのフロントエンドプロジェクト
  • Dockerfileは node:18.17.0-alpine をベースにビルド
  • CIで yarn install --frozen-lockfile && yarn build を実行

昨日まで通っていたビルドが今日壊れていて、リポジトリには新しいコミットが一つもpushされていません。つまり私たちが何も変えていないのに結果だけが変わったわけです。この種のインシデントは定義からして「外部環境の何かが私たちの知らないうちに変わった」ということを意味します。

最もありがちな候補は次の2つです。

  1. ベースイメージのパッケージが更新された — 今回はNodeのバージョンを明示的に 18.17.0 でpinしているので除外。
  2. npm registry上の依存関係が更新された — 疑う価値あり。

ステップ1 — どのパッケージが入ってきたのか

まず js-cookie がどこから来ているかを追います。私たちの package.json には直接の依存として書かれておらず、 yarn.lock をgrepしても js-cookie のエントリは見つかりません。つまり正常なlockfileベースの依存ツリーには存在しないはずのパッケージなのに、ビルドはそれを明らかにインストールしようとしています。

追跡のため、一時ディレクトリでlockfile無しで yarn install を再現したあと、 yarn why でoriginを掘ります。

mkdir /tmp/trace && cd /tmp/trace
cp ~/project/package.json .
# private git depは認証が必要なので、この調査のためだけに一行除外
grep -v "private-internal-lib" package.json > package.json.tmp && mv package.json.tmp package.json

yarn install --ignore-engines --ignore-scripts
yarn why js-cookie

結果:

info Found "[email protected]"
   - "vue-jest#js-beautify" depends on it
   - Hoisted from "vue-jest#js-beautify#js-cookie"

追跡された依存チェーンは以下のとおりです。

vue-jest (devDependency)
  └─ js-beautify (^1.6.14)
       └─ js-cookie (^3.0.5)

vue-jest はVueコンポーネントをJestでテストするためのdevDependencyです。自分たちが直接importして使うことはありませんが、ビルドステップでは yarn install がdevDependencyまで含めて全てインストールするため (Docker builder stageでは --production を付けていない) 、そのtransitiveまで一緒に降りてきます。

ステップ2 — なぜ昨日は通って今日は通らないのか

js-cookie のnpm registryの履歴を見ると、答えは明快です。次は semverカリキュレータ とregistry APIで確認した3.x系のpublish履歴です。

バージョンpublish日engines
3.0.52023-04-24node: '>=14'
3.0.62026-05-15node: '>=20'
3.0.72026-05-16node: '>=20'

3.0.53.0.6 の間でenginesの要求が >=14 から >=20引き上げられました 。semverの慣例ではこの種の破壊的変更はmajorを上げるべきものですが、メンテナーはpatch番号( 3.0.6 )としてリリースしました。私たちのNode 18環境では、このpatchが入った瞬間にengineチェックで失敗します。

ここでの本質的な問いはこうです: 私たちは js-cookie のどのバージョンも直接指定したことがないのに、なぜ新しくpublishされた3.0.6/3.0.7が私たちのビルドに入ってくるのか?

答えは依存チェーンの最も奥のリンクに埋め込まれているcaretレンジです。

// [email protected] のpackage.jsonの中:
"dependencies": {
  "js-cookie": "^3.0.5"
}

^3.0.5 という表記は 「semverのcaret range」 であり、「3.x.xの範囲で3.0.5以上の全てのバージョン」を意味します。つまり3.0.6も3.0.7も3.99.99もマッチします。yarnはresolveのたびにこのレンジにマッチする registry上の最新バージョン を選びます。2026-05-15以降はその最新が3.0.6/3.0.7になったので、昨日は3.0.5が、今日は3.0.7がコンテナの中に入ってきた、という流れです。

ステップ3 — ではなぜlockfileが守ってくれなかったのか

ここで疑問が湧きます。私たちのリポジトリにはyarn.lockがちゃんとあって、その中に js-cookie は1つもないと上で確認しました。 lockfileさえ正しく使われていれば js-cookie はツリーに登場すらしなかったはず です。それなのにビルドはそれを取りに行っていました。

Dockerfileを改めて見ます。

COPY package.json ./
RUN yarn install --frozen-lockfile

問題が見えます。 yarn.lockをビルドコンテキストにコピーしていません。 builder stageにyarn.lockが無いと、yarn 1.xは --frozen-lockfile フラグが指定されていてもlockfileが存在しないので、結局freshにresolveしてinstallしてしまいます。つまり毎回registryの その時点のlatest を取りに行ってしまい、外部で誰かが新しいpatchをpublishすれば、私たちのビルドも自動的にそれを追従してしまうわけです。

これが、インシデントを解いていく中で気づいた最も重要なポイントです。lockfileがビルドコンテキストに無い状態は 「私たちの依存ツリーは、npm registryに新しくpublishされたパッケージバージョン次第で毎回変わりうる」 と言うのと同じことです。

修正はシンプルです。

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

この2行になれば、yarnはlockfileに書かれた正確なバージョン (私たちのケースでは js-cookie が存在しないツリー) をそのまま再現します。外部のregistryで何がpublishされようと、私たちのツリーは変わりません。

package.jsonのcaret(^)は何を意味するのか

ここで一歩引いて、semverとcaretを整理しておきます。今回のインシデントの中核メカニズムはcaretだからです。

npmの Semantic Versioningドキュメント によれば、バージョンは MAJOR.MINOR.PATCH の3つの部分で構成され、それぞれ次の意味を持ちます。

部分意味
MAJOR互換性のないAPI変更 (breaking change)
MINOR後方互換性を保ったまま機能追加
PATCH後方互換性を保つバグ修正

package.jsonのdependencyレンジ表記は、この3つのうちどこまで自動アップデートを許すかを決めます。最もよく使われるのがcaret(^)です。

npm CLIドキュメントのcaret-range定義 によれば、caretは 「最左の非ゼロ桁を固定し、それより下は全ての更新を許可する」 という意味です。

表記マッチ範囲メモ
^1.2.3>=1.2.3 <2.0.0MAJOR桁(1)が固定
^0.2.5>=0.2.5 <0.3.0最左の非ゼロがMINOR(2)なのでMINORが固定
^0.0.4>=0.0.4 <0.0.5最左の非ゼロがPATCH(4)なのでPATCHが固定

最後の2ケースが重要です。0.xラインはsemver上「まだ不安定な段階」と扱われるため、caretの動作も保守的になります。ライブラリを採用するとき、0.xラインのcaretと1.xラインのcaretは同じ意味ではない、という点は覚えておくとよいでしょう。

ある特定のcaret表記が実際にはどのバージョン集合にマッチするのかは、 npmのsemverカリキュレータ で即座に確認できます。registryに実際にpublishされているバージョン一覧と並べて表示してくれるのでデバッグに便利です。

今回のインシデントで問題になったcaretは私たちが書いたものではありませんでした。 vue-jestのメンテナーが自分のpackage.jsonに書いた “js-beautify”: “^1.6.14” 、そして js-beautifyのメンテナーが書いた “js-cookie”: “^3.0.5” です。どちらもcaretなので、同じmajorの中で新しくpublishされる全てのバージョンが自動的に私たちのツリーに流れ込んできます。

caretを外せば解決するのか — よくある誤解

「ならpackage.jsonのcaretを全部外してexact pinすればいいのでは?」

このインシデントを追っていると自然に浮かぶ対応策で、一緒に作業していた同僚も同じ質問をしました。答えは 「部分的にしかYESで、しかも今回のインシデントは解決しない」 です。

ポイントは caretが誰のpackage.jsonにあるか です。

[ 私たちのpackage.json ]            ← ここのcaretを外せば直接depsだけpinされる
   └─ [email protected]
        [ vue-jestのpackage.json ]   ← ここのcaretは私たちの制御外
          └─ js-beautify ^1.6.14
               [ js-beautifyのpackage.json ]   ← ここも制御外
                 └─ js-cookie ^3.0.5    ← インシデントの震源

私たちのpackage.jsonで "vue-jest": "^3.0.4""vue-jest": "3.0.4" に固定したとしても、vue-jest自身が持っている "js-beautify": "^1.6.14" のcaretはそのまま生きています。結果、fresh resolveのときに依然としてlatestのjs-beautifyが、そしてそれが連れてくるlatestのjs-cookieがツリーに入ってきます。

言い換えると transitive driftは私たちのpackage.jsonの表記方法ではなく、lockfileを使うか否かでしか止められない ということです。caretを外す作業は直接依存のminor自動アップデートを制限するだけで、transitiveまで制御したければlockfile、あるいは yarnの resolutions フィールド のようにツリー全体を制御するメカニズムが必要です。

これは普段はあまり意識されず、インシデントが起きてはじめて思い出される類の話です。普段caretが「レンジで開いたまま」になっていてもlockfileが正しく動いていればツリーはそのまま再現されるので、表面上はcaretが無害に見えるのです。

まとめ

このインシデントは、たった1行足りないDockerfileから始まり、外部registryへの新しいpatchのpublishがトリガーとなってビルドを壊しました。改めて時系列で整理します。

  1. Dockerfileがビルドコンテキストに yarn.lock をコピーしていなかった。
  2. yarn install --frozen-lockfile はlockfileが存在しないと事実上fresh resolveとして動作する。
  3. 普段は運よく依存ツリーが「昨日と同じ」だったが、registry側が変わればツリーも変わる。
  4. js-cookie のメンテナーがpatchバージョン( 3.0.6 )でenginesを >=14>=20 に変更。
  5. 私たちのcaret(正確にはtransitiveのcaret)がそのpatchまでマッチ → fresh resolveがそれを引き入れてinstallを試みた。
  6. Node 18環境でengineチェック失敗 → production deploy停止。

修正の本質は2行です。 lockfileをビルドコンテキストに含め、 --frozen-lockfile でその使用を強制する。 これさえ守れば、外部の気まぐれが私たちのビルドを揺さぶる経路が閉じます。caretをどう書くかとは別のレイヤーの問題です。

semverはメンテナーの約束であり、caretはその約束に対する私たちの信頼の表明です。どちらも人間の判断に基づいているので本質的に破られうるものであり、破られたときに私たちのビルドを守ってくれる最後のセーフティネットがlockfileです。

参考資料

私のブログが役に立ちましたか?下にコメントを残してください。それは私にとって大きな大きな力になります!

アプリ広報

今見てるブログを作成たDekuが開発したアプリを使ってみてください。
Dekuが開発したアプリはFlutterで開発されています。

興味がある方はアプリをダウンロードしてアプリを使ってくれると本当に助かります。



SHARE
Twitter Facebook RSS