“声明式”部署


基于 Helm 与 Kubeapps 的自动化部署

一种基于 Helm 封装的以 Git 仓库为单位的部署包管理模式,和一些吐槽。

部署困境

尽管已经使用了 Kubernetes,但很多时候部署并不是更新完 yaml 就结束了。

当前部署的困难在于环境隔离。我们的系统并不是公网服务,环境也是隔离的,这导致自动化的工作比较困难。

此外,由于在全国各地也有无数的环境,版本也不一,加上配置繁多,正确地从任意一个版本升级到新版是一件比较困难的事情。其中涉及到大量的人工操作,例如传输镜像包、配置等。数十个镜像和数百个配置文件,人工操作很容易缺漏、出错,导致每次部署都耗时很长。

一般来讲,大部分时候部署新版本依赖以下额外人工操作:

  • 更新配置,例如更新 Consul
  • 更新数据库

乍一看好像并不是很复杂(甚至非常简单),然而复杂的问题往往是简单问题叠加“历史原因”造成的。

由于“历史原因”(罪恶,你的名字叫历史包袱!),绝大部分现有的 Consul 配置是单个 key,在 json value 中再根据不同键区分不同服务的配置。

这带来了一个很显而易见的问题,所有的配置混杂在一起,更改困难。并且一旦某一个服务部署时,改错了配置,受影响的范围可能非常大。

此外,不同服务的不同配置都储存在一个 monorepo 里。由于历史上比较糟糕的工程化实践,正确地找到并更新这个 monnorepo 里的一部分配置也是一件非常困难的事情。

monorepo 也有 monorepo 的好处,至少在首次部署的时候非常方便。但问题是不同区域的服务有不同的版本,这时候想正确地从一个不知道多久前的版本更新到新版,绝大部分时候不是直接刷上最新配置即可的事情。据我所知,有的业务线部署甚至需要几天的时间(大多数时候是因为 monorepo 以及不规范带来的各种配置缺漏问题)。

这些问题在良好的工程实践中是比较难以想象的。

新的部署模式

鉴于 monorepo 维护配置具有诸多坏处,包括但不限于:

  • 没有权限区分以及缺乏 review,经常有人为了“能用”乱改配置、不按规范改,造成服务间配置耦合十分严重。
  • 开发网并不使用这套部署包,部署包的问题可能在部署当晚才发现。由于着急上线,很可能大量产生临时补丁,耦合和不规范性超级加倍。
  • 由于所有服务的配置都在一起,修改时可能并不在意是否引入了新的耦合。很多时候为了某个服务能工作反而修改了其他服务的配置。这导致了很多问题。
  • 部署包本身是一堆 Shell 脚本,维护比较困难。加上灵活性太大,经常有垃圾代码产生。

一个显而易见的改动,就是必须将不同服务的不同配置拆分到各自的 repo 中,而不是耦合在一个维护混乱的 monorepo 中。这些配置也应该引入强制性的规则,比如禁止操作其他服务(尤其是基础服务)的配置。

规范的作用是用来约束他人,提高代码的下限,保证起码的软件工程质量。

此外,单 key 的配置模式也必须要拆分,这涉及到许多插件与服务的改造,不过多叙述。

新的部署模式需要有如下的特征:

  • 开发与生产需要统一成一种部署方式
  • 单个服务升级方便
  • 服务升级不应该互相影响
  • 自动化更新所需的配置
  • 版本控制
  • 一些强制的规则检测,拒绝不符合规范的配置
  • 最好一键完成

有了这些需求,Helm 显而易见地是目前最简单的选择(当然,也有许多不足之处)。为了提供方便操作的界面(Helm 的命令行固然也可以用,但相对的 values.yaml 修改并不是很方便),还顺带引入了 Kubeapps(不必须)。

新的构建系统

Helm 本身是一种特别灵活的东西。从“历史原因”中考虑,灵活往往意味着不可控。大多数时候八股文可以解决的问题,就最好不要开放更多灵活性出来。

因此引入 Helm 时必须提供一组相对固定的配置,从这组配置里自动生成 Helm 包,而不是各服务自己从头编写。这还带来了一个好处,一些 yaml 的更新不需要通知到所有 repo 修改,只需要构建系统修改即可。

新的构建系统还有一个重要的目的就是减少部署过程中人力的干预,人工干预越少,错误就(相对来说)越少。

新的构建编写的系统能够:

  • 根据环境变量与项目自动填充 Chart.yaml
  • 根据构建脚本里填写的依赖服务,自动填充 Helm 的依赖项
  • 自动填充必须的 yaml 配置,如环境变量,probe 等
  • 自动检测特定目录存放的 Consul、SQL 等配置,为其生成 pre-install 等 hooks,实现配置的自动更新
    • 在这一步还检测了配置项是否违反了一些规则,例如禁止修改其他服务的 consul 配置、禁止修改其他库表、检测其他 SQL 规范等
  • 打好的 Helm 包推向 Harbor Helm 仓库;如果是 release 版本,还需要自动同步所需镜像、Helm 包到生产环境

这一套系统本质上是对 Helm 包的二次封装,增加了许多限制的同时将配置项规范化,减少了产生垃圾代码的可能。此外引入了几个自动化程序来代替人工更新配置。

这套系统还有一些比较奇怪的功能,比如 consul 配置的优先级等,比较业务相关,不过多阐述了。

有了这一套系统,勉强算是可以保证一个 git 提交完打包出来的东西可以直接无痛部署上线了。当然还有一些后续的痛苦工作,比如梳理几百个服务的依赖关系,将各种不合理依赖砍断,将一些服务下沉到基础域,等等,才算真正能保证服务升级不互相影响。


这套系统也没有丢失 monorepo “一键部署”的好处。

从 git 的 commit message 中可以判断是否是 release 版本。在实践中,根据是否是 release 版本可以导出不同的配置,例如:

if is_release; then
  export REPLICA=30
else
  export REPLICA=3
if

如果有一些额外的配置,例如不同环境的某些依赖服务路径不同,也可以通过 checkout 分支来实现。

这使得生成的 helm 包的 values.yaml 是直接可用的,无需人工配置,解放了人力。

由于一个业务线可能有数十个服务,一个个部署也比较困难,还可以新建一个空 Repo,在构建脚本中 require 一堆服务,即可产生一个“一键部署包”,例如:

helm_require app-a 1.0.1
helm_require app-b 1.2.3

Helm 体系(尤其是这个总包的实践)还带来了一个额外的好处:在过去部署时,需要人工寻找镜像列表,然后传输,这是非常容易缺漏错的。但现在通过解析总包,能够从一个完整的 Helm 包中自动导出所有需要的镜像。以前需要人工填写大量镜像,现在直接一个脚本全部搞定、自动同步镜像。

总而言之,一切都终于自动化起来了。目前的链路中只需要一次公司规定必须有的人工干预(在 Kubeapps 上点击升级版本)。截止目前已经服务于数个业务线支撑了数十个服务的数百次部署。

一些想法

这个问题的根本,在我看来本质上似乎并不是一个软件工程问题,而是一个团队管理问题。

这个系统推进过程中最大的好处是顺便将“配置管理的责任”与数十数百个服务间随意的依赖关系厘清了,将部署责任也附加到了开发过程中(而不是上线当晚)。同时引入了许多强限制,整体上提高了一大截工程质量。并且将部署过程中的人力干预几乎减少到 0。

从软件工程上看,貌似一切问题的根源都可以归根在“缺乏良好的 Code Reivew”上。

但是平心而论,大部分人可能对 code review 不感兴趣,毕竟需求繁多人力不足的情况下,code review 是一个不小的负担,何况业务需求强相关的东西别人不一定能看懂。据我观察,项目组的 leader 也许很精通业务和进度管理,但 leader 本人也有很重的开发责任,review 的工作实在是没有时间。而且,也不是所有人都具备 review 的能力,有能力的人一般都属于“流动项目组”,一般不会在某个业务线里长待,公司内部哪里有需要就去哪里救火。

具体到部署包这东西上,由于开发环境与部署环境的割裂,往往是上线时问题频出,临时补丁来补丁去,好不容易工作了。在这种环境下,哪来的工程质量可言呢?

在此之前,我从来没有想过部署一些东西居然会因为“历史包袱”的原因逐渐地变得如此复杂与耗时。在人员质量参差不齐的情况下,也许从构建系统上下手是一个好办法。

此外,我也完全没想到推动这项声明式部署的方案竟然如此困难……幸而公司恰好新建了一个需要快速部署更新的业务线,恰好赶上这个系统上线,恰好这个团队就在隔壁工位。这条业务线在几个月的实践中内证明了这套部署流程的高效与可用性。由于各种各样的原因,这可能是公司内部第一次“持续交付”实践——虽然由于环境隔离和公司规定,还需要在工单结束后人工在生产环境的 Kubeapps 上点一下更新,但相比过去可以说是从无到有的进步。

这可能是公司内部第一个不因为部署加班的业务线,但尽管有如此一个正面典型案例,后续的推广工作也十分困难。观察下来虽然运维和测试都对这个系统好评如潮,但其他业务线似乎根本没有动力去接入。最后还是依靠强制制定的 KPI 要求才终于开始大规模接入……这是我十分难以理解的。

最后我还特别想吐槽,这套系统可能拥有全公司最完善的使用文档和 FAQ,但几乎没有人看,令人心累。不得不感慨团队管理的本质首先是找人,其次才是找目标——虽然在商业公司中可能是反过来的。