検索

記事と日記を検索…

在家用 NAS 上搞 CI/CD:從 Docker 建置慢到自架 Runner 的踩雷全記錄

2026-07-18 に記す・小暑

この記事はまだ日本語に翻訳されていません。原文を表示しています。

他の言語: 繁體中文

動機:兩台機器的兩難

家裡有兩台機器:一台 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 下來成本很低。

整體架構收斂成這樣:

docker build + save scp 傳 tar + compose docker load + compose up git push GitHub Actions Self-hosted RunnerPVE CT Synology NAS

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 是用 GitHubProxmox VE Helper-Scripts 社群腳本建的,純粹是一個 GitHub Actions runner,沒有 Docker。裝的時候又撞上一個 Proxmox LXC 特有的地雷:即使裝好 Docker,daemon 也可能起不來,因為 LXC 預設不給 container 用 Docker 需要的 kernel 功能,要在 Proxmox 宿主機(不是 CT 裡)先開兩個 feature:

Bash
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:

Bash
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 讀密碼:

YAML
- 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:

Bash
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 沒變的話這層幾乎秒過,不用每次重新下載:

Dockerfile
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 專門解這個問題:

Dockerfile
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-lockfile

turbo 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,之前版本是預裝的,這版直接拿掉。

Dockerfile
RUN npm install -g corepack && corepack enable && corepack prepare pnpm@latest --activate

先用 npm 把 corepack 裝回來就解決了。順便學到一課:Node 的偶數大版號要等發布半年後才轉正式 LTS,發布當下貿然跳最新版,很容易撞上這種「新版拿掉東西」的驚喜。

建置時間到底差多少?

踩了這麼多坑,最後回頭看效果如何:

低いほど良い
NAS 直接建置(優化前)30 分鐘×24
Runner 剛架好、還沒優化4.5 分鐘×3.6
turbo prune + cache mount 到位後最良1.25 分鐘

從最早在 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 五種技術堆疊怪癖的踩坑之旅,算是這次意外收穫最多的部分。

丙午年 小暑

唯一 書

コメント欄を読み込み中...