package.json だけで固定したはずなのに、なぜ他人の環境では違うバージョンが入るのか?
あなたは package.json に "lodash": "^4.17.0" と書いた。だが半年後に別のメンバーが npm install したら、あなたの手元とは違うパッチバージョンの lodash が入り、片方の環境だけでテストが落ちた。package.json で「使うバージョン」を指定したはずなのに、なぜ環境ごとに実際のバージョンがずれるのだろうか。
package.jsonの^4.17.0は「このバージョン以上、次のメジャー未満」という範囲の指定であり、特定の1バージョンを固定していない。package-lock.jsonは依存解決を1回だけ実行して結果を固定した記録で、次回以降のnpm installはこのファイルを見て「今回何をインストールすべきか」を決める。- CI やデプロイでは
package-lock.jsonを書き換えないnpm ciを使うと、lockfile が持つ厳密な再現性を壊さずに済む。
package.json に書いているのは「バージョン」ではなく「範囲」
package.json の dependencies に並ぶ ^4.17.0 のような文字列は、メジャー.マイナー.パッチの3つの数字でバージョンの互換性を表す取り決めであるSemVer(Semantic Versioning)の範囲指定だ。先頭のキャレット(^)は「メジャーバージョンの数字は変えず、マイナー・パッチは新しい方を使ってよい」という意味で、^4.17.0 は 4.17.0 以上 5.0.0 未満のどれでもよいという範囲を表す。似た記号にチルダ(~)があり、~4.17.0 はパッチバージョンの更新だけを許し 4.18.0 は範囲外になる、より狭い範囲だ。
npm は npm install 実行時、この範囲に一致する中で公開レジストリ上の最新版を選ぶ。つまり package.json を1文字も変えていなくても、npm レジストリに新しいパッチ版が公開された後に npm install を実行すれば、選ばれるバージョンは変わり得る。半年前にあなたが入れた 4.17.21 と、今日別のメンバーが入れる 4.17.23 が食い違うのは、両者とも「範囲」の指定通りに動いた結果であり、どちらかが間違えたわけではない。
package.json は「野菜は何でもいいのでキャベツ科を1つ」という買い物メモに近い。今日買えばキャベツが手に入り、来月同じメモで買い物に行けば白菜が手に入るかもしれない。どちらも「キャベツ科」という条件は満たしているが、実際に棚から取った1個が何であるかはメモには書かれていない。package-lock.json はこのとき実際にレジで会計した「キャベツ」というレシートで、次に同じメモを持って行った人に「今回はこれと同じものを買ってきて」と正確に伝える役目を果たす。
package-lock.json は「解決済みの結果」を固定する
npm は node_modules や package.json を変更する操作のたびに package-lock.json を自動生成・更新する。ここには依存グラフ全体について、実際に選ばれた正確なバージョン番号と、そのパッケージの中身を検証するための integrity フィールド(SHA-512 の Subresource Integrity ハッシュ)が記録される。次に誰かが npm install を実行すると、npm はレジストリに範囲一致の最新版を問い合わせ直すのではなく、まずこの lockfile に書かれた解決結果をそのまま使おうとする。
この2段構造を理解すると、なぜ両方のファイルをコミットする必要があるかが分かる。package.json だけをコミットして package-lock.json を .gitignore に入れてしまうと、依存グラフ内の間接依存(依存の依存)まで含めて、メンバーやCI環境ごとに毎回新しく依存解決をやり直すことになる。数十個の間接依存がそれぞれ独立に「範囲内の最新版」を選ぶため、ごくわずかな公開タイミングの違いだけで、手元では再現できない環境固有のバグが生まれる。
package.json (範囲の宣言)
"lodash": "^4.17.0"
│
▼ npm install (初回)
依存解決 → レジストリに問い合わせ
│
▼ 結果を記録
package-lock.json (解決結果の固定)
"lodash": { "version": "4.17.21",
"integrity": "sha512-..." }
│
▼ npm install (2回目以降)
lock を見るだけで解決を省略
npm install と npm ci はどちらも lockfile を尊重するとは限らない
package-lock.json さえコミットしておけば、以後は誰が npm install を叩いても常にその通りの構成が再現されるように思える。しかし実際には npm install は lockfile が存在してもそれを絶対視しない。package.json の範囲と lockfile の内容が食い違っていれば(誰かが手で package.json のバージョンだけ書き換えた場合など)、npm はその差分を解決し直し、package-lock.json自体を書き換えて更新する。開発中にパッケージを追加・更新する分には都合が良い挙動だが、CI やデプロイパイプラインでこれが起きると、本来固定されているはずの依存が実行のたびに変わりうる。
npm ci はこの動きを禁止するコマンドだ。package.json と package-lock.json の間に矛盾があれば、npm は解決し直さずにその場でエラーにして止まる。矛盾がなければ既存の node_modules を一度削除してから、lockfile に書かれた通りのバージョンだけを再現し、lockfile 自体は一切書き換えない。「今この瞬間のレジストリの状態」ではなく「lockfile に固定された過去の解決結果」を再現することに徹する点が、CI で npm ci が推奨される理由だ。
# ローカルの開発では npm install でよい npm install # CI・デプロイでは npm ci を使う # package.json と package-lock.json が矛盾していれば exit 1 で止まる npm ci
同じ問題は言語ごとに別の名前で存在する
この「範囲の宣言」と「解決結果の固定」を分けるという設計は npm に固有の発明ではない。Ruby の Bundler は Gemfile(範囲の宣言)に対して Gemfile.lock(解決結果の固定)を生成し、bundle install は既定で lockfile を優先する。Go はやや異なる方式で、go.mod が依存の最小バージョンを宣言し、go.sum には依存モジュールごとに 2 行、モジュール本体のファイルツリーのハッシュと go.mod 自体のハッシュが記録される。go コマンドはダウンロードしたモジュールがこのハッシュと一致するかを検証し、未知のモジュールであれば Go Checksum Database(既定 sum.golang.org)に問い合わせて改ざんがないかを確認してから受け入れる。
3つの言語で仕組みは異なるが、狙いは共通している。「依存のバージョンをどこまで許容するか」の宣言と、「実際に何を使ったか」の記録を別ファイルに分離し、後者を全メンバー・全環境で共有することで、依存関係に起因する「自分の環境では動く」を減らす設計だ。
PR に package-lock.json の巨大な差分が乗っているのを見て「本体のコードは1行しか変えていないのに」と戸惑った経験があるはずだ。多くの場合、それは間接依存の誰かが新しいパッチ版を公開したタイミングで npm install を実行したために起きた正常な再解決であり、lockfile の差分自体は削除すべきノイズではない。逆にレビューで package-lock.json だけ古いまま package.json の依存が追加されている PR を見たら、CI で npm ci が失敗するはずなので、ローカルで npm install を実行し直して lockfile を更新してもらう、という判断ができる。
キャレット範囲が実際にどこまでを許容するかを、レジストリに問い合わせず手元だけで確認する。
mkdir /tmp/lockfile-demo && cd /tmp/lockfile-demo
npm init -y
# package.json に手で範囲指定を書く
# (npm install lodash@^4.17.0 のようにコマンドラインで
# 指定すると npm が範囲を解決後バージョン基準へ
# 書き換えてしまうため、ここでは手書きにする)
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json'));
pkg.dependencies = { lodash: '^4.17.0' };
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
"
npm install
# package.json 側の範囲を確認
node -p "require('./package.json').dependencies.lodash"
# package-lock.json 側の解決済みバージョンを確認
node -p "require('./package-lock.json').packages['node_modules/lodash'].version"
package.json には自分が書いた範囲 ^4.17.0 がそのまま残るのに対し、package-lock.json の version フィールドには範囲内で実際に解決された厳密なバージョンが入っているのが確認できるはずだ。package.json の範囲指定と package-lock.json の解決結果が別物であること、それがここまで説明してきた仕組みの実物だ。
- package.json に書いたバージョンが常にインストールされる — キャレットやチルダ付きのバージョンは範囲の指定であり、実際に選ばれる1バージョンは
package-lock.jsonが記録するまで確定しない。 - package-lock.json は生成物なのでコミットしなくてよい — lockfile をコミットしないと環境ごとに依存解決がやり直され、間接依存のわずかなバージョン差から再現しないバグが生まれる。
- npm ci は npm install の単に速い版だ — 速さは副次的な効果で、本質は package.json と lockfile の矛盾を許さず lockfile を書き換えない点にある。
- SemVer(Semantic Versioning)
- メジャー.マイナー.パッチの3つの数字でバージョンの互換性を表す取り決め。
- キャレット(^) / チルダ(~)
- package.json でバージョン範囲を書く記号。^ はマイナー・パッチの更新を許し、~ はパッチの更新のみを許す。
- package-lock.json
- 依存解決の結果を厳密なバージョンと integrity ハッシュで固定した記録ファイル。
- integrity(Subresource Integrity)
- パッケージ内容が期待通りかを検証する SHA-512 ベースのハッシュ値。
- npm ci
- package.json と lockfile の矛盾を許さず、lockfile を書き換えずに node_modules を再現するコマンド。
- go.sum
- Go の依存モジュールごとにファイルツリーと go.mod のハッシュを記録し、改ざん検知に使うファイル。
- package-lock.json | npm Docs — lockfileVersion の変遷や integrity フィールドの定義を一次情報として確認できる。
- Go Modules Reference — go.sum files — go.sum の2行構造と Checksum Database による検証フローが載っている。