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,常驻监听。这有两个硬伤:

  1. 闲置浪费——大多数时候没有 job,机器白白跑着
  2. 运维负担——机器挂了要手动恢复,升级也麻烦

目标很明确:有 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
2
3
4
5
6
7
8
9
10
11
12
13
switch event {
case "workflow_job":
// Gitea ≥1.23:job 进入队列时触发
shouldLaunch = payload.Action == "queued"

case "create":
// 旧版 Gitea 兜底:推送 v* tag 时触发
shouldLaunch = payload.RefType == "tag" && payload.Ref[0] == 'v'
}

if shouldLaunch {
aws.ECSRunTask(taskDef, cluster, subnets, sg)
}

收到事件后调用 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
2
3
4
5
6
7
8
9
10
11
12
FROM public.ecr.aws/docker/library/golang:1.24-bookworm

RUN apt-get update && apt-get install -y \
nodejs npm git curl unzip ca-certificates

# AWS CLI
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/aws.zip \
&& unzip -q /tmp/aws.zip -d /tmp && /tmp/aws/install

COPY act_runner /usr/local/bin/act_runner
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

entrypoint.sh 是容器启动时执行的核心脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 每次用时间戳生成唯一名称,避免并发冲突
UNIQUE_NAME="ecs-act-runner-$(date +%s)"

# 向 Gitea 注册
act_runner register \
--no-interactive \
--instance "${GITEA_INSTANCE_URL}" \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
--name "${UNIQUE_NAME}" \
--labels "ubuntu-latest:host" \
--config /etc/act_runner/config.yaml

# 最多运行 30 分钟,之后自动退出
timeout 1800s act_runner daemon --config /etc/act_runner/config.yaml

3.3 Token 管理

注册 token 存储在 AWS Secrets Manager,不硬编码在代码或环境变量里。

这里用 Terraform(一种基础设施即代码工具)声明 ECS 任务定义。下面这段的意思是:容器启动时,ECS 自动从 Secrets Manager 里取出 token,注入到容器的环境变量 GITEA_RUNNER_REGISTRATION_TOKEN 中:

1
2
3
4
5
6
7
# 这是 Terraform 的 HCL 语法,用来描述 ECS task definition 中的 secrets 配置
secrets = [{
# 注入到容器内的环境变量名
name = "GITEA_RUNNER_REGISTRATION_TOKEN"
# Secrets Manager 中存储 token 的资源 ARN(由 Terraform 自动管理)
valueFrom = aws_secretsmanager_secret.runner_reg_token.arn
}]

如果不用 Terraform,等价的操作是在 AWS 控制台的 ECS 任务定义页面,手动添加一条 “ 从 Secrets Manager 注入环境变量 “ 的配置。token 方便随时轮换,无需改代码。

3.4 为什么用 Task 而不是 Service

ECS 有两种运行模式:

ServiceTask(单次)
用途长期运行的进程一次性任务
挂了会怎样自动重启不重启
典型例子Web server、APICI 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
2
container:
docker_host: "-" # 不使用 Docker,直接在容器内执行

配置就绪后,实际跑起来还是会碰到问题。下面记录一次排查过程。

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 修复

  1. 在 Gitea Site Administration → Actions → Runners 页面获取全局 registration token

  2. 更新 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
  3. 推送一个 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 记录。