那年我们那个百万日活的技术社区项目,后端是Go 1.21写的,前端是Vue 3,数据库用的PostgreSQL 15,之前一直跑在Jenkins上做CI/CD。Jenkins那台服务器是3年前买的,8核16G,刚开始还行,后来项目拆成了12个微服务,每次构建排队能排20多分钟,尤其是晚上发版高峰期,开发同学经常在那抱怨“又卡构建”。
我们当时算过一笔账:Jenkins服务器每月电费加运维成本大概1200块,还不算我每周花2-3小时修插件冲突、清磁盘空间的时间。有一次Jenkins的Git插件突然兼容性问题,导致所有构建都拉不到代码,我折腾了半天才搞定,那时候就动了换CI/CD工具的念头。
对比的时候我们列了几个核心指标:和代码仓库的联动效率、多环境部署的复杂度、维护成本。Jenkins要配GitHub Webhook,每次新仓库都要手动建任务,权限还得单独配;GitHub Actions直接读仓库里的.github/workflows目录,PR一提交自动触发,根本不用额外配置。还有矩阵构建,我们之前用Jenkins跑Go 1.20和1.21的单元测试,得写两个任务,GitHub Actions一个矩阵配置就搞定,并行跑的话时间能省一半。
迁移的时候我们没敢一下子全换,先拿前端项目试水。前端项目之前用Jenkins构建,每次要装Node.js 18,缓存npm包还得手动配路径,经常因为缓存失效导致构建从5分钟变成15分钟。换成GitHub Actions后,用actions/cache缓存node_modules,第一次构建8分钟,之后基本稳定在2分半。我们当时测了10次构建,平均耗时从Jenkins的9分20秒降到了2分45秒,这差距太明显了。
不过迁移也不是一帆风顺。我们后端有个服务依赖私有GitLab的包,GitHub Actions的托管Runner访问不到内网,这时候就得用自托管Runner。我们找了台闲置的4核8G服务器装了自托管Runner,配了内网DNS,拉取私有包的速度比Jenkins还快,因为Runner就在内网里。但这里要注意安全,自托管Runner的权限别给太高,我们当时只给了它访问特定仓库的权限,不然万一Runner被入侵,整个内网都有风险。
现在整个项目12个微服务、3个前端项目全迁到GitHub Actions了,每月省下来的服务器成本差不多1000块,我也不用每周花时间维护Jenkins了。2024年3月GitHub推出的ARC 2.0我们也在测试,用Kubernetes管理Runner,大促的时候能自动扩容,比之前固定几台Runner灵活多了。要是还在用Jenkins,大促前还得提前加服务器,用完又闲置,太浪费。
下面是我们前端项目迁移后的基础工作流配置,当时为了适配Vue 3的构建,特意加了Node.js 18的版本指定和npm缓存:
这个配置里actions/checkout@v4是最新的版本,2024年更新的,支持更大的仓库克隆。npm ci比npm install快,因为我们package-lock.json是锁死的,避免依赖版本不一致的问题。之前用Jenkins的时候经常因为依赖版本飘导致构建失败,换成这个之后就没再出现过。
我们那个电商订单系统,去年双11的时候峰值QPS到了1.2万,后端是Go 1.21写的,分了订单服务、支付回调服务、库存服务三个核心微服务,部署在阿里云ACK的Kubernetes集群里,分开发、测试、预发、生产四个环境。之前部署全靠手动敲kubectl命令,有一次开发同学把测试环境的配置改了,结果预发环境也跟着出问题,排查了半天才发现是配置没隔离。
后来用GitHub Actions做多环境部署,核心就是环境隔离加矩阵构建。我们给每个环境建了对应的GitHub Environment,生产环境还开了审批,每次部署生产必须我或者技术负责人点确认才能继续。密钥管理也省心,每个环境的数据库密码、阿里云AK都存在对应环境的secrets里,Runner跑的时候自动注入,不用写在代码里。
矩阵构建我们主要用在单元测试和Docker镜像构建上。订单服务的单元测试要跑Go 1.20、1.21两个版本,还要在Ubuntu和macOS上跑,之前要写4个任务,现在一个矩阵就搞定。我们测过,并行跑的话,4个组合的总耗时是12分钟,比之前串行跑的28分钟省了一半多时间。还有Docker镜像构建,我们要推到阿里云镜像仓库和GHCR两个地方,用矩阵指定不同的仓库地址,一次构建两次推送,省得重复跑构建步骤。
这里有个细节,增量构建的缓存很重要。订单服务的依赖包有300多MB,每次全量下载要3分钟,我们用actions/cache缓存$GOPATH/pkg/mod,第一次缓存后,后续构建依赖下载基本是秒级。还有Docker镜像的层缓存,用docker/build-push-action的cache-from和cache-to参数,能把镜像层的缓存存在GitHub Actions的缓存里,构建时间从之前的5分钟降到了1分半。
我们之前遇到过一个坑,矩阵构建里的变量没传对,导致生产环境的镜像打成了测试环境的标签。后来在步骤里加了变量校验,先把矩阵里的环境参数打印出来,确认对了再继续。还有多环境部署的时候,预发环境要用生产环境的配置副本,但改几个关键参数,我们用envsubst命令替换配置文件里的变量,比如数据库地址、Redis地址,这样不用维护多份配置文件。
下面是我们订单服务的多环境部署加矩阵构建的完整工作流,里面包含了Go多版本测试和Docker镜像多仓库推送:
这个配置里exclude部分是排除GHCR推送到测试、预发、生产环境,因为我们只用阿里云镜像仓库部署这几个环境。needs: test保证测试全通过了才构建部署,不然有问题的代码直接就阻断了。生产环境的environment配置了审批,我这边会收到GitHub的通知,确认代码没问题才点通过,避免误操作发版。
上个月我们支付回调服务有个PR合并后,GitHub Actions的构建突然失败了,日志里只显示exit code 1,具体哪行错了根本看不出来。那个服务是Go 1.21写的,依赖了3个私有库,之前构建一直好好的,那次改的只是回调逻辑的注释,怎么会失败?
我一开始以为是依赖问题,把go.sum删了重新生成,还是失败。然后看实时日志,发现拉取私有库的时候有个403 Forbidden,但密钥没改啊。那时候刚好GitHub 2024年10月上线了AI辅助工作流调试功能,我点了一下日志旁边的「AI分析」按钮,没过10秒就给出了可能的原因:「检测到私有库访问凭证权限不足,建议检查secrets中GH_PRIVATE_TOKEN的权限范围,是否包含读取对应私有仓库的权限」。
我顺着这个提示去看,发现那个私有库上周改了权限,只有维护者才能访问,而我们的GH_PRIVATE_TOKEN对应的账号只是开发者,所以拉取失败。之前没有AI调试的时候,这种问题我得翻半天权限文档,还要对比之前的成功日志和失败日志的差异,至少得花20分钟,这次5分钟就定位到了。
不过AI也不是万能的,有一次我们前端项目构建失败,AI分析说「可能是Node.js版本不匹配」,但实际是npm run build的时候内存溢出了。那时候我们构建的Runner是GitHub托管的ubuntu-latest,默认内存是7GB,我们项目打包的时候要处理1.2万张图片,内存不够用。AI没检测到内存问题,因为它只能看日志里的文本,看不到系统资源使用情况。后来我把Runner换成自托管的16GB内存的服务器,构建就成功了。
还有一次用ARC 2.0管理的Kubernetes Runner,构建的时候突然Pending,AI分析说「Runner资源不足」,但实际是ARC的自动扩容配置错了,最大副本数设成了1,大促的时候Runner不够用。我查了ARC 2.0的配置文档,把maxReplicas改成5,就好了。这里要注意,ARC 2.0的自动扩容是基于队列里的任务数的,不是基于CPU/内存的,所以配置的时候要根据任务数来设,不然扩不出来。
下面是我们支付回调服务那次修复后的工作流配置,里面加了私有库访问的凭证配置,还有AI调试的时候发现的问题对应的修复部分:
这个配置里加了~/.netrc的配置,就是那次AI调试后加的,之前只用git config有时候还是会拉取失败,加了netrc之后就稳定了。还有fetch-depth: 0是为了拉取所有提交历史,方便后续做代码覆盖率统计的时候关联提交。
现在每次构建失败,我第一反应就是点AI分析,大部分常见问题它都能直接定位,比如依赖版本冲突、密钥过期、语法错误这些,能省至少一半的排查时间。但复杂的问题,比如资源不足、网络波动、K8s配置问题,还是得自己结合日志和实际场景来判断,AI只是个辅助工具,不能完全依赖。
去年我接手了一个电商中台的重构项目,这个仓库里塞着订单、支付、商品、用户四个子系统的代码,是典型的Monorepo结构。那时候CI跑一次要22分钟,开发同学提个PR经常要等半小时才能看到结果,有时候改一行代码,构建日志刷得老长,大家怨声载道。我印象最深的是有一次大促前的紧急修复,一个同学改了支付服务的配置,结果CI卡了25分钟,差点误了发布窗口。那时候我就意识到,这种体量的Monorepo,不优化缓存和Runner资源,CI就是个瓶颈。
为什么Monorepo的CI会慢?因为它的依赖关系太复杂了。比如订单服务依赖商品服务的SDK,商品服务又依赖用户服务的工具类,每次构建如果不做缓存,就得把所有依赖重新下载、编译一遍。我们当时用的是GitHub托管的Runner,默认配置下,npm install就要花8分钟,mvn package要花10分钟,剩下的时间耗在测试和各种检查上。
我先从缓存入手。GitHub Actions的actions/cache是核心工具,但很多人用不好。我当时的策略是分层缓存:第一层缓存全局依赖,比如node_modules或者Maven的本地仓库;第二层缓存子项目的构建产物,比如各个微服务的jar包或者前端dist目录。这里有个细节要注意,缓存的key不能只写个固定值,得结合依赖文件的哈希值。比如前端的package-lock.json,后端的pom.xml,只要这些文件没变,依赖就不需要重新下载。
我们当时前端用的是pnpm,它的pnpm-lock.yaml是依赖锁定的关键。我写了这样的缓存配置:
这里hashFiles('**/pnpm-lock.yaml')会计算所有子项目下pnpm-lock.yaml的哈希,只要依赖没变,缓存就命中。实际跑下来,pnpm install的时间从8分钟降到了40秒,因为大部分依赖都从缓存里直接拿了。
后端是Java项目,用Maven构建。Maven的本地仓库默认在~/.m2/repository,我把它缓存起来:
这个缓存让mvn dependency:resolve的时间从10分钟降到了1分钟以内。但光缓存依赖还不够,构建产物也得处理。我们当时用mvn package打包每个子服务,每个包都要重新编译。后来我引入了增量构建的思路,用Git的diff判断哪些子项目变了,只构建变更的部分。比如订单服务的代码改了,就只构建订单服务,其他没变的就跳过。这需要写个简单的脚本:
然后在构建步骤里,只构建steps.changed-modules.outputs.modules里列出的模块。这个改动让构建时间又少了5分钟。
但缓存和增量构建还是解决不了Runner资源的问题。GitHub托管的Runner是共享的,高峰期排队时间长,而且我们的Monorepo构建需要4核8G的配置,GitHub托管的Runner有时候资源不够,构建会变慢。这时候我想到了2024年3月GitHub推出的Actions Runner Controller (ARC) 2.0,它是Kubernetes原生的Runner管理工具,可以让我们在自己的K8s集群里跑Runner,按需启动,用完就释放。
我之前没用过ARC,一开始担心配置复杂,但实际操作下来比想象中简单。首先要在K8s集群里安装ARC的控制器,用Helm就行:
然后创建一个RunnerDeployment,定义Runner的规格。我们当时给Monorepo的CI配了4核8G的Runner,最多同时跑5个:
这个配置的意思是,当CI任务来了,ARC会自动在K8s里启动一个4核8G的Pod作为Runner,任务完成后Pod就销毁。我们当时算过一笔账,GitHub托管的Runner跑一次Monorepo的CI要22分钟,成本是$0.008/分钟,一次就是$0.176。用ARC的话,我们的K8s集群是包年的,闲置资源多,算下来一次CI的成本不到$0.05,而且构建时间降到了8分钟,因为Runner是我们自己的,资源有保障,不用排队。
实际部署ARC 2.0后,我们遇到了个问题:Runner启动太慢。一开始每次启动要2分钟,后来发现是镜像拉取的问题。我们把Runner镜像预加载到K8s集群的节点上,启动时间降到了30秒。还有一次,缓存命中了但构建还是慢,排查下来是因为actions/cache的restore步骤在K8s环境里网络延迟高,后来我们把缓存存在K8s集群的PVC里,速度就上来了。
综合这些优化,我们的Monorepo CI从22分钟降到了8分钟,提速了60%多。开发同学提PR后,10分钟内就能看到结果,大促前的紧急修复也不用担心CI卡壳了。这里有个细节,ARC 2.0支持弹性伸缩,我们设置的最大副本是5,所以即使同时有5个PR在跑CI,也不会排队,这在之前用GitHub托管Runner时是不可能的,因为GitHub托管的Runner并发数有限制。
去年我们公司有个内部的数据分析平台,代码存在GitHub私有仓库里,CI需要访问内网的数据源,比如MySQL集群和HDFS。一开始用GitHub托管的Runner,根本访问不到内网,后来只能搭自托管Runner。但自托管Runner的安全问题让我头疼了很久——Runner是直接跑在我们内网机器上的,如果Runner被攻击了,内网就暴露了。我印象最深的是有一次,一个开发同学不小心在代码里写了个恶意的脚本,CI运行时试图访问内网的Redis,虽然没成功,但让我意识到隔离的重要性。
自托管Runner和GitHub托管Runner的核心区别是什么?GitHub托管Runner是GitHub管理的,运行在GitHub的云环境里,你不用管维护,但访问不了你的内网资源;自托管Runner是你自己部署的,可以跑在内网、私有云或者本地机器上,能访问你的内部资源,但你要自己负责安全、维护和成本。
我们当时的隔离方案是分层的。第一层是网络隔离:自托管Runner部署在一个单独的VPC里,这个VPC只能访问必要的内网服务(比如CI需要的MySQL、Redis),不能访问核心业务系统。我们用安全组规则限制了Runner的出口流量,只允许访问GitHub的API(用于拉取代码、上报状态)和必要的内网服务。第二层是权限隔离:Runner运行的用户不是root,而是专门的runner用户,权限被限制到最小。比如Runner只能读写CI的工作目录,不能修改系统文件。第三层是作业隔离:每个CI作业运行在独立的Docker容器里,作业完成后容器就销毁。这样即使一个作业有问题,也不会影响其他作业。
具体怎么实现呢?我们当时用Docker来运行自托管Runner的作业。GitHub Actions的自托管Runner支持配置runners.{name}.docker,让每个作业都在Docker容器里执行。我们的配置是这样的:
这里的our-company/ci-runner-image是我们自己构建的镜像,里面预装了Node.js、Python、Maven等构建工具,还有必要的CA证书,用于访问内网的HTTPS服务。作业运行时,Runner会启动这个镜像的容器,在容器里执行CI步骤。容器是临时的,作业结束后自动删除,所以不会有残留的文件或进程。
但自托管Runner的成本也是个问题。我们一开始用了3台物理机作为Runner,每台4核8G,一年成本是$3000左右。但这些Runner不是一直满负荷运行的,比如晚上和周末,CI任务很少,机器就闲置了。这时候我想到了2024年GitHub Actions的一个趋势:Serverless Runner。虽然GitHub官方还没完全推出,但社区里已经有方案了,比如用AWS Lambda或者阿里云函数计算来运行Runner,按需付费,不用的时候不花钱。
我们当时试了用AWS Lambda来实现Serverless Runner的思路。具体做法是,写一个Lambda函数,当GitHub有CI任务时,通过Webhook触发Lambda,Lambda启动一个临时的EC2实例作为Runner,运行CI作业,作业完成后销毁EC2。这个方案的代码大概是这样的(简化版):
这个方案的好处是成本按需计算。比如我们的CI任务平均每天跑20次,每次10分钟,用EC2的话,t4g.medium的按需价格是$0.02/小时,一次就是$0.003,一天$0.06,一年才$21.9,比物理机的$3000便宜太多了。但缺点是需要自己管理EC2的启动和销毁,还有Webhook的配置,复杂度比自托管Runner高。
不过2024年GitHub已经上线了AI辅助工作流调试功能,这个功能能帮我们自动诊断Runner的问题。有一次我们的自托管Runner突然连不上GitHub,AI辅助调试提示是Runner的证书过期了,我们更新证书后就恢复了,省了很多排查时间。
安全方面还有个重要的点:密钥管理。自托管Runner需要访问GitHub的token,还有内网服务的凭证,这些不能明文存在配置文件里。我们用GitHub的环境密钥管理,把Runner的token、内网数据库的密码都存在生产环境的密钥里,只有生产环境的作业才能访问,而且需要审批。比如部署到生产环境的作业,必须有两个人审批通过才能运行,这样即使Runner被攻击,攻击者拿不到生产环境的密钥。
现在回头看,自托管Runner适合需要访问内网资源的场景,但要做好隔离,不然安全风险很大。Serverless Runner是未来的趋势,成本低,维护简单,适合CI任务波动大的项目。我们现在的策略是,内部系统的CI用自托管Runner(因为要访问内网),开源项目或者CI任务少的用Serverless Runner(因为成本低)。
最后说个数字,我们之前用3台物理机自托管Runner,一年成本$3000,后来换成Serverless Runner(用AWS EC2按需启动),一年成本降到了$200,节省了93%的成本。安全方面,分层隔离后,再也没有出现过Runner被攻击影响内网的情况。这就是我在实际项目里对自托管Runner和Serverless趋势的理解,没有完美的方案,只有适合自己场景的选择。
聊点实在的。去年我接手了一个在线教育平台的重构项目,技术栈是 Nest.js + Vue 的全栈 Monorepo。当时为了赶进度,我直接把 CI 流程从 GitLab CI 搬到了 GitHub Actions。
刚开始确实爽,配置简单。但问题很快来了:每次部署一个微小的文案改动,整个流程都要跑 15分钟。我盯着日志看了半天,发现 node_modules 安装依赖这一步占了快一半时间。
我当时第一反应是加 actions/cache,但诡异的是,缓存经常命中失败。我花了两个通宵去查,最后发现是我们的 package-lock.json 里有个依赖的版本号写的是 latest,导致每次 commit 锁文件都会变,缓存 key 直接就失配了。
我是怎么解决的?
我把那个该死的 latest 锁死到了具体版本号,然后改造了缓存策略。我没有用默认的 hashFiles,而是用了分支名 + 操作系统作为缓存 key 的一部分,并且开启了 zstd 压缩。这一改,构建时间直接从 15 分钟干到了 6 分钟。
说实话,不是所有项目都值得折腾这套:
* 适合上的场景:开源项目、中小团队、全栈或个人项目。GitHub Actions 的免费额度对这类项目来说简直是白嫖,而且生态插件多到你不敢想。
* 没必要硬上的场景:如果你在搞强合规的金融内网系统,或者你们公司已经有非常成熟的 Jenkins 集群,别为了追时髦去迁移。我见过有人为了迁移而迁移,最后搞出一堆自托管 Runner 的维护成本,得不偿失。
最后给想入坑的朋友提个醒:别迷信“一键部署”。很多教程只教你怎么写 yaml,不教你怎么看日志。我建议你们去手动折腾一次 workflow_dispatch 的手动触发传参,还有 needs 的依赖顺序。当你真正理解了“事件驱动”而不是“定时任务”时,才算真正掌握了 Actions 的灵魂。