在 NAS 上搭一套 CI/CD:EasyTier 组网 + Woodpecker 裸机执行器 | 一方天地
在 NAS 上搭一套 CI/CD:EasyTier 组网 + Woodpecker 裸机执行器
起因:生产服务器带不动
我的云服务器配置很低。跑一个 Nginx、一个 Java 后端已经接近满载,再让它去做 npm ci + 打包,内存直接告急——前端构建是典型的吃内存操作,vite build 峰值轻松上 GB。
所以「在生产环境里跑 CI/CD」这条路一开始就是堵的。构建这件事必须挪到别的地方去做,云服务器只负责接收产物。
问题是挪到哪儿。我手上有一台飞牛 NAS,性能远好过那台云服务器,但它在家里的内网,云服务器在公网,两者互相看不见。
前提:EasyTier 把两端拉进同一个局域网
这一步是整套方案的地基。
之前折腾 EasyTier 内网穿透的时候,我把云服务器和家里的 NAS 组进了同一个虚拟局域网。组网之后,两台机器可以用内网 IP 直接互访,就像插在同一台交换机上——云服务器能访问 NAS 上的服务,NAS 也能访问云服务器。
这条链路一旦通了,CI/CD 的部署拓扑就自由了:代码托管、构建执行、产物分发可以各自放在最合适的机器上,而不必挤在同一台。
说得直白些:没有 EasyTier 这一层,后面所有事情都不成立。整套自动化部署的可能性,是内网穿透给的。

代码托管:从 Gogs 换到 Gitea
在装 Woodpecker 之前,还有一步是我没预料到的。
我自建的私有化 Git 服务原本是 Gogs,用了挺久,轻量、省资源,一直没什么可挑的。但 Woodpecker 装好之后,两者死活连不上——Gogs 不在 Woodpecker 支持的代码托管平台列表里,webhook 递不过去,OAuth 授权也走不通。
这不是配置问题,是兼容性问题,没有绕过去的余地。于是我把 Git 服务迁到了 Gitea。
Gitea 本身就是从 Gogs fork 出来的,数据结构和使用习惯几乎一致,迁移成本比想象的低。而 Woodpecker 对 Gitea 是一等公民级别的支持——填个地址、配好 OAuth 应用,webhook 就自动挂上了。
教训:选 CI 工具之前,先确认它认不认你的代码托管平台。反过来也一样。这两个的组合关系应该在动手之前就查清楚,而不是装完才发现连不上。
Woodpecker Server 放在飞牛 Docker 里
Woodpecker 是个轻量的 CI/CD 引擎,架构上分成两部分:
- Server — 提供 Web UI、接收 Git webhook、编排流水线
- Agent — 真正执行构建命令的那个
Server 很轻,常驻内存不大,我直接用飞牛的 Docker 面板起了一个容器,挂上自建 Gitea 的 webhook。这部分没什么坑。
Agent 该放哪:一个隔离性的决定
Server 起来之后,下一步自然是部署 Agent。最省事的做法当然是在飞牛里再起一个容器。
但我犹豫了。Agent 是真正干活的那个进程——它要拉代码、装依赖、跑构建脚本、往外传文件,行为比 Server 重得多,也脏得多。NAS 上还跑着相册、影音、文件同步这些我天天在用的服务,万一 Agent 把环境搞乱了、把磁盘写满了,受影响的是整台 NAS。
所以我换了个思路:在 NAS 上开一台虚拟机,Ubuntu 26.04 Server,专门当 CI/CD 执行器。
这台虚拟机除了跑 Agent 什么都不干。它爆了就爆了,重装一遍十分钟的事,不会牵连 NAS 上任何一个正在用的服务。资源隔离、故障隔离、环境隔离,一次性解决。
第一个坑:image 字段填什么
在虚拟机里装 Agent 的时候,我做了一个当时觉得理所当然、后来发现有连带后果的选择:不用 Docker,直接装在裸机上。
理由是不想套娃——NAS 里已经有一层虚拟化了,虚拟机里再套一层 Docker,磁盘和网络都要多绕一跳,调试起来也麻烦。Woodpecker 的 Agent 支持 local backend,直接在宿主机 shell 里执行命令,正合我意。
然后写 .woodpecker.yml 的时候就撞墙了。
Woodpecker 的每个 step 都要求有 image 字段。这个字段不能省,省了直接报错。 但我用的是 local backend,压根没有容器,填什么镜像名都没意义。
我先试着填 node:26,结果 Agent 报错说找不到镜像——local backend 不会去拉镜像,但它显然对这个字段做了某种校验。试了几个变体都不行,直到我填了这个:
image: /bin/sh
通了。
回头理解,local backend 下这个字段被当作「用哪个 shell 来执行 commands」而不是「用哪个容器镜像」。填 /bin/sh 就是告诉它:这些命令直接在 sh 里跑。语义完全不同于 Docker backend,但文档里这一点写得相当隐晦。
这是我在这套流程上花时间最多的一个坑,纯靠试出来的。
第二个坑:YAML 里的冒号
流水线跑通之后,我想在 CD 阶段打印一下当前的 tag 和 commit,方便排查:
commands:
- echo "Tag: $CI_COMMIT_TAG"
解析失败。
原因是 YAML 的语法规则:一个未加引号的纯文本标量里,不允许出现「冒号 + 空格」。 因为 : 正是 YAML 用来分隔键和值的符号。解析器读到 echo "Tag: $CI_COMMIT_TAG" 的时候,会把 echo "Tag 当成键、把后面当成值,于是整行的结构就崩了。
注意这里的双引号救不了你——那对引号在 YAML 眼里只是普通字符,标量是从 echo 就开始的。
解法是把整条命令用单引号包起来,让 YAML 把它当成一个完整的字符串:
commands:
- 'echo "Tag: $CI_COMMIT_TAG"'
凡是命令里带 : 的都要这么处理。这个坑很小,但报错信息指向性很差,容易卡住。
第三个坑:Ubuntu 26.04 不让你直接 pip install
我的部署脚本 deploy_oss.py 依赖阿里云的 oss2 库,负责把构建产物推到 OSS。
第一版我图省事,在流水线里加了一行 pip install oss2。两个问题立刻暴露:
一是慢。 每次 CD 都重新装一遍依赖,白等几十秒,而这个依赖从头到尾没变过。
二是装不上。 Ubuntu 26.04 遵循 PEP 668,把系统 Python 标记为「外部管理」,直接 pip 装东西会被拦下来:
error: externally-managed-environment
× This environment is externally managed
这是发行版有意为之的保护——防止 pip 装的包和 apt 装的包互相打架,搞坏系统工具链。硬用 --break-system-packages 绕过去当然可以,但那个参数名本身就是警告。
正确做法是用虚拟环境。而且这件事应该在准备执行器的时候做一次,不该放进流水线:
# 在虚拟机上一次性准备好
python3 -m venv /opt/woodpecker/venv
/opt/woodpecker/venv/bin/pip install oss2
然后流水线里直接用这个 venv 的解释器,绕开所有激活步骤:
- '/opt/woodpecker/venv/bin/python deploy_oss.py'
依赖变成了执行器的一部分,而不是每次构建的负担。CD 阶段少了几十秒的等待,也不再受网络波动影响。

最终的流水线:push 跑 CI,tag 跑 CI + CD
坑填完之后,策略就清晰了:
when:
- event: [push, tag]
steps:
build:
image: /bin/sh
commands:
- node -v
- npm ci --prefer-offline
- npm run type-check
- npm run build:cdn
deploy:
image: /bin/sh
commands:
- 'echo "Tag: $CI_COMMIT_TAG"'
- '/opt/woodpecker/venv/bin/python deploy_oss.py'
when:
- event: tag
关键在 deploy 步骤自带的 when: event: tag。顶层的 when 放行 push 和 tag 两种事件,而 deploy 这一步额外收窄到只在 tag 事件下执行。于是:
| 动作 | 触发 | 效果 |
|---|---|---|
git push | CI | 类型检查 + 构建,验证代码没坏 |
git tag + push | CI + CD | 构建通过后自动发布到 OSS |
日常提交随便推,每一次都过一遍类型检查和构建,坏了立刻知道。要发版的时候打个 tag,产物自动上线。发布这件事被压缩成了一条 git tag 命令。
产物分发:顺手把缓存策略做对
deploy_oss.py 除了上传,还顺便解决了一个前端老问题——缓存。
思路是按文件类型区别对待:
if file == 'index.html':
headers = {'Cache-Control': 'no-cache, no-store, must-revalidate'}
else:
headers = {'Cache-Control': 'max-age=31536000, immutable'}
index.html 完全不缓存,static/ 下的 JS 和 CSS 缓存一年且标记 immutable。
这套组合能成立,是因为 Vite 给静态资源的文件名都带内容 hash(index-CVjGbPGi.js)。内容一变,文件名就变,URL 就变——所以老文件缓存一年也无所谓,反正不会再被请求。而 index.html 的名字是固定的,它必须每次都回源,才能把新的 hash 文件名带给浏览器。
上传之前脚本还会先清掉 bucket 里旧的 index.html 和 static/,避免历史 hash 文件越堆越多。
小结
整条链路串起来是这样:
- EasyTier 把云服务器和 NAS 组进同一个虚拟局域网——地基
- Gogs 迁到 Gitea,因为 Woodpecker 不支持 Gogs
- Woodpecker Server 跑在飞牛 Docker 里,接 Gitea webhook
- Woodpecker Agent 跑在 NAS 上的 Ubuntu 虚拟机里,裸机执行,与 NAS 环境隔离
- push 触发类型检查 + 构建;tag 额外触发部署
- venv 预装依赖,部署脚本推产物到 OSS,缓存策略随手做对
几个坑值得记住:Gogs 与 Woodpecker 不兼容,得用 Gitea;local backend 的 image 填 /bin/sh;YAML 里带 : 的命令要用单引号包住;Ubuntu 26.04 的 PEP 668 限制要用 venv 绕开,而且要在准备执行器时就装好。
下一步是把后端 Java 的 CI/CD 也跑通。前端相对简单——构建产物是静态文件,传上去就完事。后端要处理的东西多得多:Maven 依赖缓存、jar 包传输、服务优雅重启、数据库迁移。等这条链路也通了,整个项目就算真正进入可持续集成、可持续交付的状态了。
工具搭好之后,注意力才能真正回到写代码本身。