从零搭建 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(自动重启服务)
几个关键点先说清楚:
- Runner:跑在 VPS 上的 act_runner 容器,job 容器镜像用
node:22-bookworm,host网络模式(这点很关键,后面坑 #3 会讲)。 - 构建:docker build 多阶段构建。前端 npm 打包,后端 Maven 打包,最后塞进 JRE 运行时镜像。
- 依赖源:公共包直接走 Maven Central / npmjs.org(VPS 在美国,直连飞快);私有制品走 SSH 隧道回 NAS 上的 Nexus。
- 部署:
docker compose up -d自动重启服务。
看起来很清爽对吧?但为了走到这一步,我踩了下面这 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.1 上!172.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.1 和 172.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 个坑一览表
用表格汇总一下,方便对照排查:
| # | 坑 | 根因 | 解决方案 |
|---|---|---|---|
| 1 | Actions 未启用 | Gitea 默认关闭 Actions | app.ini 加 [actions] ENABLED=true |
| 2 | Runner 注册地址 + CONFIG_FILE | 填了 127.0.0.1,且未指定配置文件 | 用域名 gitea.nas,设 CONFIG_FILE 环境变量 |
| 3 | Job 容器网络隔离 | bridge 网络访问不到宿主机 127.0.0.1 | config.yaml 设 container.network: "host" |
| 4 | Submodule URL 内网不可达 | .gitmodules 写了 192.168.1.2 | git config url.insteadOf 重写 |
| 5 | Nexus 端口未映射 | SSH 隧道漏了 8081 | 隧道加 -R 8081:localhost:8081 |
| 6 | Maven HTTP blocker | Maven 3.8+ 禁止 HTTP 仓库 | settings.xml 覆盖 maven-default-http-blocker |
| 7 | pom.xml 硬编码内网 IP | 构建容器访问不了 192.168.1.2 | 构建时 sed 替换成 nexus.nas:8081 |
| 8 | Docker build 容器 DNS | --add-host 指向 172.17.0.1,隧道只在 127.0.0.1 | SSH -R 双绑定 + GatewayPorts clientspecified |
| 9 | glibc 版本不兼容 | node:20-bullseye glibc 2.31 < 2.34 | 升级到 node:22-bookworm(glibc 2.36) |
| 10 | compose 文件路径不可见 | 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 上,绕一圈回去再绕回来,延迟更高。
所以最终策略是分流:
- 公共包(Maven Central、npm registry):VPS 直连官方源,美国机房访问这些源速度拉满。
- 私有制品(公司内部 jar、内部 npm 包):走 SSH 隧道回 NAS 的 Nexus,只有这些才需要跨网络。
这样做还有一个好处:即使隧道偶尔抖动,公共依赖的下载不受影响,构建成功率更高。
洞察四:Job 容器镜像不是随便选的
选 job 镜像的时候,我一开始只想着"够不够新、有没有 npm"。但坑 9 教训了我:如果你的 job 要挂载和调用宿主机的二进制(比如 docker CLI、特定版本的 git),必须考虑 glibc 兼容性。
经验法则:
- 不需要调用宿主机二进制 → 随便选,够新就行。
- 需要调用宿主机二进制 → 基础镜像的 glibc 版本 ≥ 宿主机 glibc 版本,或者至少满足二进制的最低要求。
- 优先选 Debian stable 最新版(目前 Bookworm / Debian 12),避免用 bullseye(Debian 11)这种即将 EOL 的老版本。
写在最后
10 个坑,从配置文件的一个开关,到 glibc 的 ABI 兼容,跨度不可谓不大。每一个坑单独看都不算难,但它们叠加在一条 CI/CD 流水线里,排查起来就需要你同时理解 Docker 网络、SSH 隧道、Maven 机制、glibc 链接——任何一个环节的知识盲区都会让你卡住。
不过话说回来,踩完这 10 个坑之后,流水线是真的稳了。现在 git push 一下,三四分钟后服务就自动更新到最新版本,半夜再也不用爬起来敲 deploy.sh 了。这感觉,值。
如果你也在做类似的事情——跨网络、Gitea Actions、Docker 构建、内网制品仓库——希望这篇笔记能帮你省掉一两个深夜的排查时间。坑都在这了,你不必再踩一遍。