记package-lock引发的一次事故

去年因为升级npm包导致在开发环境崩了(相关记录)。昨天在生产环境遇见了一个更严重的问题:线上环境升级了thrift,其升级包的某个依赖未正确安装,导致node服务启动失败,线上走静态容灾两个多小时~

经过排查和总结,发现之前对于pageack-lock的机制理解存在误区,因此这里整理下package-lock的原理和注意事项,避免下次遇见相同的问题。

<!--more-->

1. 相关概念

1.1. 语义化版本

npm 包支持语义化版本,简单理解: XYZ 的格式,对应为: 主版本号.次版本号.修订号,版本号递增规则如下:

  • 主版本号:当你做了不兼容的 API 修改,
  • 次版本号:当你做了向下兼容的功能性新增,
  • 修订号:当你做了向下兼容的问题修正。

打开package.json我们会发现,大部分依赖包前面的都会有^或~的符号,他们的意思是,在使用npm install命令时:

  • ^会匹配最新的大版本依赖包,比如^1.2.3会匹配所有1.x.x的包,包括1.3.0,但是不包括2.0.0
  • 会匹配最近的小版本依赖包,比如1.2.3会匹配所有1.2.x版本,但是不包括1.3.0
  • 如果没有任何前置修饰符,则表示安装对应制定的版本号

默认使用-S或-D,不指定版本安装时,会自动添加^修饰符,因此在某些时候会造成版本的不兼容(所以说发版本包的时候版本号不能乱取)

1.2. Lock机制

package-lock.json是当 node_modulespackage.json发生变化时自动生成的文件。这个文件主要功能是确定当前安装的包的依赖,以便后续重新安装的时候生成相同的依赖,而忽略项目开发过程中有些依赖已经发生的更新

Lock机制是为了保证多人开发的统一性。什么是统一性?就是无论何时来了一个新人、换了个新电脑,我们npm i的包都是一致的。

随着项目越来越大,依赖越来越多,很难保证每一个npm包的最新版本都是适合的、有用的。Lock机制可以最大化解决此类冲突。在多人协作时同步开发环境。至于什么时候用新的包,到时候再同步lock文件就是。

2. package-lock的机制

2.1. 一个栗子

这里参考了网上的一个举例,用来说明package-lock的使用方法。

假设我们创建了一个新项目,它将使用express。 在运行npm init之后,在撰写本项目时,最新的express版本是4.15.4。 (默认情况下,npm 将安装最新版本)

因此在package.json中,"express":"^ 4.15.4"被添加作为依赖项。 假设明天,express的维护者会发布一个 bug 修复,所以最新版本变成了4.15.5。 然后,如果有人想要为我的项目做贡献,他们会克隆它,然后运行 npm install, 因为4.15.5是一个更高版本的主要版本,这是为他们安装的。 我们都有express依赖,但我们有两个不同的版本。 理论上,它们应该还是兼容的,但是也许这个 bug 会影响我们正在使用的功能,而我们的应用程序在使用Express版本4.15.4与4.15.5进行比较时会产生不同的结果。

而package-lock.json的作用就是用来保证我们的应用程序依赖之间的关系是一致的, 兼容的.

2.2. 是否需要把package-lock文件提交git版本库

由于Lock机制的主要目的是保证多人开发的版本一致,package-lock文件为的是让开发者知道只要你保存了源文件,到一个新的机器上、或者新的下载源,只要按照这个package-lock.json所标示的具体版本下载依赖库包,就能确保所有库包与你上次安装的完全一样。

基于以上原因,因此建议将package-lock文件锁定安装时的包的版本号,并且上传到git,以保证其他人在npm install时大家的依赖能保证一致。

官方文档的建议也是是提交到版本库,因此不能写到.gitignore

This file is intended to be committed into source repositories, and serves various purposes:

如果确定不把package-lock写到版本库,则必须清楚自己做的是什么~这样不能体会到其他依赖包的语义化版本修复了

参考问题:

2.3. package-lock的规则变化

自npm 5.0版本发布以来,npm i的规则发生了三次变化。

  • npm 5.0.x 版本,不管package.json怎么变,npm i 时都会根据lock文件下载
  • npm 5.1.0版本后 npm install 会无视lock文件 去下载最新的npm
  • npm 5.4.2版本后,
    • 如果改了package.json,且package.json和lock文件不同,那么执行npm i时npm会根据package中的版本号以及语义含义去下载最新的包,并更新至lock
    • 如果两者是同一状态,那么执行npm i,都会根据lock下载,不会理会package实际包的版本是否有新。

3. 事故分析

现在来分析下篇头提到的线上事故。

3.1. 过程描述

首先是环境版本,服务机器上使用的node版本是v8.4.0,对应的npm版本是v5.3.0,使用的是上面的版本2的规则。

然后介绍一下背景,由于历史问题,在我们的开发环境存在多个thrift编译版本,为了保证生产环境的thrift版本一致,在package.json中对应的版本号是固定的未任何修饰符的版本。

此外,生产服务器上设置的npm镜像地址是由公司内网维护的,当内网上没有对应的私有npm包时,会从taobao镜像拉取。

我们在.gitignore文件中将package-lock文件移除了版本库,因此服务器上保存的lock文件由npm i 自动生成并控制。

在最近的需求中对thrift进行了升级,从thrift@0.9.1升级到0.11.0。出现问题的库是thrift的依赖node-int64,从v0.3.1升级到了v0.4.0

you

3.2. 还原步骤

首先安装固定版本thrift@0.9.1,切换到thrift对应包目录下的package.json

"dependencies": {
    "node-int64": "~0.3.0",
    "nodeunit": "~0.8.0"
  },

依赖的是~小版本更新,会自动安装最新的0.3.x版本的node-int64

然后然后修改package.json中的版本号为hrift@0.11.0,删除node_modules目录后重新npm i,查看对应package.json

"dependencies": {
    "node-int64": "^0.4.0",
    "q": "^1.5.0",
    "ws": ">= 2.2.3"
  },

同时切换到对应的node-int64中,发现库版本已更新到0.4.0的版本,说明更新依赖成功了,同时查看lock文件中的node-int64版本,也已经更新为0.4.0

3.3. 分析

一般地,node_modules目录不会提交到版本库,取而代之的是通过package.json声明项目的依赖,其他人会通过克隆仓库然后执行npm i安装依赖并初识化项目。

前面提交我们的lock文件没有进入版本库,因此thrift@0.9.1时的lock文件对应的node-int64版本。重新更新部署时,脚本会删除node_modules并重新执行npm i,根据上面的规则2,npm install 会无视lock文件 去下载最新的npm,因此会更新package-lock的文件。这与我们上面还原步骤得到的结果一致。

那么,为什么会报node-int64包不存在的错误呢?这就显得有点诡异了,按照上面的流程更新时应该会安装对应的依赖才对。

后来发现,在某次代码commit时,由于编辑器的缘故,提交了对应的lock文件到代码库,之后进行了部署,然后又重新删除了该lock文件。相当于服务器上的拉取到了代码库中的lock文件,对比文件发现提交的lock文件中对应的node-int64版本为v0.3.3

参考上面规则2,修改package.json中的版本后,应该会忽略对应的lock文件,直接安装新版本并更新lock文件。这种规则也不会导致某个依赖包不安装的情况。

那么出现依赖包不存在的原因,只可能是在这次部署时执行npm i 时出现了某些异常,我们的持续部署流程出现了一些问题。由于事关线上用户访问,紧急的解决办法将node-int64手动写到package.json文件中,然后重新部署,这次依赖安装成功了,服务也正常启动了。

但是真正的原因是什么呢?我尝试搜索了"npm i 时某个依赖包未安装"等问题,却没有找到相关的答案,可能是一次环境导致的意外吧。由于找不到部署日志了,暂时只能排查到这里。

4. 小结

貌似到最后还没有解决这个问题,不过对应package-lock文件的作用有了大致了解。除了npm,其他JavaScript包管理工具甚至其他语言的包管理工具基本都存在类似的lock机制,如yarn-lockcomposer.lock等。

对于日常工作中常用的一些工具,不能仅限于使用而不去了解其原理,貌似最近都很浮躁,缺少静下心思考的时候,需要改一改。