ECS Fargate 搭建 Gitea CI Runner
自建 Gitea 需要 CI/CD 能力。在 EC2 上 24 小时跑 act_runner 既浪费又难维护。本文记录如何用 ECS Fargate 实现按需启动的 Serverless runner——有 job 时拉起容器,跑完即销毁,零闲置成本。
1. 背景与目标
Gitea 从 1.19 起支持 Actions,语法与 GitHub Actions 兼容,迁移成本低。问题在于 runner 怎么跑。
最直接的方案:一台 EC2 装 act_runner,常驻监听。这有两个硬伤:
- 闲置浪费——大多数时候没有 job,机器白白跑着
- 运维负担——机器挂了要手动恢复,升级也麻烦
目标很明确:有 job 时才启动 runner,跑完自动销毁,零闲置成本。最终方案是 ECS Fargate 按需启动的临时容器。
2. 整体架构
从 git push 到 CI 执行完毕,整个链路如下:
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
A["开发者 git push"] --> B["Gitea 触发 webhook"]
B --> C["Webhook Server 验证签名"]
C --> D["调用 ECS RunTask"]
D --> E["Fargate 拉起 act-runner 容器"]
E --> F["从 Secrets Manager 读取 token"]
F --> G["act_runner register 注册到 Gitea"]
G --> H["执行 CI job"]
H --> I["30 分钟后容器自动退出"]
classDef trigger fill:#3B82F6,stroke:#2563EB,color:#fff
classDef process fill:#10B981,stroke:#059669,color:#fff
classDef finish fill:#F59E0B,stroke:#D97706,color:#fff
class A,B trigger
class C,D,E,F,G,H process
class I finish没有 job 时,集群里零容器运行。整个过程无需人工干预。
3. 核心组件
3.1 Webhook Server
触发入口是一个 Go HTTP server,监听 Gitea 的 webhook 事件:
1 | switch event { |
收到事件后调用 aws ecs run-task,立即拉起一个 Fargate 容器。安全上用 HMAC-SHA256 验证 X-Gitea-Signature,防止伪造请求。
3.2 Runner 容器
Dockerfile 基于 golang:1.24-bookworm,预装 Node.js、AWS CLI、git 等 CI 常用工具:
1 | FROM public.ecr.aws/docker/library/golang:1.24-bookworm |
entrypoint.sh 是容器启动时执行的核心脚本:
1 | # 每次用时间戳生成唯一名称,避免并发冲突 |
3.3 Token 管理
注册 token 存储在 AWS Secrets Manager,不硬编码在代码或环境变量里。
这里用 Terraform(一种基础设施即代码工具)声明 ECS 任务定义。下面这段的意思是:容器启动时,ECS 自动从 Secrets Manager 里取出 token,注入到容器的环境变量 GITEA_RUNNER_REGISTRATION_TOKEN 中:
1 | # 这是 Terraform 的 HCL 语法,用来描述 ECS task definition 中的 secrets 配置 |
如果不用 Terraform,等价的操作是在 AWS 控制台的 ECS 任务定义页面,手动添加一条 “ 从 Secrets Manager 注入环境变量 “ 的配置。token 方便随时轮换,无需改代码。
3.4 为什么用 Task 而不是 Service
ECS 有两种运行模式:
| Service | Task(单次) | |
|---|---|---|
| 用途 | 长期运行的进程 | 一次性任务 |
| 挂了会怎样 | 自动重启 | 不重启 |
| 典型例子 | Web server、API | CI runner、数据迁移 |
Runner 天然是一次性任务——跑完一个 job 就退出。用 Task 而非 Service,没有 job 时零容器运行、零费用。
组件实现完成,但有一个容易让人困惑的配置细节需要单独说明。
4. ubuntu-latest 标签解析
注册时用的标签:
1 | --labels "ubuntu-latest:host" |
这里有两个关键点。
ubuntu-latest 是名字,不是操作系统。容器实际运行的是 Debian(golang:1.24-bookworm 基础镜像)。之所以命名为 ubuntu-latest,是为了让 workflow 里的标准写法直接匹配:
1 | runs-on: ubuntu-latest # 匹配 runner 注册时的标签 |
团队不需要修改任何 workflow 文件。
:host 是执行模式。act_runner 支持两种模式:
:docker——在嵌套容器里执行 job,需要 Docker-in-Docker:host——直接在当前容器里执行 job
ECS Fargate 不支持 Docker-in-Docker,所以选 :host。同时在 config.yaml 里禁用 Docker:
1 | container: |
配置就绪后,实际跑起来还是会碰到问题。下面记录一次排查过程。
5. 踩坑:job 一直等待中
5.1 现象
在仓库触发 CI,job 一直卡在 “ 等待中 “,始终没有 runner 接取。
5.2 排查过程
第一步,确认标签匹配。workflow 写的是 runs-on: ubuntu-latest,runner 注册的标签也是 ubuntu-latest,没问题。
第二步,确认 runner 状态。Gitea 全局管理后台 → Actions → Runners 里有大量 ecs-act-runner-*。多数离线(历史记录),少数在线。
第三步,查看仓库级 runner。进入仓库 → Settings → Actions → Runners,显示 “ 无可用的 Runner”。问题出在这里。
5.3 根因
Gitea 的 runner 有三个作用域:
- 仓库级——只服务该仓库
- 组织级——服务该 org 下所有仓库
- 全局——服务所有仓库和 org
之前注册 runner 用的 token 来自某个特定仓库或 org。runner 的作用域不覆盖 ailab 这个 org。全局管理页面能看到这些 runner(管理员可见所有作用域),但仓库无法使用它们。
5.4 修复
在 Gitea Site Administration → Actions → Runners 页面获取全局 registration token
更新 AWS Secrets Manager 里的 token 值。如果用 Terraform 管理基础设施,只需修改变量值然后执行
apply,Terraform 会自动更新 Secrets Manager;如果不用 Terraform,也可以直接在 AWS 控制台修改 Secret 的值:1
2# Terraform 变量文件中,把 token 改为全局 token
runner_registration_token = "新的全局token"1
2# 执行 Terraform,自动将新 token 同步到 AWS Secrets Manager
cd terraform/server && terraform apply推送一个 tag 触发新的 CI,让新容器用全局 token 注册
新容器启动后注册为全局作用域,仓库的 job 立刻被接取。
5.5 经验
Gitea 全局管理页面的 runner 列表包含所有作用域的 runner。看到 runner 在线不代表它能服务你的仓库。排查 job 等待问题时,先去目标仓库的 Settings → Actions → Runners 确认可用 runner,那才是真实的可用范围。
回顾整个方案,下面列出一些可以继续探索的方向。
6. 延伸思考
- 并发控制:当前每次 webhook 都拉起一个容器。如果短时间内大量 push,可以在 Webhook Server 层做去重或限流,避免同时启动过多 Task。
- Runner 缓存:每次启动都是全新容器,依赖全部重新下载。可以考虑挂载 EFS 做 Go module、npm 等缓存,缩短 job 执行时间。
- 自动清理:离线 runner 会在 Gitea 管理后台堆积。可以定期调用 Gitea API 清理过期的 runner 记录。