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

#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をどのように取り込んでいたかを以下に抜粋する。

```json: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:
しかし予想に反してエラーの連発という現実がった。
リリースまでの時間がないため、テストをクラウド上では実施せずに投入することになった。
のちに確認したところ恐らくこういうことだ。

```json: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を抜粋する。

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

`npm ci`でもいいのだが、`npm ci`と`npm 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ステップですっきりする。

```yaml: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
https://docs.npmjs.com/files/package-locks