从零搭建 Gitea Actions CI/CD:一个跨网络部署的完整实践

事情是这样的:我有一台放在家里局域网的 NAS,上面跑着 Gitea(监听 30000 端口),所有代码仓库都在那。然后有一台美国的 RackNerd VPS,是实际部署服务的地方。很长一段时间,我的"部署流水线"是一个名叫 deploy.sh 的脚本——手动 SSH 上去,git pull,mvn 打包,docker build,docker compose up。

这套流程能用,但每次部署都要人肉操作,半夜改完 bug 还得爬起来敲命令,实在不是长久之计。于是决定上 Gitea Actions,搭一条真正自动化的 CI/CD 流水线。

本以为"配个 runner、写个 workflow 文件"就完事了,结果陆陆续续踩了 10 个坑。这篇文章就是这 10 个坑的完整记录——如果你也在做跨网络的 Gitea Actions CI/CD,希望它能帮你少走点弯路。

最终架构

先看最终跑通的架构,大概长这样:

push to main
   │
   ▼
Gitea Actions 触发(NAS 上的 Gitea)
   │
   ▼
VPS Runner 接收 job(act_runner 容器)
   │
   ├── git clone(submodule 用 insteadOf 重写 URL)
   ├── npm install(公共包走 npmjs.org 直连)
   ├── Maven 打包(公共包走 Maven Central,私有制品走 SSH 隧道回 NAS Nexus)
   ├── docker build(多阶段:npm + Maven + JRE)
   └── docker compose up(自动重启服务)

几个关键点先说清楚:

看起来很清爽对吧?但为了走到这一步,我踩了下面这 10 个坑。

踩过的 10 个坑

按时间顺序来。每个坑都是真实踩过、真实排查、真实解决的,不是编的(编不出这么离谱的)。

坑 1:Gitea Actions 压根没启用

第一个坑最基础,但也最容易忽略。Gitea 从某个版本开始支持 Actions,但默认是关闭的。我在 Gitea Web 界面翻遍了仓库设置,愣是没看到 Actions 标签页,还以为是自己版本太低。

其实只需要在 app.ini 里加两行:

[actions]
ENABLED = true

改完重启 Gitea,仓库设置里就出现 Actions 标签页了。这个坑纯粹是文档没读仔细——但话说回来,谁会在装 Gitea 的时候就把所有配置项看一遍呢?

坑 2:Runner 注册地址写错 + CONFIG_FILE 没设

Runner 要向 Gitea 注册自己。注册的时候要填一个 Gitea 实例地址。我一开始想当然填了 http://127.0.0.1:30000——因为 Gitea 就跑在 NAS 本地嘛。

但问题是:runner 不在 NAS 上,在 VPS 上。对 VPS 来说,127.0.0.1 是它自己,根本不是 NAS。正确的地址应该是 NAS 的可达地址。

然后还有第二个坑中坑:act_runner 的配置文件 config.yaml,必须通过 CONFIG_FILE 环境变量指定路径,否则它会用内置的默认配置——而那个默认配置,大概率不适合你的场景。

正确的 docker-compose 写法:

services:
  runner:
    image: gitea/act_runner:latest
    environment:
      - CONFIG_FILE=/config/config.yaml
      - GITEA_INSTANCE_URL=https://gitea.nas   # 用域名,不要用 127.0.0.1
      - GITEA_RUNNER_REGISTRATION_TOKEN=xxx
    volumes:
      - ./config.yaml:/config/config.yaml
      - /var/run/docker.sock:/var/run/docker.sock

这里 gitea.nas 是我在 VPS 的 hosts 里给 NAS 公网域名/隧道地址起的别名。关键是:地址要填 runner 真正能访问到 Gitea 的那个地址。

坑 3:Job 容器的网络隔离

这个坑让我卡了最久。act_runner 执行 job 时,会启动一个 job 容器(就是我指定的 node:22-bookworm)。问题来了:这个 job 容器默认用的是 Docker bridge 网络,它访问不了宿主机的 127.0.0.1

我的 SSH 隧道把 Nexus、Gitea 这些内网服务映射到了 VPS 的 127.0.0.1。但 job 容器里的 127.0.0.1 是它自己,不是 VPS 宿主机。于是 job 里 curl http://127.0.0.1:8081 直接连接拒绝。

解决方案:在 config.yaml 里把 job 容器的网络模式设成 host

# config.yaml(act_runner 配置)
container:
  network: "host"

这样 job 容器就和宿主机共享网络栈,127.0.0.1 就是 VPS 本身,隧道端口直接可用。

host 网络模式在 Linux 上才有意义(macOS/Windows 的 Docker Desktop 不支持)。好在我们的 runner 跑在 Linux VPS 上,没问题。

坑 4:Submodule URL 指向内网 IP

我的项目用 git submodule 引了另一个仓库,.gitmodules 里是这样写的:

[submodule "common-lib"]
  path = common-lib
  url = http://192.168.1.2:30000/myorg/common-lib.git

这个 192.168.1.2:30000 是 NAS 的内网地址。在局域网里 clone 没问题,但现在 VPS 在公网,它根本访问不到 192.168.1.2

.gitmodules 文件不好——内网开发还要用这个地址。正确的做法是在 VPS 上用 git config --global url.insteadOf 做重写:

# 在 VPS 上执行(可以写进 runner 的初始化脚本)
git config --global url."https://gitea.nas/".insteadOf "http://192.168.1.2:30000/"

这样 git 在 fetch submodule 时,会自动把内网地址替换成 VPS 可达的地址。代码库里一个字都不用改。

坑 5:SSH 隧道忘了映射 Nexus 端口

为了打通 VPS 和 NAS 内网,我用了一条 SSH 反向隧道,把 NAS 上的几个服务端口映射到 VPS:

# 初始版本:只映射了 3 个端口
ssh -R 30000:localhost:30000 \   # Gitea
    -R 8022:localhost:8022 \     # 其他服务
    -R 9000:localhost:9000 \     # MinIO
    user@nas ...

结果构建跑到一半,Maven 下载私有依赖时报 Connection refused。一看——Nexus 跑在 8081 端口,但我压根没在隧道里映射它

这个坑没有任何技术含量,纯粹是漏配了。加上 -R 8081:localhost:8081 就好了。但它提醒我:搭隧道的时候要把所有需要回内网的服务列清楚,别漏。

坑 6:Maven 3.8+ 的 HTTP Blocker

Nexus 隧道打通了,Maven 下载还是报错,但这次错误信息不一样了:

[ERROR] Failed to execute goal ...:
Blocked mirror for repositories: ...
maven-default-http-blocker

原来 Maven 3.8 之后默认阻止所有 HTTP(非 HTTPS)仓库,这是出于安全考虑的硬编码规则。而我的 Nexus 走隧道,用的是 http:// 而不是 https://,直接被挡了。

解决方案:在 settings.xml 里显式覆盖 maven-default-http-blocker,允许 HTTP:

<settings>
  <mirrors>
    <!-- 覆盖 Maven 3.8+ 的默认 HTTP blocker -->
    <mirror>
      <id>maven-default-http-blocker</id>
      <name>Allow HTTP for internal Nexus</name>
      <url>http://nexus.nas:8081/repository/maven-public/</url>
      <mirrorOf>dummy</mirrorOf>  <!-- mirrorOf 不能匹配真实仓库,这里用占位 -->
      <blocked>false</blocked>
    </mirror>
  </mirrors>
</settings>

关键是 <blocked>false</blocked> 这个标签——用同样的 id 覆盖掉默认的那个 blocker,把它放开。

这是 Maven 官方的"安全默认",但对内网 HTTP 服务来说很烦。官方建议给 Nexus 配 HTTPS 证书,但在隧道场景下 HTTP 其实是安全的(流量走 SSH 加密),所以直接放开 blocker 更实际。

坑 7:pom.xml 里硬编码了内网 IP

前面 Maven 能连上 Nexus 了,但构建还是失败——因为 pom.xml 里把仓库地址写死了:

<properties>
  <pkg.repo>192.168.1.2:8081</pkg.repo>
</properties>

这个 IP 在 Docker 构建容器里当然不可达。我不想改 pom.xml(影响内网开发),于是在 CI 构建用 sed 动态替换

# 在 deploy.yml 的构建步骤里
- name: Fix internal repo address
  run: |
    sed -i 's|192.168.1.2:8081|nexus.nas:8081|g' pom.xml

这里 nexus.nas 同样是一个域名别名,通过 --add-host 注入到构建容器里(下一个坑会讲为什么需要 --add-host)。

sed 替换虽然不优雅,但在 CI 场景下是最务实的办法——把内网地址当成一个"环境相关变量"来处理,而不是写死在代码里。

坑 8:Docker build 内部容器的 DNS 解析(最难的一个)

这个坑是所有坑里最绕的,卡了我大半天。

背景:docker build 的每个构建阶段(FROM node、FROM maven、FROM jre)其实各自跑在一个独立的临时容器里。这些临时容器的网络环境和 job 容器、宿主机都不一样。

我在 docker build 里用 --add-host 注入 DNS:

docker build \
  --add-host=nexus.nas:host-gateway \
  -t my-app:latest .

host-gateway 会被解析成 Docker 网桥的网关地址,也就是 172.17.0.1(docker0 的地址)。所以构建容器里 nexus.nas 会指向 172.17.0.1:8081

但问题来了:我的 SSH 隧道只监听在 127.0.0.1172.17.0.1 根本没有服务在监听。

# SSH 隧道默认行为
ssh -R 8081:localhost:8081 user@vps
# 这条命令在 VPS 上的 127.0.0.1:8081 监听
# 但 Docker 容器访问的是 172.17.0.1:8081 —— 无人应答

第一反应是改 --add-host 指向 127.0.0.1,但不行——构建容器里的 127.0.0.1 是它自己。这是个死结:容器的 127.0.0.1 ≠ 宿主机的 127.0.0.1

最终解决方案是:让 SSH 隧道同时绑定到 127.0.0.1172.17.0.1 两个地址。SSH 的 -R 参数其实可以指定绑定地址:

# 关键:GatewayPorts clientspecified(在 VPS 的 sshd_config 里)
# 然后隧道可以指定绑定到 172.17.0.1
ssh -R 127.0.0.1:8081:localhost:8081 \
    -R 172.17.0.1:8081:localhost:8081 \
    user@vps

VPS 的 /etc/ssh/sshd_config 需要加:

GatewayPorts clientspecified

默认 GatewayPorts no 会强制把 -R 的绑定地址改成 127.0.0.1,即使你显式写了别的 IP 也没用。clientspecified 表示"客户端说绑哪个 IP 就绑哪个"。

这样:Docker 构建容器通过 nexus.nas → 172.17.0.1:8081 访问,命中 SSH 隧道,转发回 NAS 的 Nexus。整条链路终于通了。

坑 9:glibc 版本不兼容(node:20 跑不了 docker 命令)

前面所有网络问题解决后,构建终于能拉依赖了。但接着报了一个诡异的错误:

/usr/local/bin/docker: /lib/x86_64-linux-gnu/libc.so.6:
  version `GLIBC_2.34' not found (required by /usr/local/bin/docker)

原因:我的 job 容器镜像用了 node:20-bullseye,基于 Debian 11,glibc 版本是 2.31。而宿主机上的 docker 二进制(挂载进来的)需要 glibc 2.34+

job 里需要调用 docker 命令来做 build 和 compose up,所以我把宿主机的 /usr/bin/docker 挂载进了容器。但这个二进制是在宿主机(更新版本的 Linux)上编译的,低版本 glibc 跑不了高版本编译的二进制——经典的 ABI 兼容问题。

解决方案:升级 job 容器镜像:

# 从 node:20-bullseye (Debian 11, glibc 2.31)
# 升级到
# node:22-bookworm  (Debian 12, glibc 2.36)

# config.yaml
container:
  image: node:22-bookworm

Debian 12(Bookworm)的 glibc 是 2.36,满足 GLIBC_2.34 的要求,docker 命令能正常跑了。

选 job 镜像的时候不要只看"有没有 node"。如果你的 job 需要调用宿主机的二进制(docker、git 等),一定要确认 glibc 版本够用。Debian 11 太旧了,现在新项目直接上 Bookworm 起步。

坑 10:docker-compose.yml 路径在 job 容器里不可见

最后一个坑。构建完镜像要部署,我想当然地在 workflow 里写了:

- name: Deploy
  run: docker compose -f /root/my-platform/docker-compose.yml up -d

结果报 No such file or directory

因为 job 容器看不到宿主机的文件系统(除非显式挂载)。/root/my-platform/docker-compose.yml 这个路径在 job 容器里根本不存在。

当然可以挂载,但更干净的做法是:在 workflow 里内联生成 compose 文件,不依赖宿主机路径:

- name: Deploy
  run: |
    cat <<'EOF' > /tmp/docker-compose.yml
    services:
      my-app:
        image: my-app:latest
        ports:
          - "8080:8080"
        restart: unless-stopped
    EOF
    docker compose -f /tmp/docker-compose.yml up -d

这样 compose 文件跟着 workflow 走,版本可控,也不依赖宿主机上提前放好的文件。部署逻辑完全自包含。

10 个坑一览表

用表格汇总一下,方便对照排查:

#根因解决方案
1Actions 未启用Gitea 默认关闭 Actionsapp.ini 加 [actions] ENABLED=true
2Runner 注册地址 + CONFIG_FILE填了 127.0.0.1,且未指定配置文件用域名 gitea.nas,设 CONFIG_FILE 环境变量
3Job 容器网络隔离bridge 网络访问不到宿主机 127.0.0.1config.yaml 设 container.network: "host"
4Submodule URL 内网不可达.gitmodules 写了 192.168.1.2git config url.insteadOf 重写
5Nexus 端口未映射SSH 隧道漏了 8081隧道加 -R 8081:localhost:8081
6Maven HTTP blockerMaven 3.8+ 禁止 HTTP 仓库settings.xml 覆盖 maven-default-http-blocker
7pom.xml 硬编码内网 IP构建容器访问不了 192.168.1.2构建时 sed 替换成 nexus.nas:8081
8Docker build 容器 DNS--add-host 指向 172.17.0.1,隧道只在 127.0.0.1SSH -R 双绑定 + GatewayPorts clientspecified
9glibc 版本不兼容node:20-bullseye glibc 2.31 < 2.34升级到 node:22-bookworm(glibc 2.36)
10compose 文件路径不可见job 容器看不到宿主机 /root/workflow 内联生成 compose 文件

核心洞察

踩完这 10 个坑,回过头看,有几个认知层面的收获值得单独拎出来讲。

洞察一:SSH -R 可以指定绑定 IP

这是整件事最关键的"技术发现"。大多数人(包括踩坑之前的我)以为 SSH 反向隧道 -R 只能绑 127.0.0.1。但其实它可以指定任意绑定地址

# 绑到 loopback(默认)
ssh -R 127.0.0.1:8081:localhost:8081 vps

# 绑到 docker 网桥网关 —— Docker 容器可达!
ssh -R 172.17.0.1:8081:localhost:8081 vps

# 两条一起用,loopback 和 docker 都能访问
ssh -R 127.0.0.1:8081:localhost:8081 \
    -R 172.17.0.1:8081:localhost:8081 vps

前提是 VPS 的 sshd_config 里设了 GatewayPorts clientspecified,否则服务端会强制把绑定地址改回 loopback。

这个技巧让 Docker 容器能通过 --add-host=xxx:host-gateway 直接访问到隧道里的服务,是打通"容器网络 ↔ 内网服务"的关键一环。

洞察二:跨网络 CI/CD 的核心矛盾

所有这些坑,归根结底都在解决一个核心矛盾:

构建容器运行在隔离的容器网络中,但它需要访问的服务(Nexus、Gitea、内部仓库)都在另一个私有网络里。这中间隔了好几层网络地址转换。

从 job 容器的视角看,要访问 NAS 上的 Nexus,数据包要经过:job 容器 → docker0 网桥 → VPS 宿主机网络栈 → SSH 隧道 → NAS 内网 → Nexus。这链路上任何一环的地址映射不对,就会断。

解决思路就是:用隧道打通网络层,用 DNS 映射(insteadOf / add-host / sed)打通应用层。把内网地址统一映射成一组"VPS 本地可达的域名",让所有组件都用这些域名,不感知真实的网络拓扑。

洞察三:公共源走直连,私有源才走隧道

一开始我想把所有依赖都通过 Nexus 中转(Nexus 做公共仓库的代理 + 私有仓库的 host)。但 VPS 在美国,公共包直连 Maven Central 和 npmjs.org 反而更快——Nexus 在国内 NAS 上,绕一圈回去再绕回来,延迟更高。

所以最终策略是分流

这样做还有一个好处:即使隧道偶尔抖动,公共依赖的下载不受影响,构建成功率更高。

洞察四:Job 容器镜像不是随便选的

选 job 镜像的时候,我一开始只想着"够不够新、有没有 npm"。但坑 9 教训了我:如果你的 job 要挂载和调用宿主机的二进制(比如 docker CLI、特定版本的 git),必须考虑 glibc 兼容性

经验法则:

写在最后

10 个坑,从配置文件的一个开关,到 glibc 的 ABI 兼容,跨度不可谓不大。每一个坑单独看都不算难,但它们叠加在一条 CI/CD 流水线里,排查起来就需要你同时理解 Docker 网络、SSH 隧道、Maven 机制、glibc 链接——任何一个环节的知识盲区都会让你卡住。

不过话说回来,踩完这 10 个坑之后,流水线是真的稳了。现在 git push 一下,三四分钟后服务就自动更新到最新版本,半夜再也不用爬起来敲 deploy.sh 了。这感觉,值。

如果你也在做类似的事情——跨网络、Gitea Actions、Docker 构建、内网制品仓库——希望这篇笔记能帮你省掉一两个深夜的排查时间。坑都在这了,你不必再踩一遍。

← 返回首页