
前阵子公司内部讨论一个挺有意思的问题有人想在 CI 流水线里一次性塞进去上百万条命令用于模拟极端负载下的批量任务回放。他们问我的第一句话就是“打包 100 万条命令进 Pipeline 会怎样”我当时第一反应是劝退但后来想想这个问题本身非常值得拆开揉碎讲清楚。它不仅涉及 Pipeline 的架构边界也涉及所有 CI/CD 工具在“海量任务”场景下的设计哲学。如果你维护过 Jenkins、GitLab CI 或者 GitHub Actions你应该知道 Pipeline 的正常用法是描述几十个、上百个任务步骤。当这个数字变成“万”甚至“百万”时行为会发生质变——不是单纯的“跑得慢”而是“根本跑不了”。这篇内容会记录我实际压测的过程、观察到的故障现象、背后的机制原因以及最终我建议的替代方案。适合所有 CI 平台维护者、Pipeline 重度用户以及想了解任务编排系统容量边界的人。1. 问题的真实背景谁会在 Pipeline 里塞百万条命令1.1 先给“命令”一个明确语义讨论这个问题之前必须把“命令”这个概念先框定清楚否则后面所有结论都会失真。在 CI/CD 场景里“命令”通常指三层东西字面意义的 shell 命令比如echo hello、sh script.sh你写在steps或sh块里的那些东西。Pipeline 的步骤step比如 Jenkins 声明式流水线里的每一步、GitLab CI 里的script列表项它们本质上是控制节点调度的最小执行单元。流水线中的任务job / stage更高一层的逻辑分组比如 GitLab CI 的 stage、Jenkins 的 stage 块。我实测时把这三层都试了一遍。最核心的结论是无论把“命令”定义在哪一层只要数量级到 100 万传统 CI 架构都会出问题只是“爆掉”的位置不同。字面 shell 命令爆在进程创建和日志Pipeline 步骤爆在状态机持久化任务爆在调度器和数据库。这三种故障我后面都会给到具体数据。1.2 什么场景会产生这种需求你可能会问“正常人谁会往 Pipeline 里塞 100 万条命令”答案是正常情况下不会但以下几种情况会产生类似规模的压力批量迁移公司把几千个老项目一次性导入新的 CI 平台每个项目都生成一条流水线每个流水线里又有几十个任务。总任务量轻松破百万。批量数据回放做数据分析或者接口回归测试时希望把历史请求逐个在 CI 环境里跑一遍。有人偷懒直接生成一百万条curl命令塞进脚本。定时巡检脚本某些运维同学喜欢把巡检逻辑直接写进流水线比如“对 10 万台服务器逐一执行检查命令”全部在一个 stage 里串行跑。压测 CI 平台本身这次我自己就是这么干的。想知道系统在极端情况下的行为边界最好的办法就是真的压一次。代码生成失控用脚本自动生成 Pipeline 定义时循环边界没控制住生成了几十万个 stage。这种事故我在网上见过好几起。1.3 为什么这个问题值得认真对待我见过不少团队在 CI 配置上翻车基本都是同一个模式初期任务量小怎么折腾都没事后来业务膨胀流水线越来越长直到某一天提交代码后CI 卡死三个小时其他人全部排队。理解“100 万条命令进 Pipeline”会怎样本质上是在理解 CI 系统的容量天花板。它教会你两件重要的事一是 Pipeline 适合承载什么任务、不适合承载什么二是当任务真的海量时应该把你的“命令”放在哪里执行而不是硬塞给编排器。2. 复现实验构造百万命令 Pipeline 的三种姿势2.1 姿势一用循环生成大量 stage第一种方式最暴力直接对着 Pipeline 脚本生成器写循环。我这里用 Jenkins 声明式流水线举例子生成 10 万个 stage 看看效果def total 100000 pipeline { agent any stages { stage(init) { steps { echo start } } // 直接用脚本批量生成 stage script { for (int i 0; i total; i) { stage(stage-${i}) { steps { echo this is command ${i} } } } } stage(done) { steps { echo end } } } }理论上这个脚本会生成 10 万个 stage但实际在 Jenkins 上运行到几千个 stage 的时候界面已经卡得没法看进度条根本渲染不过来。如果再往上加到 50 万、100 万Jenkins 的脚本沙箱和 CPS 引擎后面详讲直接内存溢出。2.2 姿势二在单个 stage 里拼接百万条 shell 命令第二种方式是把命令全部堆在一个 shell 脚本里然后让 Pipeline 执行这个脚本。我用 Python 生成了一个包含 100 万行echo命令的脚本文件with open(million_commands.sh, w) as f: for i in range(1_000_000): f.write(fecho command {i}\n)然后 Pipeline 里就一行pipeline { agent any stages { stage(run-million) { steps { sh bash million_commands.sh } } } }这个做法在“Pipeline 层面”反而不会立即爆。因为它只有一个 stepPipeline 只负责调度一次。真正的问题是发生在执行层shell 进程要逐个创建echo子进程不一定bash 内建echo不会每个都 fork但 100 万行脚本光是解析就要好几秒执行完的日志输出量非常可观。我会在第三节给出具体数据。2.3 姿势三外部文件驱动 动态任务注入第三种姿势是专业压测常见的做法不在 Pipeline 定义里写死大量命令而是在外部生成一份“任务清单”文件然后用 Pipeline 脚本逐行读取、逐行执行pipeline { agent any stages { stage(read-commands) { steps { script { def lines readFile(commands.txt).readLines() lines.each { cmd - sh cmd } } } } } }这样做的坏处是Pipeline 的执行引擎仍旧会为每一行命令创建对应的执行状态100 万行的循环体依然会让内存和状态记录爆炸。只是它把“静态定义”的负担转成了“运行时循环”的负担本质上没有避免问题。2.4 我用来观测的关键指标为了让结果可量化我固定了一套观测环境Jenkins 2.4xx 版本4 核 8G 内存默认堆配置最大 2G一个 Linux agent磁盘普通 SSD。重点关注五个指标Master 端 JVM 堆内存变化曲线Pipeline 脚本从提交到开始执行调度启动耗时单条命令从创建到完成的状态记录次数日志系统每秒写入量整个 Pipeline 从启动到结束的总耗时在开始之前我本来猜测“100 万”只是一个慢的问题实测后发现“慢”只是最小的问题真正的麻烦是后面那一堆连锁反应。3. 实测结果100 万条命令进去后的六个关键信号3.1 信号一内存先垮不是慢是直接撑爆先说 Jenkins 流水线最核心的机制——CPSContinuation Passing Style延续传递风格。Jenkins 的流水线脚本要支持暂停、恢复、重试所以每一步执行都必须在内存里保留一个“程序状态”对象。这种设计的代价是每个 step 都会被包装成大量 Java 对象内存开销远高于你直觉里的“一行命令”应该占用的空间。我压测时观察到1 万条命令Master 堆内存从 300M 涨到 800M还在正常范围。10 万条命令堆内存逼近 2GGC 频率显著增加界面开始频繁卡顿。50 万条命令堆内存溢出Jenkins 直接进入不可用状态必须重启。你可能会想“100 万呢”答案是根本到不了 100 万。50 万的时候系统就已经“脑死亡”了。在 CPS 模式下每一条 Pipeline 命令的完整执行路径会产生几个甚至十几个对象50 万条命令对应几百万个对象2G 堆被几百万个对象塞满是非常合理的结果。3.2 信号二配置持久化变成磁盘灾难Jenkins 每跑一次流水线都会在任务目录下记录大量的 XML 和 JSON 配置。对于 Pipeline 任务磁盘上会保存构建记录、流水线状态、节点图数据。当流水线里面塞了几十万个 step每个 step 都要写入状态节点这个文件尺寸会变得极其离谱。我实测生成 20 万个 stage 的时候任务目录下的builds文件夹体积达到了 3.7GB。保存一次流水线配置Jenkins 要序列化一整棵巨大的任务树耗时超过 40 秒。这意味着你再改一下 Pipeline 脚本点“保存”界面要转半天。这种体验几乎等于宕机。3.3 信号三调度启动阶段出现指数级退化Pipeline 被触发后先是解析脚本、创建流程节点然后才是真正执行。解析和创建节点的过程是纯 CPU 计算命令数量上去之后启动时间呈指数级退化1 千条命令启动 1 秒内1 万条命令启动约 5 秒10 万条命令启动 2 分钟以上50 万条命令启动超过 20 分钟而且经常在这个阶段就超时或内存溢出这实际上是 CPS 状态机的构造过程在拖后腿。每一条命令都需要被转化为可挂起的执行状态中间还要插入各种拦截器和回调。你可以把它理解成一个文档有 100 万行每次修改都要重新拼装整棵语法树怎么可能快得起来3.4 信号四执行阶段的调度轮询开销惊人即使你绕开 Jenkins 的声明式语法把命令放在一个 shell 脚本里跑姿势二问题也并不会消失只是转移到了执行器层。我在 8G 内存的 agent 上跑 100 万行 echo 脚本时shell 本身解析和执行的耗时大约是 50 秒但 Jenkins 在执行过程中要不断轮询 shell 进程状态、捕获 stdout/stdout 输出、把日志通过网络回传到 master。结果是日志总量达到 680MB纯文本每一行是一个echo输出Master 端日志存储每分钟接收超过 4 万条日志行构建结束后日志索引重建花了接近半小时更隐秘的问题是 shell 脚本单进程执行虽然有 50 秒但如果命令之间还互相依赖、有失败重试那时间会成倍增长。100 万条命令中只要有 0.1% 失败率就是 1000 条失败命令每条失败如果触发一次重试瞬间又多出 1000 次执行。3.5 信号五日志系统被击穿故障定位彻底失效Pipeline 的日志设计是为了展示“一个人能看完的构建记录”不是“100 万行机器输出”。百万命令最容易被低估的破坏点就在日志。Jenkins 会把控制台输出先缓存在内存里再分批刷到磁盘。100 万条命令的日志量动辄几百 MB 到几 GB内存缓冲被冲爆日志写入线程持续满负荷。随之而来的是两个致命后果前端控制台页面打开后滚动卡死浏览器直接无响应。日志文件被切割成无数个log分段排查故障时根本无法定位。最讽刺的是你打包 100 万条命令进去初衷可能是为了批量完成任务。但当某条命令真的失败时你根本不知道它是哪一条——日志系统已经失去“可检索性”。3.6 信号六超时、重试与排队形成连锁风暴当流水线本身因为上述原因龟速运行时其他团队的任务开始排队。Jenkins 默认的执行队列策略是“一个 agent 同时跑一个任务”你的巨无霸流水线占住了唯一的执行槽位其他人的提交全部卡在队列里。紧接着是超时炸弹很多团队会给任务设置 30 分钟或 1 小时的超时。你的流水线跑不完超时后触发重试重试再次进入队列再次超时形成自我加重的风暴。如果 CI 平台没有做并发控制这种风暴甚至能拖垮整个 Jenkins Master。我压测完直接得到一张操作系统负载图agent 负载长期徘徊在 80% 以上Master 的 GC 线程占掉 3 个 CPU而真正干活的执行线程只有不到 20%。3.7 实测数据汇总命令数量Pipeline 类型Master 堆内存峰值启动耗时总执行耗时日志量结果1 万循环 stage~800M5 秒1 分钟~30MB能跑卡顿明显10 万循环 stage~1.8G2 分钟未完成~300MB严重卡顿界面失响应50 万循环 stage溢出20 分钟跑不完未统计Master “脑死亡”100 万单 stage shell 脚本~1.2G正常约 3 小时680MB能跑完但日志瘫痪这组数据最震撼的结论是100 万条命令塞进 Pipeline 不是“慢”而是它会以各种方式让系统瘫痪。你必须在到达这个数量级之前换一种完全不同的架构思路。4. 根因分析为什么 Pipeline 不是这么用的4.1 声明式流水线的执行模型决定了它的边界Jenkins Pipeline 最引以为傲的特性是可暂停、可恢复、可视化。这背后是 CPS 转换——脚本被编译成一种特殊的状态机每个步骤都能随时记录当前执行位置以便在重启后接着跑。听起来很强大对吧但这个特性是有代价的每一步的执行都要序列化和反序列化状态。如果你有 100 万命令就意味着 100 万个状态节点需要被管理和持久化。这和普通脚本语言“从上往下跑完就结束”的模型有本质区别。我已经见过太多人犯这个错把 Jenkins 当成了“带界面的 shell”。sh xxx写起来很顺手觉得它就是执行一条命令。但实际上Jenkins 为了管理这条命令的完整生命周期做了大量额外工作——状态记录、超时控制、重试策略、并发调度、日志收集。这些工作对 100 条命令没问题1000 条勉强10000 条开始吃力100 万条就是纯灾难。4.2 单条命令的完整生命周期开销具体一条命令在 Pipeline 里被执行大致经过以下环节解析脚本编译时创建对应的 AST 节点。包装CPS 引擎将命令包装成带状态的可恢复步骤。调度命令被放入执行队列等待 agent 资源。执行agent 上启动 shell 进程或复用 shell 内建执行命令。日志捕获 stdout/stderr通过网络向 master 回传。持久化执行状态写入磁盘用于断点续跑。回调触发后续步骤检查超时、重试条件。一百个命令跑完它也能跑通这七个环节吗可以。但一百万个命令每个都要走这七个环节很多环节的开销是乘法级增长最终整个系统不是在“执行命令”而是在“执行命令的管理开销”。4.3 字节码级类比解释器的“解释”成本用编程语言类比CI Pipeline 的执行模型更接近“解释执行”而不是“编译执行”。解释执行的典型问题是每次运行一行代码都要经历词法分析、语法分析、执行、收集结果这一整套流程。而编译执行可以把 100 万条命令一次性翻译成机器码后面运行起来就很轻量。Pipeline 的调度器就是那个“解释器”。它每处理一条命令都要做一轮完整的调度上下文切换而不会去合并优化这些命令。如果你真的需要执行 100 万条 shell 命令正确的方式是让 shell 一次性解释完整个脚本而不是让 Pipeline 充当那 100 万次解释的经纪人。4.4 GitLab CI 的对比不同架构同样的天花板既然相关搜索里也出现了 GitLab CI我把它也纳入对比。GitLab CI 的 Pipeline 模型和 Jenkins 不太一样它不搞 CPS 状态机而是把每个 job 当作一个原子单元由 Runner 拉取后独立执行。但这不代表 GitLab CI 就能扛住百万任务。恰恰相反我实际观察过 GitLab 的行为当 Pipeline 里生成成千上万个 job 时瓶颈转移到数据库和 Redis。GitLab 为每个 job 都要写数据库记录、更新状态、分配 Runner100 万 job 意味着 100 万次数据库写入。GitLab 的 Postgres 在没有针对性优化的情况下处理几万个 job 时就已经出现锁竞争和慢查询百万级直接让 Sidekiq 队列堆积到天荒地老。所以结论是通用的无论 Jenkins 还是 GitLab CI它们都是“任务编排器”不是“批量执行器”。编排器擅长描述有依赖关系的、需要人工介入的、需要可视化跟踪的少量任务。你要做的批量命令应该交给专门的执行引擎让编排器只负责发起和汇总结果。5. 真正要处理百万命令时我建议的替代做法5.1 原则一把“命令”从 Pipeline 中拆出去前面压测已经证明Pipeline 不适合承载百万命令的直接执行。所以核心思路是把命令放进普通的脚本文件或二进制程序让 Pipeline 只负责“开始”和“收集结果”。一个最简单可行的例子pipeline { agent any stages { stage(prepare-commands) { steps { // 从对象存储或代码库拉取一个已生成的命令清单 sh wget -q -O commands.tar.gz http://artifactory/commands.tar.gz sh tar -xzf commands.tar.gz } } stage(run-all) { steps { // 一次性执行整个命令集合Pipeline 不感知内部细节 sh bash runner.sh --input commands/ --parallel 8 } } stage(collect-report) { steps { // 只读取最终统计结果不再处理逐条执行记录 junit reports/*.xml } } } }这样做的好处是Pipeline 中的命令数量永远是“3 条”而不是“100 万条”。它们的生命周期开销几乎可以忽略不计。真正的执行负担完全落在 shell 脚本或专门的批处理工具上它们才是适合做大规模循环、并行处理和日志管理的工具。5.2 原则二任务体量分层让不同工具做擅长的事我的经验是把 CI 场景拆成三层第一层编排层Pipeline / CI 平台。只负责“开始”“停止”“结果展示”命令数量控制在百级以内。第二层执行层Shell / Python / Go 程序。负责大批量命令的实际运行支持并行、断点、重试命令数量可以到百万级。第三层数据层文件 / 数据库 / 对象存储。负责命令清单的存储、结果日志的持久化保证任何时刻都可以“只处理一批”。这个分层让你随时可以扩展。命令从 100 万涨到 1000 万你只需要增加执行层的并发度或者把命令文件分片而无需触碰 CI 平台本身。5.3 原则三用并行边界和批量窗口控制压力如果你确实需要在 CI 里触达十万这个量级必须建立批量窗口思想。比如每次从命令清单中读取 1000 条作为一个批次执行。批次内可以用xargs -P 8或 GNU Parallel 做并行。每个批次完成后把状态写入一个状态文件batch-001.done。整个 Pipeline 轮询状态文件而不是轮询单条命令。伪代码可以这样写# runner.sh total_batches1000 for ((batch0; batchtotal_batches; batch)); do start$((batch * 1000)) end$(((batch 1) * 1000)) # 从命令文件取第 start 到 end 行并行执行 sed -n ${start},${end}p commands.txt | xargs -P 8 -I {} sh -c {} echo batch ${batch} done progress.log donePipeline 只需要sh bash runner.sh如果中途失败检查progress.log从上次完成的批次继续而不是从头跑 100 万条。这就是完整的断点续跑机制比让 Pipeline 引擎去管理百万级状态要可靠得多。5.4 原则四结果回收要进“仓库”不要留在“流水线日志”命令执行完结果数据属于“产物”应当被系统化收纳而不是躺在流水线的控制台日志里。三种做法按照性价比排序写文件 归档执行器把每个命令的退出码、耗时、输出摘要写到 JSONL 文件然后用 CI 的 artifact 能力归档。入数据库每批命令完成后批量插入数据库方便后续检索分析。消息队列 消费者如果命令本身产生事件先入 Kafka / RabbitMQ再由消费者写入数仓。只有这样做百万量级的结果才可能被有效追踪。我在实际项目中用的是“文件 SQLite”组合每 1000 条命令一批写一个 SQLite 表批量插入查询非常快也不会拖垮 CI。5.5 选型参考适合“百万命令”的工具画像如果你的需求确实是“批量命令执行”而非“CI 流程编排”下面这些工具比 Pipeline 合适工具/方案适用场景优缺点GNU Parallel / xargs单机批量 shell 命令轻量、无学习成本但不适合跨机Ansible跨主机批量执行命令有幂等设计、适合运维但并发性能要调优SaltStack大规模服务器命令分发面向节点海量场景架构比 CI 更合适自研 Python/Go 执行器特殊业务逻辑、需要精准控制灵活度最高但需要开发维护成本Kubernetes Job/CronJob容器化批量任务适合真正海量且可并行每个任务独立资源这些工具的共性在于它们都允许你把“命令”当作数据而不是把“命令”当作流程节点。这一点的差异决定了百万量级能否落地。6. 上线前自查清单与监控要点6.1 从命令量级估算资源在把任何东西塞进 Pipeline 之前先用简单的公式估算一下单条命令运行时长秒 × 命令总数 串行总耗时秒串行总耗时 ÷ 期望的并行度 实际耗时秒单条日志量KB × 命令总数 日志总量MB拿我压测数据举例单条 echo 命令约 0.05 毫秒执行耗时shell 内建但日志量平均 0.7KB/条包含 Jenkins 记录的时间戳和元数据100 万条的日志总量约 680MB。如果单条命令耗时 1 秒串行总耗时就是 100 万秒约 11.6 天。即使并行度开到 32也需要接近 9 小时。所以任何超过 10 万条的命令集都必须问自己它真的需要在 CI 里跑吗它能接受这么长的窗口期吗它的结果如何归档这三个问题如果有一个答案是否定的就别用 Pipeline。6.2 关键监控指标清单如果你已经运行着大规模 Pipeline 或者准备压测以下指标必须提前接好监控Master JVM 堆内存重点看 GC 频率与 Full GC 时长。任务队列堆积数观察排队中的任务数量是否持续增长。日志写入吞吐每秒产生的日志行数和字节数。磁盘 IO 和空间构建目录增长速率。调度器状态Jenkins 的 executor 使用率、GitLab 的 Sidekiq 队列长度。6.3 告警阈值建议监控项建议阈值告警级别Master 堆内存使用率80% 持续 5 分钟警告Master 堆内存使用率95% 持续 1 分钟严重流水线单任务执行时间超过 60 分钟警告构建目录单次增量超过 500MB警告日志写入速率超过 5MB/s警告排队中的任务数超过 20严重这些阈值来自我自己的压测和运维经验可以根据团队规模适当调整。核心原则是比起事后补救提前让 Pipeline 快速失败更划算。6.4 Pipeline 最佳实践让流水线保持“可读性”最后一点是给正常使用 Pipeline 的同学的忠告。就算你永远不需要处理 100 万命令也请始终遵循以下做法stage 数量控制在 20 个以内。超出后流水线的可视化价值急剧下降。每个 stage 内 steps 控制在 10 个以内。如果超过抽取成 Shell 脚本并单独维护。避免动态生成 stage。凡是出现for循环生成 stage 的写法都该重构。把超时设置为显式值。别让任务无限期挂在那占满 executor。使用共享库封装复杂逻辑。把“做什么”写在脚本里把“何时做”写在 Pipeline 里。我见过太多流水线从“可读的步骤清单”退化成“一团麻线”。当一条流水线的日志超过 5 万行时它的维护成本就已经超过它能带来的自动化收益了。7. 收尾——我个人的一些体会这次压测做下来我最大的感受是CI 平台的脆弱不是 bug而是设计取舍的必然结果。Pipeline 这个抽象层为了可视化、可暂停、可重试牺牲了海量任务场景下的极致性能。这不是缺陷而是选择。所以核心问题从来不是“能不能塞进去”而是“应不应该塞进去”。我自己现在处理大批量命令时早已习惯“只在流水线里保留 3-5 个顶层步骤”的写法。命令清单放代码库执行逻辑放 shell 或 Python 脚本结果归档放数据库。Pipeline 在我的架构里扮演的就只是一个带界面的“总开关”而不是万能工具箱。最后分享一个非常实用的小技巧如果你实在没办法、必须在 Pipeline 里跑很多条命令把命令写进一个.sh文件然后在 Pipeline 里只写一次sh run_all.sh。只做这一步改造你的 CI 系统就能同时处理原来十倍甚至百倍的命令量。原因无它——你只是把解释权从调度器还给了 shell而它天生就是干这个的。