拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从随机代号到稳定交付:一套内部流水线引擎的设计与故障治理

从随机代号到稳定交付:一套内部流水线引擎的设计与故障治理 1. 一个随手敲出来的代号却解决了三个月没人管的痛点1.1 代号ADWDWDWDWW的来历如果你在团队聊天记录里翻到“ADWDWDWDWW”这种乱七八糟的字符串多半不会想认真对待它。但过去一年里这个名字在我参与过的内部自动化项目里出现了上百次最后我们把它做成了一个完整的“提交到交付物”的流水线引擎。故事起点很朴素。季度规划会上我们发现连续三次发布是因为人工操作顺序错误才拖到深夜另一个小组因为测试环境和构建服务器之间的参数不一致白白消耗了两天。没人愿意再靠共享表格和聊天记录来协调这件事。于是有人提议做一套内部工具把代码提交、测试执行、构建镜像、生成发布文档到触发部署前检查这些环节串起来。当时产品经理问这个项目叫什么我旁边一位同事开玩笑在键盘上随手敲出一串字母就成了 ADWDWDWDWW。它没有任何含义纯粹是乱码。我们日常叫它“AW工具”或者直接说“那个管线”项目文件里则一直保留这个名字。团队后来接受这个不严肃代号原因是我们定了一条不成文的规矩代号越没有含义越提醒我们它背后存在的问题必须清晰。名字可以不严谨需求边界和技术方案不能再含糊。1.2 三个真实痛点把项目从“工具展示”推到了“交付系统”第一个痛点是测试与发布的割裂。开发分支合并前会跑若干测试但真正构建发布时用的参数、依赖版本却未必和测试环境一致。结果是测试通过但发布失败或者发布成功但线上行为和期望不符。这不是单个人粗心而是链路中从来没有一个机制来强制两边使用同一套环境描述。第二个痛点是文档滞后。业务方非常需要每次迭代的交付说明但过去生成说明是最后一个开发者的手工劳动。它依赖记忆依赖当时能打开哪个线上页面也依赖这个人愿不愿意花时间去整理。项目忙起来的时候文档永远排在最后等真要出交付报告时又得从头翻 commit 记录。第三个痛点是追踪困难。一次发布涉及谁提交了代码、谁批准了合并、哪个阶段的测试被跳过、镜像构建用了哪些环境变量这些信息分散在至少四个系统里。出了问题排查要逐一登录后台对所有人都是巨大的时间损耗。这三个痛点放在一起就是我们后来那句“交付链路可追踪、可复现、少人工干预”的由来。目标听起来很宏大但真正落地时逼我们在需求阶段把很多漂亮话砍了下去。2. 需求从哪来把模糊愿望收敛成可实现的边界2.1 三条核心原则自动、可追踪、不替代“让一切自动”是一个危险的口号。工程领域里“自动化”如果被理解为把所有事情都交给系统去猜最后大概率会做出一个别人根本不敢用的黑盒。我们用三周时间反复讨论边界最后只保留了三条原则。第一自动处理重复劳动。凡是由代码和元数据可以直接推导的环节不要求人手工完成。比如分支选择、版本号读取、构建参数注入这些都可以从提交对象里拿到。人对重复操作的干预越多出错概率就越高。第二链路可追踪。每个触发的任务必须有一条可检查的记录什么时候、哪个分支、哪个 commit、冒烟结果、构建产物哈希。用户可以不看但拿不出来就等于不合格。后来这套追踪能力在稳定性事故排查中帮了大忙这是后话。第三不替代现有系统。我们不重写测试框架不接管完整 CI 调度不强逼所有团队迁移到新的发布工具。任何已经运行良好的组件用适配器方式接入即可。这一条非常重要它避免了项目演变成新一代“万能平台”也决定了后面的选型始终以“轻”为方向。2.2 一张表看懂我们最终要做的事需求讨论到最后我们把目标压缩成几个可以验收的条目。当有人问“这系统到底能做什么”时这张表可以直接回答功能域输入输出非目标流水线定义YAML 配置可执行的任务 DAG不替换现有 CI事件触发Git 提交或合入事件创建任务实例不做复杂定时调度任务执行步骤脚本和 Docker 镜像产物与日志不做通用脚本调度状态记录worker 上报心跳PostgreSQL 状态快照不对外暴露过多接口产物同步构建镜像与文档内部对象存储不做线上发布执行这张表当时贴在我们群置顶消息里。它看起来很简单但每一行背后都是“不要扩张”的承诺。比如临时想加一个“自动回滚”功能我们就会拿这张表对一遍发现它属于“线上发布执行”不是本系统目标就先放回去。边界清楚之后选型阶段就轻松很多。我们不需要一个庞大的工作流引擎不需要一个能做复杂规则编排的平台也不需要和已有 CI 强绑定。我们需要的只是一个足够可靠的任务调度层外加稳定的状态存储。这个判断直接决定了后面的架构和代码量。3. 引擎设计从提交到验收产物的整条链路3.1 系统整体结构最终架构分四层事件入口、调度核心、执行工人、产物与记录存储。事件入口是一个接收 Git 平台 Webhook 的小服务。请求进来后先做基础校验再把事件转换成内部统一消息格式。调度核心从队列取消息将消息映射到已注册的流水线定义执行工人使用 Docker 容器每个任务对应一个独立运行环境。产物与记录存储使用 PostgreSQL 保存任务状态和可追溯元数据构建产物放到内部对象存储。我之前见过不少团队把这类系统做成“一个大服务什么都能干”结果所有逻辑都耦合在同一个进程里测试环境一波动整个链路跟着挂。这次设计刻意做了拆层。事件入口不关心任务怎么执行调度核心不关心 worker 内部逻辑worker 不关心配置来自哪个仓库。每一层只依赖明确定义的接口。为什么选择这种结构而不是直接用现成编排工具一是团队已有的代码仓库规模不大一套轻量自研方案能更好贴合我们的配置习惯二是我们需要和内部权限系统做深度对接现成工具在权限模型上往往要么太简单要么太复杂三是我们希望将来某个环节替换成更成熟的组件时其他部分不受影响。3.2 关键技术决策队列、幂等与状态机任务状态机设计的重点在于区分“运行中”和“终态”。不要让系统存在模棱两可的阶段。我们定义的状态集合非常小pending任务已创建等待调度runningworker 已领取正在执行succeeded所有步骤成功产物已登记failed任一步骤失败已进入可重试或人工处理范围cancelled主动取消或超过重试次数后人工终止状态不是简单的字符串而是带版本号的记录。任何一次状态更新都必须携带上一次读取到的版本号否则直接拒绝。这个机制在后来的稳定性事故中起了关键作用它把并发问题变成了一个正确处理冲突的流程问题。消息处理层必须幂等。worker 在重启、网络超时、重复投递情况下可能会收到同一消息因此消息处理至少在三个位置写入去重键创建任务、分配工作、更新状态。例如我们使用 commit SHA 加上阶段编号生成唯一键只有不存在时才插入新的执行记录重复插入则直接消费掉该消息不再重复执行。worker 本身不持有用户代码的长期依赖所有步骤在 Docker 容器内执行。这也减少了环境差异导致的“在我机器上是好的”问题。宿主上只需要安装 Docker 运行时和与调度核心通信的轻量客户端其余依赖都锁在镜像里。3.3 一次触发任务的最小配置示例配置使用 YAML不发明新的领域语言。一个最简单的流程可以长这样pipeline: name: api-service-delivery trigger: event: push branch: main stages: - test: image: golang:1.21 script: [go test ./...] - build: image: docker:24 script: [docker build -t registry.internal/api:${COMMIT_SHA} .] - docs: image: alpine script: [python3 generate_docs.py] artifacts: - images: registry.internal/api:${COMMIT_SHA} - docs: out/delivery_notes_${COMMIT_SHA}.md它只做了三件事跑测试、构建镜像、生成文档。但已经替代了过去手动执行命令然后手工标记文档的做法。每个阶段失败时系统会把完整日志归档到任务记录下附上触发原因和运行时间。配置解析器并不允许任意字段。能用的变量、能引用的镜像来源、产物格式都有限制。一开始我们想做得“更灵活”后来发现灵活性在这个场景里等同于不确定性。宁可严格也不能让团队在半夜排查因为配置多写了一个空格而引发的问题。4. 稳定性实战任务卡住八小时的完整排查链路4.1 故障现场上线第六周我们遇到了一次印象深刻的故障。流水线看板有十来个任务长时间显示“运行中”时间最长的一个超过八小时。新前端项目的集成任务一直没有产出但服务器负载很低说明 worker 并没有在认真执行。这不是业务高峰期也没人改过配置。我当时的直觉是状态更新环节出了问题而不是任务本身太慢。我先去看日志。worker 进程还活着任务也在运行但状态心跳却停在一个时间点之后没有任何进展。这立刻暴露出一个不对称现象系统认为任务还在跑可它已经没有任何实际进度信号了。4.2 从日志到代码的层层定位排查分三步走。第一步确认 worker 进程存活。节点上的容器确实还在但日志结尾只有一行旧的“准备执行步骤 2”此后没有新输出。第二步检查数据库。worker 列表显示在线但last_heartbeat字段已经落后了几个小时。问题缩小到“心跳没有持续上报”或者是“上报了但没写入”。第三步查询锁表。在 PostgreSQL 的pg_locks里出现了大量同一张任务状态表的行锁等待部分会话已经存在数小时。此时原因基本浮出水面后台 worker 的心跳更新和任务状态更新在同一个事务里某些任务的状态更新因为并发竞争被阻塞进而拖住了后续所有心跳写入。当初我们以为给状态字段加上唯一索引就够了实际上在多个 worker 并发领取和执行任务时事务范围一旦拉大一个慢查询就能挡住整张表的更新。这个场景和业务网站里常见的“某条记录被锁住后面请求全部排队”非常像只是它的后果不会立刻反映在 CPU 上而是表现为任务静默卡死。4.3 修复方案和验证结果修复方案分两层。第一层压缩事务范围每个 worker 只在自己的任务上下文里更新状态手动提交不跨任务聚合。第二层给状态更新加乐观锁不再让数据库承担“帮我们仲裁冲突”的工作而是在代码层告诉自己这次更新是否基于最新版本UPDATE task_state SET status running, version version 1 WHERE task_id :id AND version :expected_version;如果影响行数为 0worker 就放弃本轮更新退避几秒后重新拉取最新状态而不是一直等着拿锁。这本质上是一种“快速失败”策略它牺牲掉了部分并发场景下的瞬时一致性换来的是整个系统的可用性。重试策略也做了调整。失败任务不再立刻重新排队而是按 1 秒、5 秒、30 秒、5 分钟的退避序列尝试超过五次就进入需要人工确认的终态。这样既能应对短暂的网络抖动又不会因为无限重试把故障放大成雪崩。验证方式我们用了故障注入。手工删掉一个 worker 的心跳记录观察后端是否能在预期时间内把任务标记为异常。新版本下数秒内就会看到任务状态变化不会再出现“挂着但没人知道”的情况。修复后这个逻辑稳定扛过了后续半年的真实使用。5. 当团队把随机代号当真名之后协作成本与沉淀经验5.1 随机代号带来的沟通摩擦代号完全没有含义最初确实制造过小麻烦。第一次写内部文档时我用 ADWDWDWDWW 做章节标题结果三天后连自己都要靠搜索才能找到。其他团队在周报里引用这个名字时往往要加上“我们说的就是那个发布日程表系统”才能讲清楚。后来我们做了一个很简单的补救给每个面向用户的界面和文档设置一个稳定别名。比如对外展示叫“交付流水线管理”底层任务表则保留原始字符串。任何人在沟通中提到“交付流水线”运维同事可以通过维护的映射台账找到对应记录不需要背那串乱码。这个成本几乎为零但解决掉了 90% 的沟通摩擦。5.2 落地推广中的非技术阻力技术项目最容易被低估的阻力是“我没有时间学一套额外的东西”。我们在推广阶段做了三件事。第一开发团队先用自己的两条流水线跑了两周每天真实点开结果页面记录哪些入口不顺手。我们不想做那种“看起来很美但没人用”的平台所以不放过每个真实操作路径上的别扭。第二给其他团队提供薄文档。所谓薄是真的只讲怎么填 YAML 前五个字段。配置解析器本身很严格但文档里不堆概念上来就是一个能跑通的示例。先让团队看到产出再讲扩展能力。第三明确设置了退出通道。如果团队觉得这个方式不合适可以随时回到原有流程。这一点让不少观望的人卸下戒心反而更愿意试。5.3 经验沉淀与后续演进系统上线半年后我们沉淀出了几条比较实用的经验。一是所有状态变化必须有审计记录哪怕当时没人看等出问题时它就是唯一能追溯的证据。二是重试不能只看次数还要看退避间隔和失败原因否则无效重试会掩盖真实故障。三是不要在系统里加一个“看起来可能有用”的功能多出的配置项和分支肯定会成为未来维护的负担。如果以后要在这个方向上再走一步我会优先把“产物与源码对应关系”做得更完整。现在每个构建产物已经记录了 commit SHA但还没做到从线上运行版本反向查回配置版本和测试报告的完整闭环。真正的可追溯性应该是任意时间点都能回答“线上那个东西到底是从什么代码、什么配置、什么测试状态下出来的”。这套补全之后系统才算真正成为团队发布流程里的基础设施。写到这里我最大的体会是一套内部自动化系统能不能活下来从来不是靠技术多新而是靠需求边界清楚、状态可追踪、失败能够快速诊断。ADWDWDWDWW 这个名字或许不好看但它提醒我名字以外的部分都要尽可能清晰。
返回列表