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 的作用域不覆盖 org-a 这个 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 的仓库也能触发 Runner
6.1 现象
org-a/repo-alpha 在仓库 Settings → Webhooks 配了一条指向 Webhook Server 的钩子。另一个仓库 org-b/repo-beta 从未配过任何 webhook,却也能触发 CI,job 被同一批 ecs-act-runner-* 接走执行。
直觉上这不应该发生——没配 webhook 的仓库为什么也能让 runner 跑起来?
6.2 推理过程
关键是把 Gitea Actions 里两套独立机制拆开看:
| 机制 | 它决定什么 | 通信方式 |
|---|---|---|
| Gitea Actions 的 job 分发 | 谁执行 workflow job | runner 主动 pull Gitea |
Webhook Server 的 /webhook/gitea | 什么时候启动一个新 runner | Gitea → HTTP POST |
这两套东西互不依赖。
第一步:Gitea 分发 job 不走 webhook。 act_runner 和 GitHub 的 actions-runner 一样是 pull 模型——runner 启动后持续向 Gitea 长轮询(FetchTask),有 job 就拉。仓库配不配 webhook 和 job 能不能被分发毫无关系,只要 .gitea/workflows/ 里的触发条件匹配,job 就进队列。
第二步:runner 的作用域决定它能被谁用。 §5.3 讲过 Gitea runner 有仓库级、组织级、全局三种作用域。本方案用 site-admin token 注册,runner 统一是全局作用域——Gitea 实例里任何仓库的 job 都能把它拉走。
第三步:Webhook Server 的真正作用是 “ 给全局池子扩容 “。 它收到 org-a/repo-alpha 的 push 事件 → 起一个 Fargate → 新 runner 注册进全局池 → 30 分钟存活期内,实例里任何仓库排队的 job 都能用它。它完全不关心 runner 会被谁用。
第四步:把现象套回去。
org-a/repo-alpha有人推 main,触发 webhook → Webhook Server 起了一个新 runner(全局作用域)- 30 分钟存活期内,
org-b/repo-beta的开发者推了一个分支 - Gitea 匹配到
repo-beta/.gitea/workflows/branch-preview.yml,把 job 入队 - 池子里那个全局 runner 正在长轮询,把 job 领走执行
repo-beta的 Actions 页面显示:job 由ecs-act-runner-*执行
6.3 另一个容易踩的混淆点
runner 日志的第一行经常是:
1 | ecs-act-runner-1776408295 received task 9046 of job 9256, be triggered by event: push |
这里的 event: push 指 workflow 的触发类型(来自 yml 里的 on: push:),不是 Webhook 发来的事件名。Webhook Server 代码里也有 case "push": 处理 HTTP header X-Gitea-Event: push。两个 push 字面一样,含义完全不同:
| 出现位置 | 含义 |
|---|---|
runner 日志 triggered by event: push | Gitea Actions 工作流的触发类型 |
Webhook Server case "push": | HTTP webhook 的事件名 |
排查时看到都叫 push 容易误判成同一件事,其实分属两个独立系统。
6.4 经验
Gitea Actions 的 job 分发是 pull 模型,不经过 HTTP webhook。仓库能不能跑 CI,取决于池子里有没有作用域匹配、label 匹配的活 runner,和 “ 这个仓库有没有配 webhook” 无关。Webhook 只是 “ 什么时候扩容一个 runner” 的信号。
副作用:全局 runner 意味着 “ 一个仓库的 webhook 启动的 runner,可以被实例里任何仓库蹭用 “。如果希望严格隔离,要么把 runner 降到组织 / 仓库级作用域(换一个对应级别的 registration token 即可),要么在 Webhook Server 层加 allowlist 过滤发起方。
回顾整个方案,下面列出一些可以继续探索的方向。
7. 延伸思考
- 并发控制:当前每次 webhook 都拉起一个容器。如果短时间内大量 push,可以在 Webhook Server 层做去重或限流,避免同时启动过多 Task。
- Runner 缓存:每次启动都是全新容器,依赖全部重新下载。可以考虑挂载 EFS 做 Go module、npm 等缓存,缩短 job 执行时间。
- 自动清理:离线 runner 会在 Gitea 管理后台堆积。可以定期调用 Gitea API 清理过期的 runner 记录。