NSX Transformers [Dropkick] 开发后记

故事背景

NSX 是 VMware 的支柱产品之一,旨在为企业提供一体化网络虚拟化方案。NSX vSphere 已在市场站稳了脚跟,公司正逐步把开发力量转移至它的后继者:NSX Transformers。去年发布的 Transformers 1.1 版本 [Crosshairs] 正在某家合作伙伴服役,今年推出的 2.0 版本 [Dropkick] 预计会在十余家企业推广试用。

NSX Transformers 曾经使用了 Pivotal 出品的 NoSQL 数据存储套件 GemFire。由于商业调整,GemFire 的核心组件被捐给了 Apache 基金会,产品开发陷入停顿。为了避免使用停止维护的产品,我司把目光投向了 VMware Research 编写的开源数据库 CorfuDB,并打算在 Dropkick 完成实装。这半年多的故事,都是围绕更换数据库展开的。

产品开发

年初的 V 家,显然还没有从硅谷一线公司那里学到两个礼拜 bootcamp 的重要意义。入职当天上午 10 点,我被经理带走配置开发环境。第二天,就领到了第一项任务,让我修改闭源的 CorfuDB 的软件版本号,并把它加到 nsx manager debian package 的依赖列表中。虽然组里没有给我安排一个导师,不过这种任务稍稍 google 一下就知道该如何动手:改一下 pom.xml 工程文件和 debian/control 控制文件就能搞定了。这差不多是一个小时的工作量,但是捣鼓评审系统、找人评审代码,花掉了好几天的时间。好不容易在第二周提交进了仓库,又迅速变身成了 P0 test blocker (P0 表示最高优先级的 bug),第一次出师就打了个全军覆没。我赶紧钻进代码和日志堆里看了一个上午,大概搞明白是因为没有把 CorfuDB 加入测试环境,才让整个流水线崩得体无完肤的。

NSX Transformers 的编译时间大约为四小时,自动化测试大约需十二小时。有一条流水线(持续集成/CI)每隔两个小时编译并测试一个 Transformers 版本。由于代码提交的预测试也挂载在流水线上,因此当它由于某些故障无法工作时,工程师就会被迫休假。按我的估算,以工资计量,流水线每停工一小时的损失约是 4000 美元。更改 CorfuDB 版本号引发的流水线大罢工,在 10 个小时后得以纠正,因此这是一个价值四万美元的教训:在修改工程代码的时候,别忘了看一下测试环境是否也需要修改。

后来的一段时期,我的开发工作主要是围绕 CorfuDB 的打包、安装和维护进行的。有几次提交,比如拆分工程结构、改写启动脚本、加入 systemd 支持等,通过 GitHub pull request 进了开源仓库,因此我也拿到了尊贵的 contributor 头衔。不过开发 CorfuDB 并非我的职责。作为一个 CorfuDB 的“用户”,我依据 NSX 的需要定制了部分功能,更多的提交也仅存在于闭源仓库中。可能是因为几乎只有我一个人单独向闭源仓库贡献代码,导致这两个仓库间的差别外人难以知晓,渐渐地,我很难找到合适的人做代码评审。再加上权限设置的弊病,在这里提交代码不要求他人的评审,事实上提供了暗箱操作的空间。当然,长此以往,自然会搞出事情。

值得一提的事故有两件,而且它们都和 shell 脚本有关,足以见得 shell 是一个绝对危险的领域。第一回,经理让我实现一个根据机器内存大小自动设定 JVM 栈空间上限的功能。我的想法是拿 awk 解析 /proc/meminfo 的数据,搞清楚到底机器上到底有多少内存,再按百分比把合适的数值写到 JVM 启动参数里面。这其中,需要判断某个环境变量是否为空。shell 有两种方法可以解答这个问题:[ -n $STRING ]$STRING 字符串长度不为 0 时返回 true,而 [ -z $STRING ] 只有当 $STRING 长度为 0 时才返回 true。听起来很简单吧!但实际的结果会出乎你的意料。

1
2
3
4
5
6
7
8
9
10
11
# 当变量 $var 未定义或长度为 0 时
[ -n $var ] # => true
[ -n "$var" ] # => false
[ ! -z $var ] # => false
[ ! -z "$var" ] # => false

# 当变量 $var 长度不为 0 时
[ -n $var ] # => true
[ -n "$var" ] # => true
[ ! -z $var ] # => true
[ ! -z "$var" ] # => true

为什么 [ -n $var ] 的行为与预期不同呢?它和 [ -n "$var" ] 又有怎样的区别?如果 $var 为空,那么在第一种写法中,test 程序会以为只传入了 -n 一个参数,它按照默认的行为返回 true。但在第二种写法中,则有两个参数传入(第二个参数是个空字符串),所以 test 程序能按预定的逻辑得出 false。同样的事情也会发生在 [ ! -z $var ] 身上,但由于存在取反符号 !,默认的返回值是 false,恰好逃过一劫。

由于在 shell 脚本中少加了一对引号,导致数据库 JVM 启动失败,管理层应用因无法连接数据库,在初始化阶段宣告阵亡,控制层和数据层应用群龙无首,整个虚拟网络系统陷入瘫痪。这个问题在 12 小时之后得到修复。以每个引号两万四千美元的代价,我自修了 shell 脚本编程极有意义的一课。

第二次事件持续了一天多的时间。为了同时兼容支持 systemd 和不支持 systemd 的系统,需要使用两套不同的 daemon (守护进程) 管理工具。整个流程成功的关键,是正确地判断系统是否支持 systemd。我对 systemd 的直观印象是“一号进程”,即它的 PID 值应当为 1。依据这个原理,我写出了一个花哨的判据:

1
[ "systemd" = $(ps -q 1 -o comm=) ]

我找来了不支持 systemd 的 ubuntu 14.04 和 支持 systemd 的 ubuntu 16.04 进行测试,一切工作正常。但是那天下班不久,就有人找上门来,说数据库又双叒叕不工作了。我登进测试用的机器,一次又一次确认上面的判据,没有发现任何问题。但是为什么数据库没能按照预定的方式启动呢?第二天上班,我一边忍受着上百封邮件的轰炸,一边百般思索追溯原因。直到我忽然心血来潮点开了编译日志,才在一瞬间拨开了云雾:原来 systemd 并不是系统原生的,而是在编译环节安装的,那个时候它还没有占据 PID 1 的位置,这一切要等系统重启之后才会发生。在编译阶段误判了系统的类型,使用了一套无效的配置,自然不能期待数据库可以正常启动了。纠正这个问题也很简单,只要把判据改成下面的语句就可以了:

1
[ $(which systemctl) ]

这一遭变故让我感慨良多。人生在世,最怕“逞强”、“装逼”四字。莫逞强,压力无助于解决问题,不如早一点儿回滚代码;留得青山在,不怕没柴烧。莫装逼,华丽的书写背后,满是陷阱;越朴素和简洁,就越接近正确与真理。

除了和 shell 的种种陷阱斗智斗勇,我还用 Java 开发了一个新特性。这个特性的开发经历了如下的流程:

  1. 提出产品需求。
  2. 经理找工程师写策划书。
  3. 经理、架构师与工程师开会,商议策划书实施方案。
  4. 工程师编写并提交代码。
  5. 测试人员验证产品功能。

一路跟下来,我发现,上面五个步骤中第二和第三步很难实施。工程师在动手写代码之前,几乎无法知道某个特性应当如何编写,所以策划书到头来还是要服从代码,而不是指导代码。至于架构会议,则一定会演变成名词解释会议,就像哲学指导下的物理学一样,能不帮倒忙就不错了。

这次让我开发的产品特性与数据库分表机制有关。组里的大佬们先花了一个月开会,争论三四种分表方案孰优孰劣。等会议上的争吵趋于缓和,我跟他们说,这些方案需要付出的代价都太大了,如果想要一个干净的实现,那就只能屈从于一个简单的方案。大佬们于是又纷纷摆出一副“怎么样都好”的姿态,批准了我提出的 API 兼容的设计。又过了一个月,我在代码评审的拉锯中,从各个角度出发完善它的功能:重构类型注册器、调整应用初始化逻辑、加入 transaction (事务) 支持,经过约 30 个版本的演进(以 git commit --amend 计),变成一千多行的庞然大物。

可能是因为修改面太大,也可能是因为优先级不够高,或是被 CorfuDB 想怎么交就怎么交的任性惯坏了,这次代码提交历经坎坷。第一次因为考虑不周,没有想清楚分表对数据迁移的影响,被撤了下来。数据迁移模块缺乏文档,可以请教的人又身处印度、沟通不畅,我通过看源码摸爬滚打一个礼拜,基本摸清了系统升级和数据迁移的流程。之后的一个礼拜,又成功地把分表机制接入了数据迁移逻辑,满心欢喜地开始了第二次尝试。那个时候,CorfuDB 正饱受高负载条件下“重启即死机”的困扰,组里的一位欧洲小哥开发出了数据预加载通路,可以在应用启动时将缓存调整至活跃状态,显著缩短了开机等待时间。我们都没有意识到彼此的代码在应用初始化时有逻辑冲突。因为他比我早一天提交,因此当测试人员发现机器重启后不能读取数据的时候,在茫茫人海中精确地将我抓获——我的代码第二次从仓库里被踢了出去。

至八月上旬,产品发布的日期一天天迫近,全组开始了以提升性能为最高目标的大会战。每天早上经理都会预订一间会议室,把开发和测试人员全部聚集起来解决问题。我对下午五点准时离场的行为深表不安,因为有人深夜十一点还在用 profiler 观测系统呢!后来,我采取了凌晨五点回邮件的策略以示回应。这真的不是为了要面子,赶在印度测试小组下班之前联络他们是很重要的。

鉴于我的产品新特性与提升性能这个最终纲领并无瓜葛,而且前科累累,是一个极大的不安定因素,经理考虑再三,问我能不能延到下个版本。我想,在全组打团的时候自己偷偷溜走刷副本确实不太厚道,于是欣然同意。等待着我的,是为此额外添加的数据迁移负担。

八月底的我们进入了快乐的海洋。代码提交被严格限制,没有 "ship stop" bug 在身的开发者基本上都开启了带薪休假的时光。我很高兴地为 Dropkick 提交了最后几个 CorfuDB 补丁,包括把日志等级从 DEBUG 升至 INFO。完成 capstone 这一封印之作,过去半年多为之流过的汗、犯下的错,也就一并被埋藏在仓库的历史中了。

运维与测试

与开发相比,我更多的时间是在运维与测试中度过的。如果问我入职的最初两个月,在工作上的主要收获是什么,我肯定会说是 git 技法得到了空前的提升。曾经有一项艰巨的任务,需要在两个相差上千次提交的分支间完成一次 git merge。那次化解 merge conflict 足足花费了我三个小时。借着 git merge 的名义,掌控他人代码的生杀大权,是极为难得的体验。做运维就像天气预报一样,做好了没有存在感,做不好会被所有人斥责。像我这样在 git 江湖行走多年的玩家,也曾经失手过——因为老眼昏花在 merge 后导致代码库编译错误。当然,因为这种 bug 具有秒杀全场的优先级,在大多数人还没有意识到发生了什么的时候,我们就果断出手解决了问题。

做运维让人最不开心的一点,是需要“出警”。流水线打雷下雨的时候,不管我是身处拉斯维加斯、优胜美地还是蒙特雷,都会被邮件或者短信呼叫,然后打开电脑看看到底出了什么问题。如果实在没有带电脑,也得提出一个合理的假设,让别人沿着那思路排查下去——总之不能装死。虽然我承认这其中的一部分故障和 CorfuDB 有关系,但领导们每次出警总是记得把我的邮箱一并写上,就好像多了一位应急修理要员 [1] 能扭转局势一样。最后嘛,大概是我也没怎么帮上忙,他们也没少打扰我。几个月前我还在想,有这么多上下文只有我一人知晓,他们肯定没办法开掉我,不然就没人能帮他们修流水线了。现在,我倒是更希望有人能接手这份运维的工作,把半年来的积累倾囊相授也可以,我只求能享受一份闲暇与清静。

在 Dropkick 打算采用 CorfuDB 的时候,它不能算是一个成熟的数据库产品。准确得说,它以前从未有过真正的用户,而我们是第一个吃螃蟹(xiang)的。缺乏历练的 CorfuDB 选择采用一种周期很长的开发模式:在 Dropkick 测试中发现数据库的 bug,然后到开源仓库打补丁,随后同步到闭源仓库,编译新的数据库,再重新运行测试。这其中的一个重要环节是代码同步。我们绝不希望把有缺陷的开源代码同步过来,所以需要预先运行一些 NSX 测试集以保证 CorfuDB 的质量。这份工作本来是另一位工程师和我一同分担的,不过由于我更熟悉内部工具与测试流水线,又长期在闭源仓库贡献代码,而且经常和工具链/维稳领导小组 [2] 打交道,那位活跃于 GitHub 的工程师遂逐渐淡出,工作不久就改为由我一人承担。

代码同步与预测试的流程几乎是固定的。为了从繁琐的手工劳动中解脱,我们萌生了开发自动化工具的想法。经过两个月有意无意地完善,这个近三百行的 shell 脚本已经涵盖了整个同步工作和绝大多数测试工作。如果 CorfuDB 没有 public API 更改,我在脚本运行的六至十五个小时里就可以安心睡大觉。如果测试结果良好,经理就会让我发一辆车,把最近的补丁全搭载上去。不过身为司机也有自己的苦衷:明明是我开的车,但你们上车的动作为什么这么熟练啊?哎,果然我自己写的代码优先级就是不够高,都没有搭车的资格。

那份自动化 shell 脚本当然不会进仓库,但不能否认它是迄今为止我在公司最得意的作品。在那里,curlawk 珠联璧合,优雅地完成了 API 的调用和解析。虽然偶尔会见到 $(awk '/"ref"/ {print $2}' output.json | uniq | awk -F \" '{print $2}') 这种诘屈聱牙的片段,但它依旧比相应的 Python 实现简短得多。公司的大部分内部系统,要么提供了 API 接口,要么提供了可以自动 POST 的表单,让我的脚本可以畅通无阻。不过,我也遇到过需要校验 CSRF token (跨站请求伪造) 的变态站点,使人叫苦不迭,不得不绕道而行。

程序调教

大多数工程师应该会认为,搞开发比调试程序有趣得多。需要经常调试的,要么是非常不成熟的产品,要么是非常成熟但是记忆失传的产品,我们不幸属于前者的范畴。人们都说,Java 有 IDE 的挟持,调试过程简单粗暴,只要能连上远程 application server,把 debugger 挂载到 JVM 之后,就可以尽情发挥想象力了。不过呢,第一次的经历总会有些特别。

那是我第一次用 Intellij(虽然 ECE6102 有写过 Java 但当时用的是 eclipse)。四位 staff engineer 围坐在会议室中,看着电视机里我的电脑屏幕,七嘴八舌地下达各种指令:

“按一下 Ctrl + n,输入 XXX 类。”
“嗯,能不能按下 Ctrl + b,看一下这个类的定义?”
“能不能在第 XXX 行设置一个条件断点?”
“能否用 Alt + F7 看看都有谁调用了它?”
“application server 的链接已经断了,重新连一下吧。”
……

那种气氛迷之尴尬。在我看来,我就像牵线木偶一样任人摆布。不过,在他们看来,把打团战这么宝贵的时间花在 Intellij 快捷键新手教学上,也是对公司资源的极大浪费。好啊,既然我们都不满意,那为啥一开始要投影我的电脑屏幕呢?似乎是因为这次教训,后来 war room 打团就很少用投影了。

调试的次数多了,会发现组里的各种 bug,除了 CorfuDB 本体的种种缺陷以外,差不多就那么几个来源。关于 Spring Framework 的错误配置是比较浅显的,Application Context 的问题都和它有一腿。深入到具体原因,Java/xml 配置、依赖关系、实现与继承、注解等,全都需要排查一遍,可以算作是体力活了。关于序列化与反序列化的问题,由组里的一个老牌工程师独自担起了整个序列化机制的设计和编码,在这种没有话语权的情况下,我就不多插嘴了。至于多线程引发的故障,虽然归结起来只有 race condition 这一个根本原因,但大家都不愿碰这块烫手山芋。因为和前两者不同,多线程竞争问题是无法调试的。

我就遇到过一个诡异的案例:某个单元测试在正常运行时总是报错,但只要加了一个断点就立马能够通过。我们无从知道 JVM 线程调度的细节,因此也无法预知什么情况下会发生什么。对付多线程难题,有对症下药和釜底抽薪两种解决思路。对症下药就是找出竞争发生的位置,用 synchronized 包装起来。这药方得良医来开才行,庸医若是想到处加上 synchronized 碰碰运气,则反而容易陷入死锁(如果不是可重入锁)。不过庸医还有釜底抽薪的法子:他们可以考虑把逻辑改成单线程的,这样就安全得很了。用这招就好比捕获异常的时候写上 catch (Throwable e),其实是为人所不齿的。但是,不少多线程逻辑改成单线程后,并没有性能损失,因此这个药方就得以流传开来。

长期跟 debug 打交道,让我真正学会了如何做一个云淡风轻的人。初来乍到的我,被安排了一个 P0 就火急火燎寝食难安。后来我身上挂着 5 个 P0、8 个 P1 的时候,想到这么多 bug 绝不可能在一个月内解决掉,竟一下子释然了。把 bugzilla 从邮箱中拉黑,凌晨爬起来看日语直播课,晚上到家了打音游,积极占领原本属于加班的时间。睡眠依旧不足,bug 终究还是要处理,但心头不被石头压着,会好受很多。

这半年多以来,我要感谢所有一同战斗过的同事。令人惊讶的是,我在职场上打过交道的同事,无不是兢兢业业、一丝不苟,从未遇到过意气用事、厚颜无耻之人。就算我的工作一再成为产品开发的不安定因素,大小经理、以及其他组的领导,也始终表现出宽容大度。当然,与学习同事的优秀品质相比,我倒是更盼望公司早一点儿把奖金发下来,别让我总以为自己的奖金因故被克扣了。急,在线等……

[1] 应急修理要员:网页赌博游戏《舰队Collection》中用于快速修理受损舰娘的装备。因在交战中受损状况是随机的,所以应急修理要员不如电探(雷达)和水听(声呐)实用。此处指公司应将更多的资源投入到基础设施建设中,减少对运维的不必要的依赖。

[2] 维稳领导小组:掌握超级权限,跟踪产品开发进度,随时准备把不良代码剔除出产品的小组。

shell 能力考试题

通晓 shell 的读者可以通过下面三道试题评估自身水平。题目是我出的,欢迎来信讨论解题过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
1. 请从下面四个选项中,选出工作正确的一个。

A | curl -X POST --digest -u "$user:$password" \
| https://jenkins.example.com/job/example/build \
| --data-urlencode json='{"parameter": [{"name":"git-checkout", "value":$HASH_VALUE}]}'
B | curl -X POST --digest -u "$user:$password" \
| https://jenkins.example.com/job/example/build \
| --data-urlencode json='{"parameter": [{"name":"git-checkout", "value":"$HASH_VALUE"}]}'
C | curl -X POST --digest -u "$user:$password" \
| https://jenkins.example.com/job/example/build \
| --data-urlencode json="{'parameter': [{'name':'git-checkout', 'value':'$HASH_VALUE'}]}"
D | curl -X POST --digest -u "$user:$password" \
| https://jenkins.example.com/job/example/build \
| --data-urlencode json='{"parameter": [{"name":"git-checkout", "value":''"'$HASH_VALUE'"''}]}'

2. 请从下面四个选项中,选出工作正确的一个。

A | find . -type f -name "*.avi" -exec some-command {} \;
|
|
B | for file in $(find . -type f -name "*.avi"); do
| some-command "$file"
| done
C | for file in $(ls *.avi); do
| some-command "$file"
| done
D | for file in ./*.avi; do
| some-command "$file"
| done

3. 请从下面四个选项中,选择返回值为 0 的一个。

A | if false; then
| exit 1
| fi
| exit 0
B | if [ false ]; then
| exit 1
| fi
| exit 0
C | if [ -n false ]; then
| exit 1
| fi
| exit 0
D | if [ "false" = false ]; then
| exit 1
| fi
| exit 0
1
2
3
4
答案
1. D
2. A
3. A