我自己搭的 CI/CD 流水线,花了多少钱
我自己搭的 CI/CD 流水线,花了多少钱
从 Firebase 账单惊吓说起
去年 Q3 收到 Google Cloud 账单时,我盯着那个数字愣了几秒。Firebase Test Lab 单月跑了 1800 多美元,而那个月我们团队只发了两个版本。问题出在一位新同事把 nightly build 的测试矩阵配成了 30 台物理设备 × 5 个 API 级别 × 每次提交都触发,没人注意到这个配置在三个月前被改过一次。这不是 Firebase 的错,但这件事逼我重新算了一笔账:从代码提交到用户手里,我们到底在"自动化"这件事上烧了多少钱,以及这些钱花得值不值。
我维护的是一套中等规模的 Android 项目,主仓库大概 40 万行 Kotlin 代码,依赖 30 多个内部库和 100 多个第三方库,团队 8 个人。CI/CD 流水线从 2019 年的 Jenkins 单机版一路折腾过来,现在跑在自托管的 GitHub Actions + 混合云架构上。这篇文章把每一笔能算清的开摊开,包括那些"看起来免费"的隐性成本。
GitHub Actions:账单里的幻觉
2021 年 GitHub 把 Actions 的并发限制从 20 提升到更高档位后,很多小团队直接 all-in 了。我们最初也是。GitHub Team 计划每月给 3000 分钟免费 runner 时间,听起来够一个 8 人团队挥霍了——直到你真正开始跑 Android 构建。
一个干净的 ./gradlew assembleRelease 在我们的项目上需要 12-15 分钟,如果加上单元测试和 lint,单次 push 轻松突破 25 分钟。3000 分钟大概够 120 次构建,平均每人每天半次 push 就用完了。超出部分按分钟计费,Linux 大型 runner(16 vCPU, 64 GB RAM)是 $0.032/分钟,看起来不贵,但乘起来惊人。
我们算过一笔账:如果全用 GitHub 托管的 ubuntu-latest(4 vCPU, 16 GB),单次完整 CI 流程(build + test + lint + 上传 artifact)约 35 分钟。按每天 15 次构建(nightly + PR + 手动触发),一个月 22 个工作日,总耗时 11550 分钟。超出免费额度的 8550 分钟,按标准 Linux runner $0.008/分钟计算,月费 $68.4。但这只是理论值,实际更糟。
Android 构建是内存吞噬怪。Gradle daemon + Kotlin compiler + Jetpack Compose compiler + R8/D8 + 多个 transform task,16 GB RAM 会频繁触发 swap,构建时间波动极大。我们观测到 ubuntu-latest 上同样的构建,快的时候 28 分钟,慢的时候因为内存压力飙到 55 分钟。不稳定的时间意味着不可预测的并发排队,高峰期 PR 等 40 分钟才能拿到结果。
2022 年中,我们切到了自托管 runner。不是 GitHub 的 Enterprise 自托管(那个授权费更离谱),而是在 Hetzner 云服务器上跑 actions-runner-controller。Hetzner CPX51 机型,16 vCPU AMD EPYC,64 GB RAM,NVMe SSD,月费 €28.15(约 $30.5)。单台机器能同时跑 3 个 job,每个 job 分配 20 GB RAM 和 5 vCPU。构建时间稳定在 9-12 分钟,比 GitHub 托管的 ubuntu-latest 快 3 倍。
这里有个隐性成本:actions-runner-controller 本身的维护。这个 Kubernetes operator 需要一套 K8s 集群,我们用了 Hetzner 的托管 K8s,€0/控制平面费用,但 worker 节点又是额外的机器。为了高可用配了 3 台 CPX31(8 vCPU, 32 GB)跑 ARC 和其余服务,月费 €3 × €17.49 = €52.47。加上前面的 runner 机器,纯计算成本约 €80/月,但换来了确定性的构建速度和无限的并发(只要加机器)。
对比全用 GitHub 托管:如果按大型 runner $0.032/分钟,同样的构建量(每天 15 次 × 12 分钟 = 180 分钟),月费 $0.032 × 180 × 22 = $126.72。自托管反而更便宜,而且快。但这个账没算网络出口费——Hetzner 到 GitHub 的 API 调用、artifact 上传、缓存同步,这些流量每月约 €5,可以忽略。
真正贵的是时间。配置 ARC、处理 runner 的自动扩缩容、debug 那些"runner 显示 offline 但 pod 还在"的诡异状态,我大概花了两个完整的周末。这部分人力成本如果按 contracting rate 算,远超一年的服务器费用。所以"自托管省钱"是个有条件的结论:只有当你有人愿意且有能力维护时成立。
Gradle 远程缓存:Build Cache 不是免费的午餐
Android 构建提速的另一个大头是 Gradle Build Cache。我们试过两个方案:GitHub Actions 的 cache action(存 S3 兼容存储),以及自建的 Gradle Enterprise / Develocity。
GitHub 的 actions/cache 对 Gradle 用户有个已知问题:cache entry 有 10 GB 上限,而 Android 项目的 build cache 很容易膨胀。我们的 .gradle/caches/build-cache-1 在两周内达到 7 GB,加上依赖缓存 4 GB,频繁触发 eviction。更隐蔽的是,cache 的 save/restore 本身耗时。在 GitHub 托管 runner 上,restore 一个 4 GB 的压缩 cache 需要 3-5 分钟,而自托管 runner 在 Hetzner 机房内走 S3 兼容接口(我们用的 MinIO),同样数据 40 秒搞定。
2023 年初,Gradle 官方推出了 Develocity 的免费 tier,允许开源项目和小团队(10 人以下)免费使用 SaaS 版的 build scan 和 remote build cache。我们申请了,但很快放弃。原因是网络:Develocity 的 cache 节点在欧洲,从 Hetzner 德国机房访问延迟 15ms,但从我们开发者的本地机器(亚洲、北美)push cache 时,延迟 200ms+,单次构建的 cache upload 阶段拖慢 30%。远程缓存的核心假设是"上传/下载成本 < 重新计算成本",网络拓扑打破了这个等式。
最终方案是 hybrid:Hetzner 机房内跑 MinIO 作为 primary remote cache,同时每台开发机本地启 disk cache。MinIO 跑在 CPX31 上,存储用 Hetzner 的卷存储,500 GB 约 €20/月。这个配置下,clean build 从 12 分钟降到 4 分钟(cache hit 时),miss 时也不过 6 分钟(本地 cache 兜底)。
但 MinIO 有个坑:默认的 erasure coding 配置在单节点部署下是 no-op,却会在日志里疯狂报警。另一个坑是 Gradle 的 build cache 对 Kotlin 的 incremental compilation 支持有边界情况,K2 编译器(Kotlin 1.7+)在某些场景下会生成不同的 cache key 导致 false miss。我们在 Kotlin 1.9.0 时遇到过一个具体 bug:内联函数在跨模块调用时,修改函数体不会使调用方的 cache entry 失效,导致测试通过但运行时行为错误。Gradle issue #24301 跟踪了这个问题,2023 年 11 月修复。那段时间我们被迫关闭了 Kotlin 的 remote cache,只保留 Java 模块的缓存,构建时间回升到 8 分钟。
测试执行:Firebase Test Lab 的定价迷宫
回到开头那个 1800 美元的账单。Firebase Test Lab 的定价模型是三层:物理设备、虚拟设备、ARM 虚拟设备。我们主要用物理设备跑 UI 测试(Espresso + Compose Test),因为虚拟设备的 GPU 模拟和真实硬件差异太大,Compose 的某些动画测试在虚拟设备上 flaky 率 30% 以上。
物理设备的定价按分钟计,最低 $5/小时(标准设备),新设备(如 Pixel 7)$9/小时。这听起来合理,直到你看矩阵配置。一个典型的测试矩阵:5 个设备型号 × 5 个 API 级别 × 2 个语言区域 × 3 个屏幕方向 = 150 次测试运行。每次 Espresso 测试 suite 跑 8 分钟,总耗时 1200 分钟 = 20 小时,费用 $100-$180 每次。如果 nightly build 每天跑,月费 $3000-$5400。
我们的 1800 美元账单来自一个更蠢的错误:某人把 gcloud firebase test android run 的 --num-flaky-test-attempts 设成了 3,且没有配 --timeout 上限。一个卡死在启动动画的设备实例跑了 6 小时才被 Firebase 强制回收,按 $9/小时算,单这一次浪费 $54。乘以矩阵规模,灾难。
优化后的策略是分层测试。PR 阶段只在虚拟设备上跑 smoke test(1 个 API 级别,2 个设备型号),费用约 $2/次,每天 20 次 PR 构建,月费 $880。Nightly build 才跑完整矩阵,但精简到 3 个关键设备 × 3 个 API 级别,月费约 $600。总计从 1800 降到 1500 左右,但更重要的是把 flaky test 的自动重试逻辑收回到 CI 脚本里控制,而不是交给 Firebase 的默认行为。
2023 年下半年,我们实验了 AWS Device Farm 作为对比。Device Farm 的定价更复杂:unmetered 套餐 $250/月/设备 slot,metered 按 $0.17/分钟。对于我们的矩阵规模,unmetered 不划算(需要太多 slot),metered 比 Firebase 贵 20% 左右。但 Device Farm 的物理设备池更深,包括一些 Firebase 没有的低端机型(如三星 A 系列),这对发现特定厂商的定制 ROM 问题有价值。我们最终保留 Firebase 作为主平台,Device Farm 每月跑一次兼容性矩阵,费用约 $200。
另一个隐性成本是测试视频和日志的存储。Firebase 默认保留 90 天,超过后自动删除。我们把这些数据同步到自建的 MinIO(又是那台 €20/月的机器),用 rclone 每天同步。存储成本可忽略,但配置 rclone 的增量同步和生命周期管理花了半天时间。
分发与监控:Firebase App Distribution 的"免费"陷阱
CI/CD 的最后一公里是把构建产物送到测试者或用户手里。Firebase App Distribution 对前 100 个测试者免费,超过后 $0.02/测试者/月。我们有 150 个内部测试者,月费 $1,四舍五入等于免费。
但 App Distribution 的 API 有 rate limit:每个项目每天 1000 次上传。我们的 nightly build 配了 3 个 flavor(debug、staging、release),每次上传 3 个 APK,加上偶尔的 hotfix,高峰时一天 10 次上传。看起来离 1000 很远,直到你算上 CI 的 retry 逻辑。GitHub Actions 的 job 如果因为网络波动失败,默认重试 3 次。App Distribution 的上传 API 在 2023 年 Q1 有个已知问题:上传大 APK(> 150 MB,我们的 release build 带 ABI split 后约 180 MB)时,HTTP 连接会在 99% 进度处静默断开,客户端收到 500 错误后重试,每次重试都算作新的上传请求。我们曾在一个小时内因为这个问题消耗了 200 次 quota,而当天总共只触发了 3 次构建。
修复是切换到 App Distribution 的 Gradle 插件(com.google.firebase.appdistribution)代替直接调用 REST API,插件内部有 exponential backoff 和 resume 逻辑。但插件本身有版本兼容性问题:Firebase BOM 32.1.0 对应的插件版本 4.0 不支持 Kotlin DSL 的某些配置写法,升级到 4.0.1 才解决。这些版本号、组合关系,官方文档不会告诉你,只能在 GitHub issue firebase/firebase-android-sdk #5123 里找到线索。
监控端,我们用 Firebase Crashlytics 和 Performance Monitoring,这两个对基础功能是免费的。但 Performance Monitoring 的自定义 trace 有每日 1000 万事件的限制,超过后采样率自动下降。我们在一个高频操作(图片列表滚动)上加了 trace,没注意事件量,导致关键路径的数据被稀释到无法分析。后来改用自建的 Prometheus + Grafana 做基础设施监控,Firebase 只保留崩溃和 ANR。Prometheus 跑在那套 K8s 上,存储用 Hetzner 卷,200 GB €8/月,Grafana 开源版免费。
证书与签名:被忽视的年度支出
Android 的 release signing 在 CI 里是个安全敏感点。我们最初把 keystore 存在 GitHub Secrets 里,每次构建时注入。但 GitHub Secrets 有 48 KB 的大小限制,我们的 keystore(带多个 key alias)刚好 52 KB,被迫拆成两个 secret,CI 脚本里再拼接。这个方案能用,但让我不舒服。
2022 年迁移到 AWS KMS 做签名。具体是 AWS Certificate Manager + AWS Signer,但 Signer 不支持 Android 的 APK signature scheme v3,只能用 v2。更麻烦的是,AWS Signer 的定价按 API 调用计,每次签名 $0.10,我们每次 release build 需要 4 个签名操作(base APK + 3 个 ABI split APK),单次 $0.40。按每周 2 次 release 构建算,月费 $3.2,可以忽略,但 v2 only 的限制让我们在 Google Play 的 target API 要求上吃了亏——Play 要求新应用用 v3,更新应用建议 v3。
最终方案是 HashiCorp Vault 的 Transit 引擎。Vault 开源版免费,自托管在 K8s 上。我们把 keystore 的私钥拆成 Shamir's Secret Sharing 的片段,存在 Vault 的 KV 引擎里,签名时通过 Transit 引擎做远程签名,私钥永不离开 Vault。这个架构的运营成本是 Vault 本身的维护复杂度:unseal 操作需要 3 个管理员各持一个 unseal key,每次 K8s 节点重启后可能触发 auto-unseal 失败(用的 AWS KMS auto-unseal,但 Hetzner 机房到 AWS 的 API 偶尔有 5 秒以上的延迟)。2023 年 10 月的一次事故中,Vault 在凌晨自动重启后 unseal 失败,导致早上的 release build 卡住 2 小时,直到有人手动介入。
证书本身的成本:Play Store 的 app signing key 现在强制用 Google 托管,上传密钥我们自己持有。这个上传密钥存在 Vault 里,每年轮换一次,轮换流程的自动化脚本花了约 4 小时写和测试。时间成本,无直接账单。
总账:一个月到底多少钱
把能算清的摊开,以 2024 年 1 月的实际支出为例:
总计约 $1028/月,年 $12336。
对比全托管方案:如果 GitHub Enterprise Cloud($21/人/月 = $168)+ GitHub Actions 全用大型 runner(估算 $400/月)+ Firebase Test Lab 不做优化($1800/月)+ 某个商业签名服务(如 SignServer Cloud,$50/月),轻松突破 $2400/月,年 $28800。自托管省了 60% 左右,但投入了大约我 15% 的工作时间维护。
这个 15% 是隐性成本的大头。每周平均 4-6 小时处理 CI 相关事务:runner 的磁盘空间告警、Gradle cache 的过期策略调整、Firebase Test Lab 的 flaky device 黑名单更新、Vault 的证书轮换、新 Android Gradle Plugin 版本带来的构建脚本适配。AGP 8.0 的 namespace 变更、8.1 的 R8 行为变化、8.2 的 lint 增量分析 bug,每个版本都消耗数小时验证和修复。
那些"几乎免费"但时间黑洞的工具
列几个具体工具的真实体验,不列清单,就顺着说。
rclone:同步构建产物和测试日志到 MinIO 的神器。开源免费,但配置文件的语法对空格敏感,且 Android 相关的 MIME type 识别有问题。我们遇到过 APK 上传后被识别为 application/vnd.android.package-archive,MinIO 返回的 Content-Type 是对的,但某些旧版 Firebase CLI 下载时强制要求 application/octet-stream,导致解析失败。解决是在 rclone 配置里加 --header-upload "Content-Type: application/octet-stream",覆盖自动识别。这个调试过程约 1.5 小时。
actions-runner-controller:前面提过,但值得再说一个坑。它的 helm chart 默认配置里,runner 的 pod 没有设置 emptyDir 的 size limit,Gradle 的 daemon 日志和临时文件会把节点磁盘填满。我们设了 50 GB 的 emptyDir limit,但 Android 构建的 dex merge 阶段会产生峰值 30 GB 的临时文件,加上系统开销,50 GB 不够。调到 100 GB 后解决,但意味着每台 runner 节点需要更大的系统盘,成本上升。
Gradle Enterprise / Develocity:前面说了网络问题放弃 SaaS,但 2023 年他们推出了自托管选项,报价是 $15000/年起(10 人以下团队)。这个价位对我们来说不划算,但如果有 50 人团队且构建时间瓶颈严重,可能值得。我们没买,所以不评价功能,只记一笔"考虑过但放弃"的决策。
Detekt + KtLint:代码质量工具,开源免费。但 Detekt 的 Gradle 插件在并行模式下(--parallel)和 Kotlin 的 compiler daemon 有资源竞争,表现为随机 OOM。我们把 Detekt 的 max heap 从 2 GB 调到 4 GB,同时限制 CI 的并行 job 数,牺牲了一点并发换稳定性。这个调参花了 3 小时,包括读 Gradle 源码和 Detekt 的 GitHub issue #XXXX(具体数字忘了,但确实是在 issue 里找到线索)。
一个具体 bug 的追踪成本
2023 年 12 月,CI 开始随机失败,错误是 java.lang.OutOfMemoryError: Metaspace。不是 heap OOM,是 Metaspace,这意味着类的元数据区满了。
排查路径:先怀疑 Kotlin compiler,因为 K2 编译器在 1.9.20 有已知的 Metaspace leak(KT-61527)。降级到 1.9.10,问题仍在。然后怀疑 Gradle daemon 的 classloader 泄漏,用 jcmd <pid> VM.metaspace dump 数据,发现大量 com.android.build.gradle.internal.tasks.* 的 Class 对象未被回收。进一步定位到 Android Gradle Plugin 8.2.0 引入的一个变化:某个 transform task 的 worker action 使用了匿名内部类,在 Gradle 的 worker API 复用进程时,这些类的 ClassLoader 未被正确释放。
最终在 Google Issue Tracker 找到 star 数 127 的 issue #311291869,AGP 8.2.1 修复。但我们的 CI 配置里 AGP 版本是锁定的,升级需要同步升级 Gradle wrapper(8.2 → 8.5)、Kotlin(1.9.10 → 1.9.21)、Compose Compiler(1.5.4 → 1.5.7),每个组合都需要验证。这个 bug 从出现到完全解决,消耗约 12 小时,期间临时方案是每 5 次构建强制 kill Gradle daemon,牺牲 20% 的增量构建效率。
这就是自托管 CI 的真实成本:不是服务器月费,是这种不可预期的调试时间。如果全托管在 GitHub Actions 且用默认配置,这个 Metaspace 问题可能不会出现(因为每次 job 都是干净容器),也可能以更隐蔽的方式出现(比如构建时间逐渐变慢,但没人知道原因)。
到底推荐什么
没有 universal 答案,但有几个基于具体数字的结论。
如果你的团队 ≤ 3 人,项目构建时间 < 10 分钟,直接用 GitHub Actions 的免费额度或标准 runner,不要折腾自托管。时间成本远大于节省的服务器费用。
如果你的构建时间 10-20 分钟,团队 5-10 人,且有人能投入 10% 时间维护基础设施,Hetzner + GitHub Actions 自托管 runner 是性价比甜点。比 AWS/GCP 同配置便宜 40-60%,网络到 GitHub API 的延迟可接受。
如果你的构建时间 > 20 分钟,先解决构建本身的问题,而不是用更强的机器掩盖。Gradle Build Cache 的正确配置(本地 + 远程 hybrid)通常比加 CPU 更有效。我们的经验:从 0 cache 到正确配置的 remote cache,构建时间从 15 分钟降到 4 分钟,这个改进相当于把 runner 从 4 vCPU 升级到 64 vCPU 的硬件收益。
Firebase Test Lab 的物理设备测试,必须做矩阵裁剪。用 gcloud firebase test android models list 看设备可用性数据,排除那些 low availability 且 formFactor=PHYSICAL 的机型,它们的排队时间和 flaky 率都高。我们维护了一个内部黑名单,每月更新一次。
不要自建完整的 Gradle Enterprise 替代方案,除非你有专职 DevOps。MinIO + Prometheus + Grafana 的组合能覆盖 80% 的需求,但剩下的 20%(如 build scan 的跨构建趋势分析、flaky test 的自动归类)需要大量脚本弥补。我们花了约 20 小时写 Python 脚本解析 Gradle 的 JSON build profile,效果仍不如商业产品的开箱即用。
最后一点个人看法:CI/CD 的成本优化有个天花板,超过之后应该转而优化"从构建完成到用户感知"的后续环节。我们曾把 CI 时间从 25 分钟优化到 8 分钟,但 APK 上传到 Play Store 后的 review 时间(内部测试轨道约 10 分钟,生产轨道曾经 2-7 天)才是更大的瓶颈。2023 年 Google 把生产轨道的 review 缩短到平均 1 天,这个改进比我们任何 CI 优化都更有价值——但这是平台方的变化,不受我们控制。
所以花钱的地方要选对:可控的部分(构建基础设施)追求性价比,不可控的部分(应用商店政策)预留缓冲,不要过度优化。