写Ansible脚本为什么这么痛

在最近的这个项目上,我们选择了用Ansible做基础环境的搭建和软件包的部署。Ansible,相对于Puppet和Chef,要更简洁和干净一些,首先是agentless,被控机器上不需要安装任何依赖;其次YAML格式的状态声明语句和playbook,比Ruby的DSL要更简洁一些。但是跟很多新技术一样,小规模玩一玩觉得还蛮有意思,上了规模之后,各种坑和痛点就显现出来了。我在这里罗列一下我们遭受的创痛,供后来人参考。注意首先我在这里只破不立,只描述痛点,不一定提供解决方案;其次我列出的坑不一定只限于Ansible,用其他的DevOps工具也可能会遇到,有些问题甚至是做运维相关工作无法避免的。

没测试,反馈周期太长

Ansible,Puppet,Chef这些DevOps工具,或者说Infrastructure as Code工具的目的之一,就是用code来管理/描述infrastructure。但是写这些代码跟写产品代码的体验很不一样,我们遇到最大的一个问题,就是不能及时有效的验证我们写的Ansible task是正确的。在写产品代码时,我们有从UT(单元测试)到E2E测试(端到端测试)等不同粒度的反馈机制来验证和保证我们写的代码的正确性,但是对于Ansible来说就没那么容易了,你总不能在本地用vagrant搭建一整套集群环境来验证,即使能这样,Ansible脚本执行一遍所花的时间,和你摁几个快捷键就能刷一遍UT相比,还是太低效了。

因为缺少得力的测试框架,在维护Ansible脚本的时候,我们倾向于要么(1)对于小的改动,直接在Ansible工程中的修改,不加验证,提交之后直接走CI(持续集成)看结果;要么(2)对于大的改动,在现存的某个集群环境上,边运行、看结果,边修改。对于(1),很明显,天知道你这次的修改是不是对的,对于Ansible熟手——真的熟手,并且对现存的infrastructure有充分了解的熟手,这么干的可操作性还尚存一些;对于看着自己修改的diff就自信心爆棚的熟手,那多半是把CI搞挂,再反复个若干次。对于(2),因为重新搭建一套专门用于测试Ansible的环境成本太高,特别是当infrastructure比较复杂,集群的组建有数十个的时候就更困难了,于是大家倾向于在现存的UAT(用户验收测试环境)或者E2E环境上调试,但这会导致一个比较隐蔽的问题——Ansible的执行是依赖于机器的初始状态的,你在现存环境上的每一次运行和调试都可能已经修改的机器的初始状态,所以尽管最后你的修改跑通了,其正确性也是个问号,如果不正确,那么下次CI上的build(构建任务)在provision(这个不知道怎么翻译)的环节挂掉的时候,你又得回过头来重新审视。

机器的初始状态,和幂等性的保证

Ansible等这些工具带来的理念除了Infrastructure as Code之外,还有一个是DSC(Desired State Configuration),即对你想要的状态进行声明式环境配置。比如你要安装apache这个包,那么你在脚本里声明一下我想要httpd这个包,它的状态是present的就行了,Ansible检查已经存在就不做动作,如果没有则安装,这一点上和shell脚本拉开了距离。但是在一个诺大的Ansible工程里,很难保证所有的task都是按照状态声明的理念来写的,这就和shell脚本又回到了同一起跑线上——即脚本执行的成功与否,依赖于机器的初始状态,而初始状态是很难保证的。前面说了,在现存环境上调试还未写就的脚本就会导致机器初始状态不一致;对于同样的CentOS 6.5 64bit,在不同的客户那里,由于网络、机器硬件等原因都会导致初始状态不一致。所以我们在用Ansible等工具对infrastructure做了管理之后,幻想着一次编写到处执行,就又变成了一次编写到处调试,花费比预期多的多的时间。

怎么能快速有效的保证机器的初始状态呢?结合虚拟化技术,锁定镜像,这样机器重建过之后,就可以保证机器的初始状态是绝对一致的,这次工作的Ansible脚本下一次也是绝对可以工作的。如果不能做这样的保证,那么在写Ansible脚本的时候就要考虑很多额外因素,比如幂等性,你最起码要能保证即使是用流程化方式撰写task,一次成功运行之后再次运行n次也能成功。还有migration(迁移),当你需要对infrastructure做调整的时候,你需要考虑调整之后的Ansible脚本和现存环境的当下状态是否冲突,如果冲突,则需要运行一下ad-hoc的Ansible命令做一次migration,或者做脚本调整的时候就把migration考虑进去。

Ansible的坑

Ansible虽然简洁和干净,但是也有不少bug或不合理的地方。

首先是抽象不足,既然目标是DSC,那么我对目标状态的声明越远离机器细节越理想,但是对于安装最基本软件包这样最基本的task,rpm系和deb系软件包名不一样这样的差异也反映到了Ansible脚本的层面,比如apache2httpd。当然你可以用ansible_os_family做判断,但是既然Ansible知道当前OS的family,那我自然也期待她能把软件包名抽象掉。

Ansible对于机器建模,有group的概念,比如对于app_servers这个group,它包含了node01node02tomcat01tomcat02,对于一个完整的集群环境,node01/02,和tomcat01/02是4台不同的机器。但是当机器紧张的时候,我们可能会吧node01tomcat01部署到同一台机器上,node02tomcat02部署到同一台机器上,4台机器的声明,实际只有2台机器。而Ansible有并行部署的功能,当我们对app_servers这个group执行某个task的时候,Ansible会同时连接到4台声明的机器上(虽然实际只有2台),并行的执行任务。在同一台机器有两个进程同时执行同样的任务,就很有可能会冲突,导致失败。

还有很多很细碎的坑,比如Ansible的group可以继承,如果group A继承group B,那么A和B同名的group variable就会冲突。还有对于特定版本的CentOS,Ansible走ssh连接机器会出现不稳定的情况,得切换到paramiko等等等等。

总结

即使是用Ansible如此充满了业界先进理念的工具来帮助管理infrastructure,面临的问题域还是一样的,就是充满细节,琐碎,不稳定的底层机器和环境,更换一台机器,很多细节都不能保证。用工具能帮我们省一些力气,但是复杂性一上来,该费力的地方还是会费劲。

虽然我们被Ansible搞的很痛,但这仍是一个正确的方向,因为我们在Ansible实践上发现越多的问题,就越说明我们的infrastructure越复杂,既然已经复杂到这个程度了,总归得用个什么工具给管理起来,要么old fashioned way的用shell脚本,要么就用工具,用工具显然是优于赤裸裸的写脚本的。说到底,做infrastructure相关工作的体验没有写代码舒服,还是因为这门技术跟后端相比,发展的还不够,伴随着它成为瓶颈,很多工具和实践也会慢慢演化出来,容器技术就已经从另外一个方向上开了一个好头了。