作者 | 朱烨
关于最佳实践
本系列内容是我们在不同项目的维护过程中总结的关于DevOps/SRE方面的优秀实践,我们将致力于在项目上尽最大的努力来推行这些优秀实践。我们希望这些最佳实践能对项目的稳定运营提供帮助,也希望刚接触DevOps/SRE的新人能通过学习这些优秀实践来提升自己在这方面的水平。
因为DevOps/SRE涉及到的方方面面比较多,一次性完成的工作量太大,所以我们决定分阶段来完成,这一次发布的是“应用开发和部署”这个部分的内容,后续我们将逐步发布“云平台与网络”,“操作系统和服务”,“用户与权限”,“监控与可视化”,“数据与备份”,“敏感数据”,“故障与应急响应”这几部分的内容。
所谓“最佳实践”应该是最适合自己的实践,而不一定是最先进的,而且每一种实践本身也存在一定的局限性,所以我们在描述了对应实践的优点的同时,也把可能存在的缺点写了出来,就是希望大家在看到它的好处的时候,也能知道可能存在的风险在那里,理性地去评估到底是不是要采用相应的实践,所以这里总结的最佳实践请适度取用,不要为了“最佳”而实践。
我们深知自己在诸多方面存在一定的局限性,相关的内容可能存在一些不足,而且优秀实践本身会随着技术更新等因素不停地变化,我们将会把蓝皮书内容同步发布在Github上(https://github.com/toc-lib/DevOps-SRE-best-practice) ,希望引发更广范围的传播和讨论。也请使用PR或Issue的方式来提出你的不同的观点和更好的建议,谢谢。
应用开发和部署
使用牲口模式
在传统的运维环境中,由于条件的限制无法快速的提供新的基础设施和环境,所以通常在业务的依赖环境如操作系统内核,服务,类库,运行时版本等需要变化时,我们会根据需要在现有的环境上做持续性变更。而且我们还可能会在机器上运行一些临时任务,做调试和排错等,很多的时候,这些操作对应的变化并不具有可追溯性,甚至不可以恢复到之前的状态。这样,刚开始统一配置的无差别的一批机器随着时间的推移慢慢的就会变得各自具有一些独有的特性。另外还有一些类型的服务,比如数据库,存储等,其业务本质就导致了集群中的每一台机器具有独特的属性。当我们在维护这些服务的时候,需要根据每台机器的特性来做不同的管理和配置,而且一旦机器出现故障的时候,也很难去创建出一样的机器来替代。因为这种情形和养宠物类似,比如我们会给宠物起一个名字,它也需要悉心照料,生病的时候要带去看病,所以我们称这种服务模式为宠物模式。
而在具有云原生能力的平台上,我们可以按需定制基础镜像,也能快速的从这个基础镜像中创建出运行环境,我们的变更就可以基于基础镜像来做更新和版本迭代。这样当某一台机器发生了故障,我们可以快速的复制出一台一模一样的机器来替代。如果需要做一些临行性的操作和变化,在任务结束之后,也可以销毁这台已经发生了变化的机器,使用一台新的机器来替代,使整个集群恢复到一个最初的收敛状态。这个场景和我们现实生活中的规模化牲口养殖类似,对应的我们称这种服务模式为牲口模式。
大家所熟知的无状态应用,就是牲口模式的最常用的一种实现方式。在业务的设计和实施过程中,我们建议把逻辑和数据分离,在逻辑运行环境不要兼顾数据存储工作,比如请求的session相关的数据,不要保存在本地,而是把它放在一个共享数据服务中,从而达到无状态的目的,这样就可以对逻辑运行环境进行牲口化的管理方式。
优点:
- 可随时被销毁或替换,结合自动化基础设施和监控,自动完成对故障机器或节点的替换。
- 配合自动化基础设施和监控,可实现自动水平伸缩,从容应对业务峰谷,节约成本。
- 在不影响服务稳定性的前提下可部署所需要版本的应用、进行系统升级或者打补丁。
- 监控和管理的重心不再是具体的单一资源的使用率,而是整体的承载能力和更深层次的性能关注点。
缺点:
- 需要基础设施平台具有相应的能力支撑,否则很难实现。
- 不是所有的业务类型都能做牲口模式设计,比如数据库。
实施要点:
- 除计算和业务处理过程中的临时产生的数据,数据的来源和最终的持久化应由外部服务来提供,如独立的内存型数据库或者关系型数据库。
- 可以使用客户端Cookie、cache取代外部数据服务。如果有敏感数据,服务器端可以加密后交由客户端存储,在之后的请求时发回服务器解密使用。
- 通过锁或者幂等性设计,使得应用能正确、快速、自动地解决对同一份数据的竞争而导致的流程异常、数据不一致等问题。例如,多个定时任务同时处理一批数据。
使业务升级向前兼容
向前兼容指低版本的系统、程序或技术能优雅处理(例如:忽略其不理解的部分)高版本的系统、程序或技术。向前兼容技术的目标是让旧系统能够识别为新系统生成的数据,简单的说就是旧版本的系统可以接受新版本的数据,是旧版本对新版本的兼容。
我们建议在做业务升级时候,设计你的业务具有向前兼容的能力,以应对升级失败时某一功能模块或者依赖无法随之回滚的风险。比如说在有数据库字段变化的升级中,在正式对数据库做变动之前,基于旧的业务流程做代码层面更新,使其可以兼容数据库将要发生的改动并加以部署。在数据库升级完成之后,如果新的业务流程上线后不幸出现重大的问题等情况需要回滚时,回滚之后的代码仍然可以兼容数据库的变化,而不用对数据库也进行回滚,毕竟数据库的回滚成本非常高。
优点:
- 可以在新版本出现不容易修复和存在重大的风险的时候快速地回滚到旧的版本,业务中断的可能性会大大降低。
- 即使整个系统中存在不可回滚的部分,但我们不用花费很多的精力去考虑和解决完全不可回滚的问题。
缺点:
- 设计成本:要做到兼容未来的变化。这听起来就很难。一开始很难获知所有用例、极端案例和业务理解。回顾过去并说这是一个错误的决定很容易,今天做出明天不会后悔的决定要困难得多。
- 为了同时兼容两种数据格式,需要在代码中增加额外的处理逻辑,增加复杂度和投入的成本。
实施要点:
- select语句只获取需要的字段,避免使用select * from语句,有效防止新增字段对应用逻辑的影响,还能减少对性能的影响。
- 对数据库表结构变更通过新增字段实现。
- 尽量新增接口,避免对现有接口做修改,如需要修改现有接口,可尝试在接口上增加版本标识。
使用唯一性标识给镜像打标签
当生成容器镜像时,应当使用唯一性标识来给容器镜像打标签,唯一标识可以更好的标记当次生成的镜像,避免出现多个同名标签但不同的版本镜像被使用的情况。例如多次部署都使用了latest标签的镜像,可能因为拉取和缓存策略导致不同节点使用了不同版本的镜像,从而导致功能上的不一致,在这种情况下,并不能很方便地判断出某个节点部署的是哪一个版本。
唯一标识最好有一定的含义,不仅可以用来区分产物,还可以获取到本次构建的关键信息。比如git提交哈希等关联性比较强的标识。虽然时间戳也是一个唯一性比较强的标识,但是关联性相对较差,如果长度不足,也有一定的几率产生碰撞。可以考虑使用组合型标签,比如使用时间戳,build号,版本号等根据自己的需求来组合生成唯一标识,这样的标签本身就包含了很丰富的信息。
不建议单纯使用pipeline的build序号来作为镜像的标签,如果需要更换CI工具或者重建pipeline时,这个序号将会被重置而可能产生重复,除非在构建脚本中加入偏移量。而且不同的CI工具获取这个序号的方法也有所不同,对于迁移并不友好。虽然它的可追溯性看起来较好,但是单纯的Build序号和代码之间并没有直接的关联。
如果不是需要对外公开发布的镜像,并不建议对同一镜像打上多个不同标签。因为绝大部分的情况下,我们只会选用其中一个标签在所有的地方使用,多个标签的实际意义并不会很大。
如果制品库支持immutable特性,强烈建议开启这个功能,防止因为意外情况导致对已上传的镜像的覆盖。
优点:
- 可以准确对应的到源代码具体版本,在溯源时可以对应到特定的提交而不是可能存在的多个提交。
- 不需要使用SHA256等额外的信息来区分同一标签的不同版本。
缺点:
- 一些类型的唯一性标识可读性不是很高,比如git提交哈希。
- 一些类型的标识受时间影响,不能使用同一命令获得一致结果,需要使用其他的方式来传递给后续阶段,比如时间戳。
- 制品库immutable功能开启之后,重跑已完成构建镜像的pipeline会发生上传镜像失败的错误,有可能会导致后续任务不能继续。
实施示例:
在所有环境中使用同一个构建产物
应该在不同环境中使用相同的构建产物来部署,避免对不同的环境生成不同的构建产物,以确保环境的一致性,同时也保证部署在不同环境中的业务代码是测试和验证通过的。比如某次的构建产物,在测试环境部署后经由测试人员和相关的自动化测试工具完成相关的测试验证,如果没有问题才会继续部署到后续环境中,应继续使用该产物部署后面的环境,不建议重新构建新的产物来做后续环境的部署,也不建议覆盖之前的构建产物标识。因为在现有流行的语言和框架中,普遍存在大量的第三方依赖,即便是同一份源代码,由于其依赖以及构建环境的不同,会有一定几率出现由于外部依赖的更新导致构建产物存在差异,从而产生非预期的情况出现。
优点:
- 确保所有的环境部署的构建产物是一样的,尽可能的保证环境的一致性。
- 确保部署到生产环境的产物是测试验证之后并无变化的,避免出现非预期的差异。
缺点:
- 对于如前端这类纯静态资源的应用,由于不同的环境需要连接不同的后端服务地址,因此无法直接使用唯一的构建产物。可以考虑在业务启动阶段,用一些额外的启动脚本或命令配合传入环境变量或参数来修改配置文件,从而达到所有环境使用同一个构建产物的目的。
下面例子展示了在使用nginx的容器镜像里,通过在CMD指令里面先执行一段脚本来对配置进行修改,来达到在容器运行时根据传入的环境变量WEB_ENV的值来访问对应环境的后端服务的目的。
- 对于移动端app,也存在与前端应用类似的问题,需要开发人员做额外设计和开发,在app启动时判断需要进入什么样的运行模式。
实施要点:
- 在设计CICD流水线时,将构建产物同步到制品库时,给该产物打上唯一标识。
- 如制品库支持,开启制品库的immutable特性。
- 将该唯一标识传递到在后面所有的部署流水线任务中,所有的部署任务都使用该唯一标识所指向的构建产物。
- 如果需要在多个制品库保存同一个构建产物,建议在上传成功之后对构建产物做完整性检查。
减少脚本/工具对环境的依赖
一般情况下,脚本都会或多或少的使用到一些外部工具。而我们的脚本很有可能会运行在不同的环境中,不同环境中提供的工具也会有版本和用法的差异。如果需要在环境中维护某一工具的多个版本的,工具本身的版本管理,以及多个工具之间的依赖冲突和升级更新也会产生较高的管理和维护成本。
我们建议尽可能的减少所使用的工具对环境的依赖,尤其是系统不会默认安装的工具。另外在编写脚本的时候,也尽量避免使用只有某些版本特有的语法特性。这些情况都会导致脚本有可能出现一些不可预期的结果。我们建议使用容器化工具或者容器化环境管理工具如Batect来替代对应的需求。
优点:
- CI/CD agent中只需要安装容器运行时即可,可以减小agent的体积。
- 容器化的工具因为对环境的依赖非常低,所以不论是工具升级还是降级都非常简单,同时也解耦了对agent特性的依赖,提高agent利用率。
- 最大化的保证环境一致性,使用容器化的工具消除了环境差异可能导致的非预期异常。
- 新人友好,新加入的团队成员可以快速的配置好可运行的环境,无需过多的考虑具体工具的安装,配置等。
解耦对CI/CD工具的依赖,虽然在实际项目中很少会有更换CI/CD工具的情况,但是如果需要迁移,我们也只需在新的工具环境中构建出容器运行环境即可,大大减少了切换工具工作量,提高迁移的速度。
缺点:
- 因为没有预装构建所需要的各种软件,如果本地没有镜像缓存,在运行容器化的工具时都需要去容器仓库中获取对应的工具镜像,会有额外的带宽压力。
- 因为需要获取工具镜像,容器启动也比二进制的程序要慢,所以整个任务运行过程需要的时间会更长。
- 理论上来讲,容器化技术性能损耗很小,工具的性能和二进制程序的差别不会很大,但是在实际的使用中,我们发现因为容器引擎配置不当等原因会导致一些工具性能变差甚至无响应的情况出现。
实施示例:
在使用 terraform 时,不同版本之间的 terraform 并不兼容,那么如何保证所有人与 CI 都使用相同的 terraform 版本就是一个非常麻烦的事情。那么如果我们无论在 CI 还是本地都基于 docker 去运行 terraform 就可以解决这个问题。
使用auto/ACTION模式来维护管理脚本
auto/ACTION是我们在项目实践中总结并希望可以广泛推广的一个经验总结,在和客户合作过程中,尤其是有很多团队的大型项目上,我们从这个模式中受益匪浅。auto/ACTION模式的核心是使用统一语义能表明脚本目的的ACTION来命名管理脚本,如应用的测试(test),验证(validate),打包(build),发布(deploy)等相关任务,统一把这些管理脚本归放在auto目录下来维护。
因为类unix系统在运行的时候并不真正使用文件后缀来识别文件的类型,我们建议脚本名字不要加后缀。这个建议是基于管理脚本有可能会在多个地方被使用,而不同的开发和维护人员对于语言的偏好不同,如果在需要使用另外一种语言重写脚本的时候,使用这个脚本的地方就不需要做更新,消除了因为文件名变化可能导致的自动化任务的错误和中断。虽然没有后缀可能会带来一些不便,比如编辑器的语言类型识别错误等,但是相对于它带来的优点,还是非常值得的。
优点:
- 管理代码和业务代码放在同一代码库,使用版本控制,便于进行更新,回退。
- 每个脚本只做一件事,职责单一,同时便于理解和管理。
- 可以方便的知道所有可用的脚本。如:
- 如果跨团队合作,或者团队成员有轮换的时候,可以更快速的掌握业务管理的上下文。
- 每个项目都有一套自己的auto脚本,如果有基础性变化,改动成本较高,可以考虑使用git submodule等模式来管理。
- 没有后缀的文件名会带来一些管理上的不便。
实施要点:
- 脚本满足既可在本地执行,又能在CI流水线上执行,便于验证。
- 脚本中的变量内容尽可能从环境变量中读取,避免向脚本中传入参数,方便运行。
- 专属于CI/CD平台的脚本不要放在auto根目录下,建议创建一个对应的子目录,例如 .buildkite, .github, .travis来做管理。
- 可根据团队的需求适当的扩展脚本的名字使之更容易理解,建议使用-而非_ 来分隔单词, 如auto/upload-image-to-ecr。
管理脚本和业务脚本分离
我们的应用中一般都会有一些脚本来做一些辅助性的工作。这些脚本通常会和业务代码放在同一个代码仓库,使用版本控制来进行管理。这些脚本大致分为两种:管理脚本和业务脚本。
管理脚本是用来做应用打包,部署等管理相关工作工作,这种类型的脚本是无需打包进业务运行所需的产出物中的;业务脚本是辅助业务运行,比如说初始化环境和配置,结束时的清理工作等,这些脚本需要打包到业务运行的产出物中。
我们建议除了一些有特殊要求的脚本外,不要把脚本放在根目录。并且把这两种不同类型的脚本存放在不同的目录中。
优点:
- 在封装镜像时,业务脚本和业务代码同等重要,需要封装在镜像中。将管理脚本和业务脚本分离可以减少镜像中的文件数量。
- 在软件开发过程中,针对业务运行和自动化管理关注信息不一样,将管理脚本和业务脚本分离,让团队成员更加清楚脚本的类型和目的。
缺点:
- 将管理脚本和业务脚本分离,会增加仓库的层次结构。
实施要点:
- 推荐将管理脚本放置在auto目录,将业务脚本放置在scripts目录。
- 脚本中的变量采用从环境变量中读取,避免向脚本中传入参数,方便运行。
- 推荐脚本名称即表明脚本的作用,不建议使用auto/script这样不表意的脚本命名。
- 不在文件名中使用文件类型后缀。
及时更新容器的基础镜像
基础镜像是业务镜像的地基,其包含了我们业务和应用所必需的基础库、二进制文件和配置文件等。一个良好维护的基础镜像通常会根据需要做更新,这些更新通常包含安全补丁,新功能或对操作系统或框架的改进等,我们建议及时的更新容器的基础镜像来保障业务的安全性。除非有特定原因需要继续使用旧版本镜像,否则应及时跟进使用经过充分评估和测试的最新版本镜像。
在Dockerfile和compose等文件中,可以通过指定镜像中的标识和sha256值组合来指定基础镜像的版本。当镜像有了更新之后,及时沿用了如latest或大版本号这类通用性比较高的标签时,其sha256的值也会发生变化,通过更新这个组合可以更新使用最新版本的基础镜像。
优点:
- 最新的镜像通常带有可以增强应用程序安全性的补丁修复,降低安全风险。
- 最新的镜像通常包括可以提高应用程序性能的新功能或改进功能。
缺点:
- 新功能可能存在不可预期的bug。
- 新的功能有非常小的概率存在未知的安全漏洞,如果有特殊的安全需求,请在安全部门的指导下升级。
实施示例:
- 可以使用dfresh或者类似的工具来检查和更新基础镜像。
- 检查基础镜像是否有更新
- 更新基础镜像
- 回退方法 在需要回退基础镜像版本时,可从代码库的提交找到上一个可用版本的相应信息。
定期检查和升级依赖包
随着 Bug 修复、新功能的开发或者其他更新,我们应用的依赖包可能会过时。此时应用的依赖项越多,就越难跟上这些更新。过时的依赖包可能对安全构成威胁,并对性能产生负面影响。最新的软件包可防止漏洞,这意味着定期的依赖性检查和更新很重要。我们建议定期的对应用的依赖包做更新和安全检查,并升级到一个合适的版本。并且我们建议在应用的 pipeline 中加入这些检查任务,并在常规的开发过程中及时发现和升级。如果应用已经处于维护阶段,我们也建议定期执行这些检查并在需要的时候加以升级。
优点:
- 定期升级依赖可以让应用的安全性和代码的可用性都有保障。
- 定期升级依赖会让解决依赖版本冲突和代码兼容性变得容易。
- 更新依赖项可以获得新的依赖项版本提供的所有性能改进。这些改进可以有多种形式,例如修复以前的性能问题、改进了实现和算法等。
- 升级依赖项不仅可以改进现有功能,还可以使用到以前不存在的新功能。这些新功能最终可能让我们更好的实现自己应用的新功能。
缺点:
- 如果不及时更新依赖,将会使得产品难以维护,并可能导致开发人员的时间被常规的、无意义的工作占用。
- 如果长期不更新依赖,会使应用面临无人问津的风险,之后在某一天需要进行改动的时候,面临大量的依赖包过期无法获取和版本升级造成的接口变化。这时就需要投入非常高的成本来让代码重新变得可用,甚至完全无法更新而变成遗留系统。
- 当进行大的版本升级时,需要对应用程序进行更多的更改才能与较新的库兼容。这使得付出代价比及时更新依赖大得多。
- 如果忽略升级依赖项,那么会面临无法在自己喜欢的平台上运行软件的可能。例如,如果停止升级软件中的数据库驱动程序,那么将无法使用旧版本的数据库系统。这不仅会使应用变得过时且易受攻击,而且甚至可能无法从该数据库系统提供商处获得任何支持。
- 如果应用依赖于过时的依赖项而导致升级困难变得很难维护,会使得项目很难找到对这些旧技术有经验的人,甚至失去现有的维护者。
实施示例:
(1) 手动检查
JS 篇:
- npm-outdated & npm-update
- npm outdated:可以使用 npm outdated 获取当前需要升级的包的信息。
- npm update: 会把所有的包升级到我们定义的需要的版本号。如果需要升级到最新的则需要使用@latest eg: npm update cypress@latest。
- npm-check-updates: 是一种更高级的检查工具
首先需要全局安装 npm-check-updates: npm install -g npm-check-updates ;
ncu: 检查需要升级的包信息,这里类似 npm outdated;
ncu --upgrade/ncu -u: 将所有的包升级到最新版本,即便是包含重大更改,也会进行更新。注意:更新完成后不会自动运行 npm install,所以还需要再手动执行来更新 package-lock.json。
ncu --interactive/ncu -i : interactive mode 安装某个包。
小结:npm-outdated 和 npm-check-updates都可以用来做 JS项目的包检查、升级。
Java 篇:
- 在 build.gradle中配置 owasp.dependency-check
- 执行./gradlew dependencyCheckAnalyze
查看报告:项目根目录>build>reports>dependency-check-report.html
(2) CI Pipeline 集成
- npm-check-updates 与 Buildkite Pipeline 的集成由于 buildkite 没有官方插件支持 dependency-check。所以对于buildkite 推荐两种方式:
- 自己开发对应功能的插件,然后集成到 pipeline 的 step 中;
- 通过 docker-compose 的方式去运行对应的检查,将其在 pipeline 的 step 中去运行(如果需要可以添加 block 来强制检查 npm-check-updates 的结果)。
- jenkins pipeline 的集成:需要安装 dependency-check Plugin。步骤如下:
- 在 Jenkins Global Tool Configuration 安装 dependency-check;
- 在 Jenkins builder 配置已经安装好的 dependency-check;
- 在 Jenkins Publish 里配置读取 dependency-check 的 report ,通过对相关指标进行读取,设置阈值,配置构建失败或者警告等设置。
如果项目 code 托管在 Github,我们可以使用 Dependabot 和 Renovate 工具和 Github 集成来做依赖检查。这两个工具都会做定期扫描,创建依赖版本升级的 PR。
配置 Dependabot 进行版本更新:
- 在 GitHub 的代码仓库的主页,找到代码仓库名称下的 setting;
- 在边栏的安全性部分中,单击代码安全性和分析;
- 在代码安全和分析下,在Dependabot version updates右侧,单击启用以打开存储库 .github 目录中的基本 dependabot.yml 配置文件;
- 添加version;
- 添加 updates 部分,并输入希望 Dependabot 监视的每个包管理器的条目;
- 对于每个包管理器,可使用:
- package-ecosystem 指定包管理器。
- directory 指定清单或其他定义文件的位置。
- schedule.interval 指定检查新版本的频率。
- 在代码仓库的根目录创建.github目录;
- 创建 dependabot.yml文件并且存储到.github目录下。
示例 dependabot.yml:
配置 Renovate:
- 在 Github 的 App 里面安装 Renovate app https://github.com/apps/renovate;
- 安装并配置完成后可以在PR中看到一个自动生成的PR Configure Renovate,这个PR中包含一个 renovate.json 文件,这个文件中包含了 renovate 的一些默认设定;
- 可以根据文档 (https://docs.renovatebot.com/configuration-options/) 添加或者修改适合自身项目的具体配置项;
- merge 此 PR;
- Renovate 会根据你配置的 schedule 时间去自动的扫描并生成包升级 PR 提醒
定期的重新部署维护阶段的应用
在应用处于维护阶段,如果业务不再会增加新的功能,抑或因为某些原因无法做定期的应用依赖升级,我们也建议你定期的重新部署这个应用,以应对平台等更底层的变化带来的部署失败的风险。定期部署可以确保你的应用在新的平台环境中也可以正常的部署,如果在周期性的部署过程中发现应用无法在新的环境部署,你也会有一个缓冲期来制订应对策略,而不是在平台完成升级之后的某一天,应用发生了问题才发现已经无法部署。
优点:
- 定期部署应用是对部署工具和流程的有效验证,CI/CD Agent的一些升级有可能会导致我们在部署流程中使用工具发生兼容性问题,定期部署可以及早的发现这些问题。
- 定期部署应用也能够有效缩短我们的依赖获取未验证的窗口期。虽然我们的应用依赖可以锁定版本,也可以将依赖保存到私有仓库,但长时间没有运行相关部署流程,我们无法保证应用的依赖能够在需要的时候可正常获取且可用。
- 现在的应用的基础设施很多都基于各类云平台,服务提供商会定期的对自己的基础设施做升级和换代,定期部署应用可以让我们及早的获知基础设施变化带来的兼容性风险。
缺点:
- 定期部署会对系统的稳定运行造成一些影响,变化本身就会带来一定的未知风险。
- 自动化的发布一般情况下都需要配有完善的回归测试流程来确保业务的可用性,会带来成本的增加