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 的作用域不覆盖 org-a 这个 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 的仓库也能触发 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 jobrunner 主动 pull Gitea
Webhook Server 的 /webhook/gitea什么时候启动一个新 runnerGitea → 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 会被谁用。

第四步:把现象套回去。

  1. org-a/repo-alpha 有人推 main,触发 webhook → Webhook Server 起了一个新 runner(全局作用域)
  2. 30 分钟存活期内,org-b/repo-beta 的开发者推了一个分支
  3. Gitea 匹配到 repo-beta/.gitea/workflows/branch-preview.yml,把 job 入队
  4. 池子里那个全局 runner 正在长轮询,把 job 领走执行
  5. 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: pushworkflow 的触发类型(来自 yml 里的 on: push:),不是 Webhook 发来的事件名。Webhook Server 代码里也有 case "push": 处理 HTTP header X-Gitea-Event: push。两个 push 字面一样,含义完全不同:

出现位置含义
runner 日志 triggered by event: pushGitea 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 记录。