chundev
日期:2026-05-07 情境:私有 k3s 叢集 + 內部 Docker registry,多 repo 部署
開發者本機 docker build && docker push 到內網 k8s registry,在外部網路(咖啡廳、家裡)會卡在上傳頻寬,500MB–1GB image push 動輒數分鐘。把 GitHub Actions self-hosted runner 跑在 k8s 同一個 LAN 的節點上,build/push 就回到 localhost 速度,開發者只需從任何地方送出 gh workflow run 觸發。關鍵知識:self-hosted runner 用 outbound long-poll 連 GitHub,runner 機器不需要對外開任何 port,完全可以在純內網環境跑。
典型的 k8s 部署流程:
本機 build → push image 到 registry → kubectl apply → rollout restart
當 registry 在內網(例如 <lan-ip>:30500 NodePort),開發者離開公司後,這條路會走外部上行頻寬。Image 通常 500MB–1GB,上行 10–20 Mbps 的家用網路一推就是 5–10 分鐘,而且容易因為 timeout / 斷線 retry。
實務上的副作用:
理想:開發者只負責「按下部署」,重的工作搬到內網跑。
Self-hosted runner 用 outbound HTTPS long-poll 連 GitHub,不是 GitHub 連進來。
Runner 啟動 ──► 對 github.com:443 開長連線(HTTPS)
↓
GitHub 透過這條連線把 job 推給 runner
↓
Runner 在本機跑 job,結果回傳給 GitHub
引用官方文件:
The host machine must be able to make outbound HTTPS connections over port 443.
所以 runner 機器:
這是為什麼 self-hosted runner 在 enterprise 防火牆內、家用 NAT 後、k8s LAN 都能用。
如果你的內網有 outbound 白名單,需要放行:
| 用途 | 域名 |
|---|---|
| Core API | github.com, api.github.com, *.actions.githubusercontent.com |
| Action 下載 | codeload.github.com |
| Artifact / log 上傳 | results-receiver.actions.githubusercontent.com, *.blob.core.windows.net |
| Runner 自動升級 | objects.githubusercontent.com, github-releases.githubusercontent.com |
| 容器拉取 | ghcr.io, *.pkg.github.com |
全部都是 HTTPS over 443。
幾個關鍵選擇與背後思考:
| 範圍 | 何時用 |
|---|---|
| Org-level(推薦) | 你 org 內有 ≥ 2 個 repo 要用同一台 runner |
| Repo-level | 只有一個 repo,且未來幾年也不會擴張 |
Org-level runner 註冊一次,所有 repo 的 workflow 寫 runs-on: self-hosted 就能用,加新 repo 不用再設定 runner。
⚠️ 安全備註:org-level runner 任何人在該 org 任何 repo 觸發 workflow 都能跑到,所以對 fork PR 的處理要小心。GitHub 預設 fork PR 的 workflow 不會跑在 self-hosted runner 上(需要 maintainer 手動 approve),這個預設保留就好,別關掉。
三條路線:
| 方案 | 設定難度 | 適合情境 |
|---|---|---|
| systemd 跑在 k8s node 上 | ⭐ | 已有 k8s 節點、想最快上線;接受 build job 與 k8s workload 共用 node 資源 |
| 獨立 VM(在同 LAN) | ⭐⭐ | 想完全隔離 build 工作不影響 k8s |
| k8s 內 ARC(Actions Runner Controller) | ⭐⭐⭐⭐ | 大規模需要彈性 scaling,build 流量大 |
最常見的選擇是 systemd,因為:
journalctl -u gha-runner.service實際 systemd unit 範例:
[Unit]
Description=GitHub Actions Runner
After=network.target docker.service
Requires=docker.service
[Service]
ExecStart=/opt/actions-runner/run.sh
User=gha-runner
WorkingDirectory=/opt/actions-runner
Restart=always
RestartSec=10
# 限制資源避免擠到 k8s workload
CPUQuota=200%
MemoryMax=4G
[Install]
WantedBy=multi-user.target
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
workflow_dispatch: # escape hatch
inputs:
no-cache:
type: boolean
default: false
推薦:自動觸發 + 手動 escape hatch 並存。
push: main 自動部署 — 真正做到「PR merge = ship」paths-ignore 過濾文檔變更,避免改 README 也觸發 buildworkflow_dispatch 留著當逃生口,可以手動重跑、強制 no-cache如果 N 個 repo 都跑同一條 build → push → apply → rollout 流程,抽到一個 reusable workflow,各 repo 用 5-10 行呼叫即可。
抽象化的 reusable workflow 結構:
# infra-repo/.github/workflows/k3s-deploy.yml
on:
workflow_call:
inputs:
app-name: { required: true, type: string }
project: { required: true, type: string }
namespace: { required: true, type: string }
kustomize-path: { required: true, type: string }
deployments: { required: true, type: string }
no-cache: { required: false, type: boolean, default: false }
concurrency:
group: deploy-${{ inputs.project }}
cancel-in-progress: false
jobs:
deploy:
runs-on: [self-hosted, k3s]
steps:
- uses: actions/checkout@v4
- name: Build & push
run: |
docker build --platform linux/amd64 \
${{ inputs.no-cache && '--no-cache' || '' }} \
-t ${REGISTRY}/${{ inputs.project }}-${{ inputs.app-name }}:latest .
docker push ${REGISTRY}/${{ inputs.project }}-${{ inputs.app-name }}:latest
- name: Apply & rollout
run: |
kubectl apply -k ${{ inputs.kustomize-path }}
for d in ${{ inputs.deployments }}; do
kubectl rollout restart -n ${{ inputs.namespace }} deployment/$d
kubectl rollout status -n ${{ inputs.namespace }} deployment/$d --timeout=300s
done
各 repo 的 caller 變超薄:
# 各 repo 的 .github/workflows/deploy.yml
name: Deploy to k3s
on:
push:
branches: [main]
paths-ignore: ['docs/**', '*.md']
workflow_dispatch:
jobs:
deploy:
uses: my-org/infra-repo/.github/workflows/k3s-deploy.yml@main
with:
app-name: web-app
project: my-app
namespace: my-app-ns
kustomize-path: k8s/overlays/prod
deployments: "web-app worker"
secrets: inherit
10 個 repo 就是各自一份 10 行的 caller,邏輯改動只動 reusable workflow 一個地方。
容易搞混的兩個機制:
| 特性 | Reusable workflow | Composite action |
|---|---|---|
| 引用語法 | uses: org/repo/.github/workflows/x.yml@ref(job 層級) |
uses: org/repo@ref(step 層級) |
| 跨 repo | ✅ | ✅ |
可指定 runs-on |
✅(自己定) | ❌(caller 決定) |
| 可分多個 job | ✅ | ❌(單一 step list) |
| 可定 secrets | ✅ | ❌(需從 caller 傳 input) |
| 適合情境 | 整段獨立流程(build + deploy) | 重複的 step 片段(setup + cache) |
rule of thumb:要包含「整個 job 的執行環境定義」(含 runs-on、concurrency、多 step)就用 reusable workflow;只是把幾個常用 step 包起來就用 composite action。本案部署流程是完整 job + 自訂 runner labels + concurrency 控制,reusable workflow 是正解。
實作時容易踩的坑:
GitHub 的 runner registration token 只有 24 小時有效。但這個 token 只在「第一次註冊」時用,註冊成功後 runner 自己會儲存 long-lived 認證,之後重啟都不需要再拿 token。所以 ansible/automation script 要寫成 idempotent — 偵測 .runner 設定檔已存在就跳過註冊步驟。
把 runner user 加到 docker group 是最簡單的權限解法,但這等於給 runner root 等價權限(任何人能跑 docker run -v /:/host 就能讀寫整個檔案系統)。
緩解:
不要把「部署用的高權限 runner」和「跑公開 OSS 測試的 runner」放同一台。
一台 runner 掛掉 = 所有 repo 部署都 queue 住。
第一階段可以先接受。後續加 backup runner 很簡單:在另外的 node 上裝相同設定(同 labels),GitHub 自動 round-robin 派送。
如果 runner 跟 k8s 共用 node,重的 build 會拖慢 pod。systemd unit 加:
CPUQuota=200% # 最多吃 2 core
MemoryMax=4G # 最多 4GB memory
實測一陣子再決定是否要再隔離。
同一個 project 兩次 push 撞到同時部署會混亂。Reusable workflow 內加:
concurrency:
group: deploy-${{ inputs.project }}
cancel-in-progress: false
cancel-in-progress: false 是關鍵 — 跑到一半的部署不要被新的 push cancel 掉,不然會留下半部署狀態。新 job 排隊等舊的跑完即可。
下面是實際完整跑過一輪後再補回來的,前面的 5 條是設計階段就想到的,這邊是「踩了才知道」。
GitHub Organization 才支援 org-level runner,個人 User account 只能 repo-level。如果 gh api -X POST /orgs/<user>/actions/runners/registration-token 回 404,先確認帳號類型:
gh api /users/<name> --jq '.type'
# User → repo-level only;Organization → 可用 org-level
修法兩條:
新建 private repo 內的 reusable workflow,預設只能被同 repo 內的 workflow 引用。其他 repo 引用會直接看到 This run likely failed because of a workflow file issue,而且沒有任何 job 被建立(最難 debug 的失敗類型)。
修法:
# 看當前設定
gh api /repos/<owner>/<infra-repo>/actions/permissions/access --jq '.access_level'
# 預設 "none",要改:
gh api -X PUT /repos/<owner>/<infra-repo>/actions/permissions/access \
-f access_level=user # User account
gh api -X PUT /repos/<owner>/<infra-repo>/actions/permissions/access \
-f access_level=organization # Org
額外:若 reusable workflow 內部還要 actions/checkout 另一個 private repo(例:caller 是 app repo,infra k8s manifests 在另一 private repo),預設 GITHUB_TOKEN 沒跨 repo 權限。要在 caller repo 設 PAT secret:
gh secret set INFRA_REPO_TOKEN -R <caller-repo> -b "$(gh auth token)"
然後 reusable workflow 的 checkout step 加 token: ${{ secrets.INFRA_REPO_TOKEN }}(caller 用 secrets: inherit 讓 reusable 拿得到)。
pnpm@latest 與 Node 版本相容性陷阱FROM node:20-alpine
RUN corepack enable && corepack prepare pnpm@latest --activate
pnpm@latest 自 v11 起需要 Node 22+,在 Node 20 base image 會 build 失敗,錯誤訊息:
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite
Node.js v20.20.2
修法(兩個都要做):
corepack prepare pnpm@10 --activatepackage.json 加 packageManager 欄位:"packageManager": "pnpm@10.18.0"第二步是關鍵 — corepack 在 runtime(例如 initContainer 跑 pnpm prisma migrate deploy 等場景)會看 package.json.packageManager,沒這個欄位會自動拉 latest,再次踩同坑。Dockerfile 的 corepack prepare ... --activate 只影響 image build 階段。
更通用的教訓:任何用 corepack 自動拉版本的工具,都應該在 package.json pin 死,避免 runtime 行為跟 build 期不一致。
k3s 的 /usr/local/bin/kubectl 不是 upstream kubectl,是 k3s 的 wrapper,預設讀 /etc/rancher/k3s/k3s.yaml(mode 600 root only),無視 $HOME/.kube/config 慣例。
非 root user 跑 kubectl 會看到:
warning: Unable to read /etc/rancher/k3s/k3s.yaml, please start server with
--write-kubeconfig-mode or --write-kubeconfig-group to modify kube config permissions
error: error loading config file ... permission denied
修法(runner 需要 kubectl 時三個一起做才保險):
k3s.yaml 複製到 runner user home(mode 600 owned by runner)Environment=KUBECONFIG=/path/to/runner/.kube/configenv: KUBECONFIG: ...第 2、3 都做的原因:systemd Environment 會傳給 GHA runner process 與其 children,但實測有的工具會清/重設 env,job step 自己再設一次最保險。或乾脆只在 workflow 設,更直白。
這兩個是不同層次的設定:
| 用途 | 設定檔 | 何時需要 |
|---|---|---|
| Runner build & push image | /etc/docker/daemon.json 的 insecure-registries |
runner 跑 docker push <local-registry> 時 |
| Cluster pull image | /etc/rancher/k3s/registries.yaml |
k8s pod 起 image 時 |
兩個都要設 HTTP local registry 才能完整跑通 build → push → pull → run cycle。常見坑:只記得設 k3s pull 那邊(因為先用 ImagePullPolicy 試 pod),docker push 時才看到 http: server gave HTTP response to HTTPS client。
新加的 workflow 還在 feature branch 上時,gh workflow run 會 404:
HTTP 404: workflow deploy.yml not found on the default branch
GitHub 規定 workflow_dispatch 只能觸發 default branch(通常 main)上已存在的 workflow。
開發測試 workaround:
on:
push:
branches: [main, feat/test-deploy] # 暫時加 feat branch
paths-ignore: ['docs/**', '*.md']
workflow_dispatch:
merge 前再把 feat branch 從列表移除。
另一坑:git commit --allow-empty 推到上述列表的 branch 可能不會觸發 workflow(GitHub 把無 file change 視為無路徑匹配,整個 push event 對 path-filter workflow 不算數)。要可靠觸發 push event,push 一個真實 file change。
cancel-in-progress: false 是部署用 concurrency 的標準寫法 — 部署不像 CI 測試,跑到一半被 cancel 掉會留半成品狀態actions/permissions/access 預設 none 是最難 debug 的失敗(無 job、只說 workflow file issue)package.json.packageManager 欄位 pin 死,光在 Dockerfile 寫 corepack prepare X --activate 不夠~/.kube/config — 跑 kubectl 的非 root user 必須設 KUBECONFIG 環境變數workflow_call 介面定義、跨 repo 引用語法concurrency group 用法、避免部署撞車