在 NAS 上搭一套 CI/CD:EasyTier 组网 + Woodpecker 裸机执行器 | 一方天地

2026-08-26 构建 Vue3, Linux, Python

在 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 很轻,常驻内存不大,我直接用飞牛的 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 pushCI类型检查 + 构建,验证代码没坏
git tag + pushCI + 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.htmlstatic/,避免历史 hash 文件越堆越多。

小结

整条链路串起来是这样:

  1. EasyTier 把云服务器和 NAS 组进同一个虚拟局域网——地基
  2. Gogs 迁到 Gitea,因为 Woodpecker 不支持 Gogs
  3. Woodpecker Server 跑在飞牛 Docker 里,接 Gitea webhook
  4. Woodpecker Agent 跑在 NAS 上的 Ubuntu 虚拟机里,裸机执行,与 NAS 环境隔离
  5. push 触发类型检查 + 构建;tag 额外触发部署
  6. venv 预装依赖,部署脚本推产物到 OSS,缓存策略随手做对

几个坑值得记住:Gogs 与 Woodpecker 不兼容,得用 Gitea;local backend 的 image/bin/sh;YAML 里带 : 的命令要用单引号包住;Ubuntu 26.04 的 PEP 668 限制要用 venv 绕开,而且要在准备执行器时就装好。

下一步是把后端 Java 的 CI/CD 也跑通。前端相对简单——构建产物是静态文件,传上去就完事。后端要处理的东西多得多:Maven 依赖缓存、jar 包传输、服务优雅重启、数据库迁移。等这条链路也通了,整个项目就算真正进入可持续集成、可持续交付的状态了。

工具搭好之后,注意力才能真正回到写代码本身。

本文由一方天地发布 · 查看完整体验