TT Lab
开始
学习 学习路径 课程

CI/CD 流水线

十个红色构建,却没人看过一行日志

在 TT Lab 中继续学习

目标

从头到尾构建一套调查流程:用表格给流水线的失败分类,把每一类送往不同的应对,用测量来证明不稳定,仅凭记录就重新引发失败的运行,并用确定性的判定脚本自动找出罪魁提交。

为什么重要

失败一天十次,人就不会读日志。重试按钮先被按下,通过之后就忘了。在养成这种习惯的流水线中,真正的缺陷也会被重试两三次,碰巧通过后就原样合并,红灯不再是信号。所以需要的不是更好的日志,而是类别。基础设施问题可以重试;不稳定测试用重试掩盖的话会永远留着;真正的缺陷无论运行几次答案都相同,所以重试只是白白浪费时间。确定了类别之后,“什么时候坏的”这个问题才成立,而回答这个问题的二分查找,只有在判定是确定性的时候才值得信任。因为一次错误的判定,就会把剩下的整个查找引向错误的区间。本实验要构建这条完整的链条——用表格来分类,用测量来证明,用记录来重现,用只靠退出码说话的判定脚本来找罪魁。

步骤

  1. 在 /root/triage/rules.tsv 中用表格写下分类规则。用制表符分成三栏 규칙id<TAB>갈래<TAB>확장정규식(占位符依次为规则 id、类别、扩展正则),自上而下,第一个匹配的获胜。规则六条,id 与类别如下——dns(infra)、disk(infra)、oom(infra)、timeout(flaky)、assert(defect)、syntax(defect)。然后在 /root/triage/logs/ 中亲自写六份失败日志样本,从 run-01.log 到 run-06.log。内容必须依次体现出名称解析失败(dns)、磁盘不足(disk)、因内存超限而死亡(oom)、超时(timeout)、断言失败(assert)、语法错误(syntax)。最后创建 /root/triage/classify.sh <로그파일>(占位符为日志文件)。规则表的位置可以用环境变量 RULES_FILE 更改,默认值是 /root/triage/rules.tsv。每行输出两个词:第一个词是类别,第二个词是规则 id。没有命中任何规则时输出 unknown -。如果日志文件无法读取,标准输出中什么也不要输出,以退出码 2 结束。
  2. 在 /root/triage/policy.tsv 中用表格写下每个类别的应对。用制表符分成三栏 갈래<TAB>권장대응<TAB>재시도가능(占位符依次为类别、建议应对、是否可重试),共四行——infra requeue yes、flaky measure no、defect bisect no、unknown read no(栏与栏之间是制表符)。然后修改 /root/triage/classify.sh,让它每行输出四个词 <갈래> <규칙id> <권장대응> <재시도가능>(占位符依次为类别、规则 id、建议应对、是否可重试)。策略表的位置可以用环境变量 POLICY_FILE 更改,默认值是 /root/triage/policy.tsv。并且让类别也通过退出码来表达——infra 为 0,flaky 为 3,defect 为 4,unknown 为 5,无法读取日志时为 2。最后依次运行 /root/triage/logs/ 中的六份日志,把输出原样保存为 /root/triage/triage.txt 中的六行(按 run-01 到 run-06 的顺序)。
  3. 在 /root/triage/tests/ 中创建三个测试脚本——always-pass.sh(始终为 0)、always-fail.sh(始终不为 0)、flip.sh(只在奇数次运行时失败)。flip.sh 所计数的值要放在它自己的文件旁边($(dirname "$0") 之下),这样把脚本复制到别处,计数也会随之而去。然后创建 /root/triage/flaky-probe.sh <시험스크립트> <횟수>(占位符依次为测试脚本与次数)。把收到的脚本按该次数运行,输出一行 runs=<횟수> pass=<성공> fail=<실패> verdict=<판정>(占位符依次为次数、成功数、失败数、判定),判定是 stable-pass、flaky、stable-fail 之一。退出码是:stable-pass 为 0,flaky 为 3,stable-fail 为 4,没有参数或次数不是 1 以上的整数时为 2。最后,把三个脚本各自运行 8 次的输出,保存为 /root/triage/flaky-report.txt 中的三行(按 always-pass、flip、always-fail 的顺序)。
  4. 创建 /root/triage/tests/seed-test.sh。这是一个读取环境变量 SEED 的测试:种子相同则结果永远相同,而结果成功还是失败则随种子而异。SEED 为空时,以退出码 2 结束。然后创建 /root/triage/record-failure.sh <시험스크립트> <기록파일>(占位符依次为测试脚本与记录文件)。给环境变量 SEED 传入每次都不同的值,最多运行 30 次,在第一次失败时停下,在记录文件中写五行:SEED=、TZ=、LC_ALL=、SCRIPT_SHA256=(测试脚本的 sha256 前 64 位)、EXIT_CODE=,并以 0 结束。如果 30 次内没有失败,就不要留下记录,而以非 0 值结束。最后创建 /root/triage/replay.sh <기록파일> <시험스크립트>(占位符依次为记录文件与测试脚本)。如果记录中的 SCRIPT_SHA256 与现在脚本的哈希不同,就不要运行测试,以退出码 2 结束;如果相同,就传入记录中的 SEED、TZ、LC_ALL 运行测试,并以该测试的退出码结束。把三者串起来,创建 /root/triage/repro/case.env——命令是 record-failure.sh /root/triage/tests/seed-test.sh /root/triage/repro/case.env。
  5. 在 /root/triage/repo 中创建一个练习用的 git 仓库。提交共 20 个,标题从 change 1 到 change 20。每个提交都包含 app/rate.py 和 app/notes.txt,python3 app/rate.py 在第 1 到第 11 个提交输出 100,从第 12 个提交起输出 250(第 12 个是引入缺陷的提交)。Pod 里没有 git 身份,所以要先在仓库里设置 git config user.name 和 git config user.email。在 /root/triage/expected.txt 中写一行预期值 100。然后创建 /root/triage/oracle.sh。它在已检出的工作树的根目录中运行,如果 python3 app/rate.py 的输出与预期值相同,就以 0 结束,不同则以 1 结束。预期值文件的位置可以用环境变量 EXPECT_FILE 更改,默认值是 /root/triage/expected.txt。标准输出中什么也不要输出。
  6. 在 /root/triage/repo 中,把第一个提交设为 good,把 HEAD 设为 bad,用 git bisect run 找出罪魁提交。判定使用 /root/triage/oracle.sh。找到之后,在 /root/triage/bisect/culprit.txt 中写两行——culprit=<40자리 커밋 해시>(占位符为 40 位提交哈希)和 subject=<그 커밋의 제목>(占位符为该提交的标题)。并在 /root/triage/bisect/steps.txt 中写三行——revisions=<good 뒤부터 bad 까지의 커밋 수>(占位符为从 good 之后到 bad 的提交数)、tests=<판정 스크립트가 실제로 불린 횟수>(占位符为判定脚本实际被调用的次数)、reason=<왜 그 횟수인지 한 줄 설명>(占位符为一行说明为什么是这个次数)(20 个字符以上)。结束后,用 git bisect reset 把仓库恢复到原来的分支。
  7. 在 /root/triage/repo-skip 中创建第二个仓库。提交 20 个,标题形式相同,但这次第 10 个提交的 app/rate.py 有语法错误,根本无法运行,并且从第 18 个提交起输出 250(第 1 到第 17 个,除去第 10 个之外全部输出 100)。然后创建 /root/triage/oracle-skip.sh。与 oracle.sh 相同,但如果 app/rate.py 不存在或者语法无法通过,就以退出码 125 结束。在同一个仓库中,把第一个提交设为 good,把 HEAD 设为 bad,运行两次二分查找——一次用 /root/triage/oracle.sh,一次用 /root/triage/oracle-skip.sh。把结果写成四行保存到 /root/triage/bisect/skip-report.txt——naive=<oracle.sh 가 지목한 40자리 해시>(占位符为 oracle.sh 所指认的 40 位哈希)、skip=<oracle-skip.sh 가 지목한 40자리 해시>(占位符为 oracle-skip.sh 所指认的 40 位哈希)、true=<진짜 범인의 40자리 해시>(占位符为真正罪魁的 40 位哈希)、limit=<건너뛰기의 한계를 적은 한 줄>(占位符为写出跳过的局限的一行)(20 个字符以上)。结束后别忘了 git bisect reset。
  8. 创建 /root/triage/investigate.sh <로그파일> <저장소> <보고서파일>(占位符依次为日志文件、仓库、报告文件)。用 /root/triage/classify.sh 对日志分类,只有当类别是 defect 时,才用 git clone 复制该仓库,从第一个提交到 HEAD 用 /root/triage/oracle-skip.sh 做二分查找,找出罪魁。收到的仓库不能有一个字符的变化,别人在那个仓库里正在修改的文件也必须原样保留。报告共五行——category=、action=、culprit=(不是 defect 或没找到时为 -)、subject=(同样)、reproduce=(一行说明重新引发的方法)。报告文件的上级目录不存在时要创建,正常结束时以 0 结束,参数不足或日志、仓库无法读取时以 2 结束。创建之后运行两次——用 /root/triage/logs/run-05.log 和 /root/triage/repo-skip 生成 /root/triage/report/case-defect.txt,用 /root/triage/logs/run-01.log 和 /root/triage/repo-skip 生成 /root/triage/report/case-infra.txt。

参考

红灯有十个,却没有一个人读过

在 /root/triage/rules.tsv 中用表格写下分类规则。用制表符分成三栏 규칙id<TAB>갈래<TAB>확장정규식(占位符依次为规则 id、类别、扩展正则),自上而下,第一个匹配的获胜。规则六条,id 与类别如下——dns(infra)、disk(infra)、oom(infra)、timeout(flaky)、assert(defect)、syntax(defect)。然后在 /root/triage/logs/ 中亲自写六份失败日志样本,从 run-01.log 到 run-06.log。内容必须依次体现出名称解析失败(dns)、磁盘不足(disk)、因内存超限而死亡(oom)、超时(timeout)、断言失败(assert)、语法错误(syntax)。最后创建 /root/triage/classify.sh <로그파일>(占位符为日志文件)。规则表的位置可以用环境变量 RULES_FILE 更改,默认值是 /root/triage/rules.tsv。每行输出两个词:第一个词是类别,第二个词是规则 id。没有命中任何规则时输出 unknown -。如果日志文件无法读取,标准输出中什么也不要输出,以退出码 2 结束。

把规则放在表里而不是代码里,是因为出现新的失败形态时,修改的地方必须是数据而不是脚本,这样才会留下评审和历史记录。用制表符分隔的行用 while IFS=$'\t' read -r a b c 读取。如果最后一行没有换行,read 会漏掉那一行,所以加上 || [ -n "$a" ] 更稳妥。扩展正则用 grep -Eq -- "$pattern" 来查询。评分器也会用它自己创建的规则表和日志来运行这个脚本——如果是根据日志文件名来作答,那时就会被抓住。

可以重试的失败,和绝对不能重试的失败

在 /root/triage/policy.tsv 中用表格写下每个类别的应对。用制表符分成三栏 갈래<TAB>권장대응<TAB>재시도가능(占位符依次为类别、建议应对、是否可重试),共四行——infra requeue yes、flaky measure no、defect bisect no、unknown read no(栏与栏之间是制表符)。然后修改 /root/triage/classify.sh,让它每行输出四个词 <갈래> <규칙id> <권장대응> <재시도가능>(占位符依次为类别、规则 id、建议应对、是否可重试)。策略表的位置可以用环境变量 POLICY_FILE 更改,默认值是 /root/triage/policy.tsv。并且让类别也通过退出码来表达——infra 为 0,flaky 为 3,defect 为 4,unknown 为 5,无法读取日志时为 2。最后依次运行 /root/triage/logs/ 中的六份日志,把输出原样保存为 /root/triage/triage.txt 中的六行(按 run-01 到 run-06 的顺序)。

类别也用退出码输出,是为了让后面的自动化不必再去解析字符串。本实验的最后一步会用到这个值。可以重试的只有基础设施——不稳定测试用重试掩盖的话会永远留着,真正的缺陷无论运行几次答案都相同,只是白白浪费时间。评分器会通过 POLICY_FILE 传入它自己创建的策略表来运行。如果把应对的词写死在脚本里,那时就会被抓住。

失败了一次,不能就叫它不稳定

在 /root/triage/tests/ 中创建三个测试脚本——always-pass.sh(始终为 0)、always-fail.sh(始终不为 0)、flip.sh(只在奇数次运行时失败)。flip.sh 所计数的值要放在它自己的文件旁边($(dirname "$0") 之下),这样把脚本复制到别处,计数也会随之而去。然后创建 /root/triage/flaky-probe.sh <시험스크립트> <횟수>(占位符依次为测试脚本与次数)。把收到的脚本按该次数运行,输出一行 runs=<횟수> pass=<성공> fail=<실패> verdict=<판정>(占位符依次为次数、成功数、失败数、判定),判定是 stable-pass、flaky、stable-fail 之一。退出码是:stable-pass 为 0,flaky 为 3,stable-fail 为 4,没有参数或次数不是 1 以上的整数时为 2。最后,把三个脚本各自运行 8 次的输出,保存为 /root/triage/flaky-report.txt 中的三行(按 always-pass、flip、always-fail 的顺序)。

在同样的条件下结果是否有分歧——这既是不稳定的定义,也是判别的方法。一次失败并不能说明它属于哪一类。如果把 flip.sh 的状态放在 /tmp 或写死的路径上,复制出副本时就会与原件共用状态,导致测量出现偏差。检查次数,用 case "$N" in ''|*[!0-9]*) 最简短。评分器会把学员的 tests/ 整体复制,在副本里运行。

无法复现的失败,没办法调查

创建 /root/triage/tests/seed-test.sh。这是一个读取环境变量 SEED 的测试:种子相同则结果永远相同,而结果成功还是失败则随种子而异。SEED 为空时,以退出码 2 结束。然后创建 /root/triage/record-failure.sh <시험스크립트> <기록파일>(占位符依次为测试脚本与记录文件)。给环境变量 SEED 传入每次都不同的值,最多运行 30 次,在第一次失败时停下,在记录文件中写五行:SEED=、TZ=、LC_ALL=、SCRIPT_SHA256=(测试脚本的 sha256 前 64 位)、EXIT_CODE=,并以 0 结束。如果 30 次内没有失败,就不要留下记录,而以非 0 值结束。最后创建 /root/triage/replay.sh <기록파일> <시험스크립트>(占位符依次为记录文件与测试脚本)。如果记录中的 SCRIPT_SHA256 与现在脚本的哈希不同,就不要运行测试,以退出码 2 结束;如果相同,就传入记录中的 SEED、TZ、LC_ALL 运行测试,并以该测试的退出码结束。把三者串起来,创建 /root/triage/repro/case.env——命令是 record-failure.sh /root/triage/tests/seed-test.sh /root/triage/repro/case.env。

如果不能重新引发失败,就没有办法区分它是不稳定还是真正的缺陷。所以记录里只放重现那次运行所需的东西——种子、时区、区域设置,以及用来确认输入是否原样未变的哈希。之所以要核对哈希,是因为输入变化之后的运行不是复现,而是新的实验。悄悄放行的话,就会留下“已经复现了”这种错误的结论。种子可以通过拼接 $RANDOM 或混入循环变量来生成。如果用同一个种子运行 30 次,只会得到 30 次相同的结果——评分器会数一数种子有多少种。

先做出二分查找可以信任的判定脚本

在 /root/triage/repo 中创建一个练习用的 git 仓库。提交共 20 个,标题从 change 1 到 change 20。每个提交都包含 app/rate.py 和 app/notes.txt,python3 app/rate.py 在第 1 到第 11 个提交输出 100,从第 12 个提交起输出 250(第 12 个是引入缺陷的提交)。Pod 里没有 git 身份,所以要先在仓库里设置 git config user.name 和 git config user.email。在 /root/triage/expected.txt 中写一行预期值 100。然后创建 /root/triage/oracle.sh。它在已检出的工作树的根目录中运行,如果 python3 app/rate.py 的输出与预期值相同,就以 0 结束,不同则以 1 结束。预期值文件的位置可以用环境变量 EXPECT_FILE 更改,默认值是 /root/triage/expected.txt。标准输出中什么也不要输出。

如果把判定标准放在仓库里,在提交之间移动时,标准也会一起改变,就什么都无法判定了。所以预期值要放在仓库之外,并通过环境变量开放位置——这样同一个判定脚本才能用于其他仓库。之所以让标准输出保持为空,是因为二分查找只读取退出码。如果在屏幕上混入判定,自动化的一方就得再去解析那个字符串。制造历史的循环,用 git add -A 和 git commit -qm 简短地写就行。评分器会在副本中逐个检视提交,确认从正常变为有问题的位置是否恰好只有一处。

十九个提交,四次就缩小了范围

在 /root/triage/repo 中,把第一个提交设为 good,把 HEAD 设为 bad,用 git bisect run 找出罪魁提交。判定使用 /root/triage/oracle.sh。找到之后,在 /root/triage/bisect/culprit.txt 中写两行——culprit=<40자리 커밋 해시>(占位符为 40 位提交哈希)和 subject=<그 커밋의 제목>(占位符为该提交的标题)。并在 /root/triage/bisect/steps.txt 中写三行——revisions=<good 뒤부터 bad 까지의 커밋 수>(占位符为从 good 之后到 bad 的提交数)、tests=<판정 스크립트가 실제로 불린 횟수>(占位符为判定脚本实际被调用的次数)、reason=<왜 그 횟수인지 한 줄 설명>(占位符为一行说明为什么是这个次数)(20 个字符以上)。结束后,用 git bisect reset 把仓库恢复到原来的分支。

把判定脚本放在仓库之外的原因,在这里就显现出来了——二分查找会在提交之间移动,所以放在仓库里的话,那个脚本也会随着每个提交而改变。区间内的提交数用 git rev-list --count <good>..<bad> 统计。被调用的次数,可以数 git bisect run 在屏幕上打印的 running ... 行,或者给判定脚本套一层逐行记录的外壳来统计。次数并不随提交数增长,而是与它的对数成正比,请把这个原因写进 reason=。评分器会在副本中自己运行同样的二分查找,核对答案和次数。

一个连运行都做不到的提交,改变了罪魁

在 /root/triage/repo-skip 中创建第二个仓库。提交 20 个,标题形式相同,但这次第 10 个提交的 app/rate.py 有语法错误,根本无法运行,并且从第 18 个提交起输出 250(第 1 到第 17 个,除去第 10 个之外全部输出 100)。然后创建 /root/triage/oracle-skip.sh。与 oracle.sh 相同,但如果 app/rate.py 不存在或者语法无法通过,就以退出码 125 结束。在同一个仓库中,把第一个提交设为 good,把 HEAD 设为 bad,运行两次二分查找——一次用 /root/triage/oracle.sh,一次用 /root/triage/oracle-skip.sh。把结果写成四行保存到 /root/triage/bisect/skip-report.txt——naive=<oracle.sh 가 지목한 40자리 해시>(占位符为 oracle.sh 所指认的 40 位哈希)、skip=<oracle-skip.sh 가 지목한 40자리 해시>(占位符为 oracle-skip.sh 所指认的 40 位哈希)、true=<진짜 범인의 40자리 해시>(占位符为真正罪魁的 40 位哈希)、limit=<건너뛰기의 한계를 적은 한 줄>(占位符为写出跳过的局限的一行)(20 个字符以上)。结束后别忘了 git bisect reset。

125 表示“无法用这个提交做出判定”。如果把无法判定折成 1(有问题),查找就会向它前面收敛,信心十足地把一个无辜的提交指认出来——不肯说“不知道”,反而更糟。只检查语法时,如果使用 py_compile,__pycache__ 会留在工作树里,阻止下一次检出。python3 -c 'import ast,sys; ast.parse(open(sys.argv[1]).read())' 不会留下任何东西。真正的罪魁,是从旧提交开始逐个检视、第一次变成有问题的那个位置——把仓库复制出来遍历,就不会动到原件。在 limit= 中写明:当被跳过的提交紧挨着罪魁时,查找最多只能回答到哪里。

放入一份日志,就能得出罪魁提交

创建 /root/triage/investigate.sh <로그파일> <저장소> <보고서파일>(占位符依次为日志文件、仓库、报告文件)。用 /root/triage/classify.sh 对日志分类,只有当类别是 defect 时,才用 git clone 复制该仓库,从第一个提交到 HEAD 用 /root/triage/oracle-skip.sh 做二分查找,找出罪魁。收到的仓库不能有一个字符的变化,别人在那个仓库里正在修改的文件也必须原样保留。报告共五行——category=、action=、culprit=(不是 defect 或没找到时为 -)、subject=(同样)、reproduce=(一行说明重新引发的方法)。报告文件的上级目录不存在时要创建,正常结束时以 0 结束,参数不足或日志、仓库无法读取时以 2 结束。创建之后运行两次——用 /root/triage/logs/run-05.log 和 /root/triage/repo-skip 生成 /root/triage/report/case-defect.txt,用 /root/triage/logs/run-01.log 和 /root/triage/repo-skip 生成 /root/triage/report/case-infra.txt。

如果调查脚本直接在别人的仓库里运行二分查找,就会夺走那个人正在工作的位置。要在 git clone 出来的临时目录里运行,并用 trap ... EXIT 清理。判定脚本从环境变量里读取预期值文件的位置——调查脚本不要清除那个变量,原样传下去即可。评分器会传入它自己的预期值文件来运行。第一个提交用 git rev-list --max-parents=0 HEAD 查找。对于基础设施类别,根本不应该运行二分查找——对与代码无关的失败去翻查提交,是浪费时间。