動機:兩台機器的兩難
家裡有兩台機器:一台 NAS,24 小時開機、省電,但效能普通;一台 Server,效能好,但耗電,不想讓它長開。後端 blog 服務用 Docker 部署,理想狀態是丟給 NAS 顧,但 docker build 這種 monorepo 打包,在 NAS 上直接跑一次大概要半小時。
最初的想法
與其把 build 丟給耗電的 Server,不如在 Proxmox 上開一台輕量的 LXC 容器(CT),專門拿來當 self-hosted GitHub Actions runner。CT 本身幾乎不耗資源,只有真正 build 的時候才會吃 CPU,build 完 idle 下來成本很低。
整體架構收斂成這樣:
Runner 負責重活(build image),NAS 只負責把 image load 進來、docker compose up -d 換掉正在跑的 container。分工很單純,但真的動手做,才發現每一層都有自己的怪癖,底下就是實際踩過、一路修過來的紀錄。
一個先天優勢:全部都在內網
這個架構有個關鍵前提:self-hosted runner(PVE CT)跟 NAS 都在自家內網裡,部署時 SSH 連線走的是區域網路(例如 192.168.x.x),完全不用把 SSH 對外開放到公網。這也是為什麼後面敢用密碼式 SSH 而不是堅持上 key——就算密碼強度不是最高等級,攻擊面也只限於同一個內網,不會有網路上的暴力破解機器人天天來敲門。如果哪天 runner 或 NAS 需要對外,這條路的風險評估要整個重新來一次。
第一坑:CT 裡什麼都沒有
CT 是用 Proxmox VE Helper-Scripts 社群腳本建的,純粹是一個 GitHub Actions runner,沒有 Docker。裝的時候又撞上一個 Proxmox LXC 特有的地雷:即使裝好 Docker,daemon 也可能起不來,因為 LXC 預設不給 container 用 Docker 需要的 kernel 功能,要在 Proxmox 宿主機(不是 CT 裡)先開兩個 feature:
pct set <CTID> --features nesting=1,keyctl=1
pct restart <CTID>裝 Docker 本身選了官方 apt 來源,而不是 get.docker.com 這種 convenience script——原因是後者沒辦法只裝需要的套件。我只需要 docker build/docker save,用不到 docker-buildx-plugin、docker-compose-plugin,所以手動指定只裝三個核心套件:docker-ce、docker-ce-cli、containerd.io,能省則省。
後來還是把 buildx 裝回去了
故事後面會提到:雖然單純 build 不需要 buildx plugin,但管理 build cache(docker builder prune)這件事需要它。這是後話,先賣個關子。
第二坑:Synology 的權限系統比想像中龜毛
一開始想法很單純:開一個低權限帳號,只加進 docker 群組,專門給 GitHub Actions 用,避免拿高權限帳號的密碼冒險。結果一試才發現,Synology 的 SSH daemon 寫死只允許 administrators 群組的帳號登入,非管理員帳號就算密碼正確也會被拒絕連線。
這代表「最小權限」這條路在 Synology 上根本走不通——只要這個帳號能 SSH,它就等於管理員權限,跟有沒有加 docker 群組完全沒關係,只能接受「這個帳號密碼外洩 = 整台 NAS 失守」的風險,把密碼保管好、只用在這個自動化上。
就算帳號進了 administrators 群組,還要確認 docker.sock 的擁有群組是不是 docker:
ls -l /var/run/docker.sock
# srw-rw---- 1 root docker 0 ... /var/run/docker.sock如果不是,sudo chown root:docker /var/run/docker.sock 補上——這步只改「群組」不動「擁有者」,完全不影響原本用 sudo 登入的帳號,純粹是多開一條路讓非 sudo 帳號也能直接下 docker 指令。
第三坑:密碼式 SSH 自動化
不想在 NAS 上放 SSH key(覺得多一把 key 不好管理),所以整條部署路徑都得靠密碼過——這個選擇能成立,前提就是前面提到的「全部都在內網」,密碼不會暴露在公網環境下被試探。第一版直接用 sshpass,能動,但後來發現一個更乾淨的做法:不用裝任何套件。
OpenSSH 8.4 之後內建了 SSH_ASKPASS / SSH_ASKPASS_REQUIRE=force 機制,可以讓 ssh/scp 從一個小 helper script 讀密碼:
- name: Prepare non-interactive SSH password helper
env:
SSH_PASS: ${{ secrets.NAS_PASSWORD }}
run: |
ASKPASS=$(mktemp)
cat > "$ASKPASS" << 'EOF'
#!/bin/sh
echo "$SSH_PASS"
EOF
chmod +x "$ASKPASS"
echo "SSH_ASKPASS=$ASKPASS" >> "$GITHUB_ENV"
echo "SSH_ASKPASS_REQUIRE=force" >> "$GITHUB_ENV"ssh/scp 本來就是系統內建的東西,這個做法完全不用額外裝套件,也不會讓密碼出現在 process 參數列表裡。
密碼順利驗證過關之後,還有兩個跟 Synology SSH 相關的小怪癖排隊等著:
scp 新舊協定不相容。 傳檔案時一直回報 remote mkdir "..." : No such file or directory,但那個目錄明明存在,手動 cd 進去也沒問題。原因是新版 OpenSSH 的 scp 預設改用 SFTP 協定,但 Synology 內建的 SSH server 版本比較舊,兩邊在路徑解析(realpath)的實作有落差,導致新版 client 誤判目錄不存在。解法是強制 scp 用回舊版協定:scp -O -P $PORT ...,-O 是 OpenSSH 9.0 加的參數,專門處理這種新舊 server 相容性問題。
非互動式 SSH 找不到 docker。 ssh host 'docker ...' 這種直接丟指令執行的模式會報 docker: command not found,但同一個帳號用互動式登入卻可以正常用。原因是 Synology 的 docker 指令裝在 /usr/local/bin/docker,但非互動式 SSH 根本不會讀取任何 profile/PATH 設定,解法是遠端指令開頭手動補 PATH:
ssh host '
export PATH=$PATH:/usr/local/bin &&
docker load -i image.tar &&
docker compose up -d --no-build
'第四坑:磁碟空間反覆爆掉
這段是踩最久的一段,而且不是修一次就好,是連環爆——原因說穿了很簡單:CT 的磁碟空間本來就很有限,我不想因為 build 不夠用就一直亂開空間去填,那樣只是把問題往後拖,遲早又會爆。所以每次爆掉,我都逼自己先搞懂「東西到底跑去哪了」,而不是直接加大容量了事。
第一次爆是最單純的:CT 一開始只給了 4GB,monorepo 隨便建置一次就塞滿,這種程度只能先 pct resize <CTID> rootfs +20G 解燃眉之急,沒什麼好省的,容量給太少本來就不合理。
但把容量加大之後,空間還是一直被吃光,這才是真正的問題所在。追下去發現是建置快取沒有被好好管理:一開始每次 pnpm install 都要重新下載套件,加上 BuildKit 自己的 layer 快取無限往上疊,兩邊一起把磁碟榨乾。解法分兩層:
先用 cache mount 讓 pnpm store 跨 build 保留,lockfile 沒變的話這層幾乎秒過,不用每次重新下載:
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile但光加 cache 沒有配套的清理策略,反而是找爆點的開始——第一版圖方便直接用 docker system prune -af --volumes 清理,結果連剛加的 pnpm cache mount 一起洗掉,等於白做工,每次還是要重新下載。後來改成分開處理,各司其職:docker image prune -f(不加 -a,避免連 base image 都清掉逼你重拉)清掉沒用的 image,docker builder prune --max-used-space 3GB 專門限制 build cache 總量,讓它自動 LRU 淘汰、不用擔心無限長大,也不用手動介入。
這裡就是前面賣的關子:docker builder prune 背後其實就是 docker buildx prune,管理快取這件事沒有它不行,所以雖然單純 build 不需要,最後還是把 buildx plugin 裝回去了。
一路修下來的心得是:容量不夠先加大是合理的止血,但真正該解決的是「東西為什麼一直在長大」,把快取策略搞對,磁碟需求才會穩定下來,不會變成一個要一直手動盯著、動不動就要加大的無底洞。
第五坑:turbo prune 優化建置範圍
磁碟問題穩定下來之後,回頭看 Dockerfile,發現還有一塊浪費:原本每次都把整個 monorepo 的 packages/* 複製進去裝依賴,包括 packages/ui、packages/shared-types 這些前端專用、server 完全用不到的東西,白白多裝一堆用不到的套件,也拖累前面提到的磁碟壓力。
Turborepo 剛好有 turbo prune 專門解這個問題:
FROM node:24-alpine AS base
RUN npm install -g corepack && corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY . .
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm dlx turbo prune @sao-blog/server --docker
FROM node:24-alpine AS installer
WORKDIR /app
COPY --from=base /app/out/json/ .
COPY --from=base /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
COPY --from=base /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfileturbo prune <package> --docker 會依照依賴圖算出「這個 app 真正需要的 package 子集」跟對應的 lockfile,只裝真正用得到的東西。順便把 Dockerfile 搬到 apps/server/Dockerfile,build context 留在 repo root(docker build -f apps/server/Dockerfile .),符合 Turborepo 官方建議的慣例,之後其他 app 要各自 dockerize 也不會互相干擾。
第六坑:Node 26 拿掉了內建的 corepack
裝完 turbo prune 沒多久,Dockerfile 用了新一點的 Node 版本後,corepack enable 突然報 command not found。查了才知道:Node.js 從 25.0.0 開始不再內建 Corepack,之前版本是預裝的,這版直接拿掉。
RUN npm install -g corepack && corepack enable && corepack prepare pnpm@latest --activate先用 npm 把 corepack 裝回來就解決了。順便學到一課:Node 的偶數大版號要等發布半年後才轉正式 LTS,發布當下貿然跳最新版,很容易撞上這種「新版拿掉東西」的驚喜。
建置時間到底差多少?
踩了這麼多坑,最後回頭看效果如何:
從最早在 NAS 上直接 docker build 要等半小時,到現在 self-hosted runner 穩定跑完只要 1 分鐘出頭,提升了大約 25 倍。
自架 CI/CD 這件事,難的從來不是寫 workflow yaml,而是每一層基礎設施(Proxmox、Synology、Docker、Node、Turborepo)各自的怪癖疊在一起,任何一層版本或設定跟你預期不一樣,就會冒出一個新坑。慢慢查、一次修一層,最後拼起來就是一條穩定、不用手動介入的部署管線。
最終成果
現在的流程是:push 到 main → self-hosted runner 用 turbo prune 只裝需要的依賴、BuildKit cache mount 加速建置 → docker save 打包 → 密碼式 SSH(免裝套件、走內網)傳到 NAS → NAS docker load + compose up -d 換掉正在跑的 container,全程不用手動碰任何一台機器,也完全不用把任何服務暴露到公網。
從一開始「NAS build 太慢」的單純抱怨,到後來變成一趟橫跨 Proxmox、Synology、Docker、Node.js、Turborepo 五種技術堆疊怪癖的踩坑之旅,算是這次意外收穫最多的部分。