LoginSignup
4
1

More than 3 years have passed since last update.

[npm]CI/CDのエラーで見たpackage-lock.jsonが担う厳密なバージョン管理の重要性

Posted at

Overview

一般公開するソフトウェアはすべからく自分以外が作成したソフトウェアの力を借りて実現しているといっても過言ではないだろう。
それはつまり、自分以外の誰かに強く依存していることになる。人はだれしもミスをする。
もし自分以外の誰かがミスした場合、自分もその影響を受けることを意味する。
そういう時にしっかりと防御、もしくは復旧する手段を知っておかないとサービスの提供を維持できなくなる可能性がある。

今回はCI/CD、具体的にはCloud Buildでテストを実行した際、エラーになった体験をメモで残しておく。

Target reader

  • バージョン管理に自信のない方。

Prerequisite

  • 言語はNode.js。
  • パッケージの管理はnpm。
  • CI/CDはGoogle Cloud PlatformのCloud Buildを利用する。

具体例としてNode.js&npmでの話になるが、本質はパッケージ管理の話なので別の言語でも同様の問題が起こりうるはず。

Body

どういう問題が発生したのか?

ローカル環境で開発を行い、GitHubにプッシュした。
GitHubにプッシュしたことで、Cloud BuildがテストしてGoogle Cloud Fucntionsにデプロイする予定だった。
しかし、Cloud環境ではエラーが発生し、デプロイができなかった。
ローカルでは問題ないのに、Cloud環境ではエラーとなる環境に依存した問題。

具体的にはNode.jsのテストフレームワークであるJestのバージョンアップに伴うバグが原因だった。
どうしてローカルでは問題が発生しなかったのか?を解説していく。

パッケージバージョンの話

Jestをどのように取り込んでいたかを以下に抜粋する。

package.json
{
  "devDependencies": {
    "jest": "^25.1.0",
  }
}

Jestのバージョンを指定して取り込んでいるのだが、^についての説明が必要になる。
その前にバージョンの各桁の意味の説明が必要。

npmではセマンティックバージョニングというものが採用されている。
この仕組みにより、パッケージを導入する側である程度バージョンのコントロールが可能になる。

jestの例では、一番左の25がメジャーバージョンで、下位互換性を損なう修正があるとこの数値が上がる。
つまり、この数値が上がった場合は必ずテストを実施して、必要なら既存コードを修正する必要がある。
次に中間の1はマイナーバージョンで、下位互換性がありながら新しい機能が追加されたときにこの数値が上がる。
機能の追加が主なので、既存コードに影響が出る可能性は低い。
最後に右の0はパッチバージョンで、下位互換性を維持したバグ修正があったときにこの数値が上がる。
原則としてバグ修正なので、既存コードに影響が出る可能性は極めて低い。

詳しい解説は公式ドキュメントを参考してほしい。
https://docs.npmjs.com/about-semantic-versioning

お預けになっていた^の意味とは?
各パッケージのバージョンを完全に指定して取り込んでしまうと、パッチバージョンの修正すら取り込む機会を失ってしまう。
バグ修正は迅速に取り込むべきである、その一方で互換性を崩すような修正は取り込みたくない。
そういう時に^を付与すると、メジャーバージョン未満のバージョンアップを受け入れるという指定になる。
つまり、互換性を維持しているから、最大限バージョンアップは取り込むといういいとこどりをする姿勢ということだ。

...そう、それが正ならね:sob:

セマンティックバージョニングは紳士協定

セマンティックバージョニングは第三者が判定したものでなければ、厳密な仕分けを通過したものでもない。
あくまでもパッケージの管理者が今回の修正はどれに該当するかあてはめたものに過ぎない。
そして、人間のかかわるものにはミスが混入する可能性がある。
マイナーバージョンまでのバージョンアップなら下位互換性を維持するというものの、下位互換性を破壊したリリースが混入する可能性があることを理解しておかなくてはならない。

起こってしまった下位互換性の破壊

Jestの25.2.0では、バグにより下位互換性の破壊が起こってしまった。
内容を知る必要はないが、知りたい方はこちら。
https://github.com/facebook/jest/issues/9710

24時間も経過しないうちに25.2.2がリリースされ、この破壊は修正された。
バージョンを^で指定しておけば、インストール時に自動的に修正されたパッケージがインストールできるのはありがたい仕組みだ。
しかし、リリースするまでの間は破壊されたバージョンが最新のため、自分の身を守る手段を持っておかなくてはいけない。

Cloud環境でエラーになった原因

これでようやくCloud環境でエラーになった原因を説明する準備が整った。

Jestのバージョン^25.1.0の指定で開発を行っていた。
ローカル環境ではJestのバージョンを25.1.0がインストールされていた。
Cloud BuildによるCI/CDでは毎回新規のインストールが発生する。
最新バージョンが25.1.0の場合は問題ないが、最新バージョンが25.2.0になると、Cloud環境では25.2.0をインストールする。
つまり、ローカルとCloudでパッケージのバージョンが異なってしまう。
通常なら下位互換性があるから問題ないが、今回に関してはバグを含んでいたため、見事に既存コードでエラーが発生するようになる。

package.jsonだけでは解決できなかった依存関係の先の依存関係のバージョン問題

Jestのバージョンアップによって異常が生じたのはすぐにわかった。
Jestのバージョンを25.1.0で固定して投入すれば問題は解決する…はずだった:scream:
しかし予想に反してエラーの連発という現実がった。
リリースまでの時間がないため、テストをクラウド上では実施せずに投入することになった。
のちに確認したところ恐らくこういうことだ。

Jestのpackage.json
{
  "name": "jest",
  "version": "25.1.0",
  "dependencies": {
    "@jest/core": "^25.1.0",
    "import-local": "^3.0.2",
    "jest-cli": "^25.1.0"
  },

Jestは@jest/coreというパッケージを含んでおり、そのバージョン指定は^25.1.0である。
つまり、Jest自体のバージョンを固定しても、Jestのその先のパッケージは依然として最新の^25.2.0を採用してしまう。
過去にバージョンアップによる不具合のためバージョンを固定したことがあるが、直接の依存関係のパッケージの不具合だったからpackage.jsonのみで対処できた。
しかし今回は依存先のその先に不具合があったため、過去の対応では対処できなかった。
もしかしたら、package.jsonのみで対応できる方法があるかもしえれないが、完全な解決策になりえないためそれは追わないことにする。

Git管理から外してしまっていたpackage-lock.json

数年前に突如package-lock.jsonができた記憶がある。
当時はキャッシュが壊れることも少なくなく、package.jsonだけを頼りにクリーンインストールすることが度々あった。
その影響からか、package-lock.jsonをGit管理から外すことが習慣化してしまっていた。

しかし、package-lock.jsonこそがCI/CDやるなら必須のファイルであることをようやく理解した数年後の今。
公式ドキュメントの言葉を借りるならこうだ。
https://docs.npmjs.com/configuring-npm/package-lock-json.html

Describe a single representation of a dependency tree such that teammates, deployments, and continuous integration are guaranteed to install exactly the same dependencies.

Provide a facility for users to “time-travel” to previous states of node_modules without having to commit the directory itself.

Google翻訳先生曰く

チームメイト、デプロイメント、継続的インテグレーションがまったく同じ依存関係を確実にインストールできるように、依存関係ツリーの単一の表現を説明します。

ユーザーnode_modulesがディレクトリ自体をコミットする必要なく、以前の状態に「タイムトラベル」するための機能を提供します。

何を言っているのかわからないとおもうが by ポルナレフ

package.jsonでは直接の依存関係のバージョン固定までしかできない。
しかし、直接取り込んだパッケージが10個程度でも、依存関係のその先の依存関係をたどると1000個程度になるのは珍しくない。
残りの990個のパッケージのバージョン管理というか、どのバージョンをインストールしたかを記録しているのがpackage-lock.jsonである。
全てのパッケージのバージョンを記録しておくことで、バージョン管理しないnode_modulesフォルダの構成を完全に再現できるというわけだ。

バージョンが25.2.3に進んでしまっているが、現在のpackage-lock.jsonを抜粋する。

pacakage-lock.json
    "jest": {
      "version": "25.2.3",
      "resolved": "https://registry.npmjs.org/jest/-/jest-25.2.3.tgz",
      "integrity": "sha512-UbUmyGeZt0/sCIj/zsWOY0qFfQsx2qEFIZp0iEj8yVH6qASfR22fJOf12gFuSPsdSufam+llZBB0MdXWCg6EEQ==",
      "dev": true,
      "requires": {
        "@jest/core": "^25.2.3",
        "import-local": "^3.0.2",
        "jest-cli": "^25.2.3"
      },
    },
    "@jest/core": {
      "version": "25.2.3",
      "resolved": "https://registry.npmjs.org/@jest/core/-/core-25.2.3.tgz",
      "integrity": "sha512-Ifz3aEkGvZhwijLMmWa7sloZVEMdxpzjFv3CKHv3eRYRShTN8no6DmyvvxaZBjLalOlRalJ7HDgc733J48tSuw==",
      "dev": true,
      "requires": {
        // 省略
      }
    }

Jestはバージョン^25.2.3@jest/coreを要求している。
@jest/coreはバージョン25.2.3をインストールしている。
これが問題の発生した場合にpackage-lock.jsonを記録していればこうなっていたはずだ。
Jestはバージョン^25.1.0@jest/coreを要求している。
@jest/coreはバージョン25.1.0をインストールしている。
つまり、最新は25.2.0でもpackage-lock.jsonの記録より25.1.0をインストールし、エラーの発生はなかったわけだ。

pacakage-lock.jsonは完全に再現するというだけあって、integrity(整合性)にSHA512を使ってURLだけでなくハッシュを見ることで置き換えも見逃さない作りになっている模様。

package-lock.jsonを使ってインストールするには

package-lock.jsonはnpm installnpm updateで更新される。
変化があったら更新されるのは仕組み上当然だ。
package-lock.jsonをみて更新する仕組みがあるはずとググっていると、npm ciというコマンドで実行可能らしい。
https://docs.npmjs.com/cli/ci.html

npm ciでもいいのだが、npm cinpm testを連続で実行するnpm citを推しておきたい。
https://docs.npmjs.com/cli/install-ci-test.html
余談だがnpm installにもテストがセットになったコマンドがあった。
https://docs.npmjs.com/cli/install-test.html
使用頻度からするとnpm updateにあったほうがありがたい気がするんだが…

これを採用すると、Cloud Buildの構成ファイルは以下のようになり、npm関連が1ステップですっきりする。

cloudbuild.yaml
steps:
- name: 'gcr.io/cloud-builders/npm:current'
  args: ['cit']
  dir: 'functions/autodeploy'
- name: 'gcr.io/cloud-builders/gcloud'
  args: ['functions', 'deploy', 'sample', '--trigger-topic', 'sample', '--runtime', 'nodejs10', '--region=asia-northeast1', '--memory', '2048', '--source', '<your-source>']
  dir: 'functions/autodeploy'

こうすることでようやくCloud環境でのみエラーが発生する事態を回避できるようになったはずだ(予定)

Conclusion

クラウドでのビルド時は厳密にはバージョン進むことがあるよね…という小さな懸念をようやく解決した。
ソース管理は当然として、パッケージのバージョンもそれに含め、かつCI/CDの環境に完全に再現できてようやくコントロールしているといえるだろう。
Node.jsなら自分のCI/CD環境でnpm installを使用していないか確認してほしい。

最後に、気が付いた方がいるかもしれないがcloudbuild.yamlの最初にこう定義している。
gcr.io/cloud-builders/npm:current
実はこれ、npmのバージョンは最新を使用するということでバージョン固定していないのだ。
痛い目を見たけどそれでもなお手抜きをするのが私だ、問題に対峙することで成長するので:joy:

Have a great day!

References

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1