Share Notes

chundev

View the Project on GitHub latteouka/share-notes

用 GitHub Actions self-hosted runner 把 k8s build/push 搬進內網

日期:2026-05-07 情境:私有 k3s 叢集 + 內部 Docker registry,多 repo 部署


TL;DR

開發者本機 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 在內網可行(核心知識)

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 都能用。

Runner 連線的 endpoint 清單

如果你的內網有 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。


設計決策

幾個關鍵選擇與背後思考:

1. Runner 範圍:org-level vs repo-level

範圍 何時用
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),這個預設保留就好,別關掉。

2. Runner 部署位置

三條路線:

方案 設定難度 適合情境
systemd 跑在 k8s node 上 已有 k8s 節點、想最快上線;接受 build job 與 k8s workload 共用 node 資源
獨立 VM(在同 LAN) ⭐⭐ 想完全隔離 build 工作不影響 k8s
k8s 內 ARC(Actions Runner Controller) ⭐⭐⭐⭐ 大規模需要彈性 scaling,build 流量大

最常見的選擇是 systemd,因為:

實際 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

3. 觸發方式

on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '*.md'
  workflow_dispatch:    # escape hatch
    inputs:
      no-cache:
        type: boolean
        default: false

推薦:自動觸發 + 手動 escape hatch 並存。

4. 多 repo 的 workflow 共用:reusable workflow

如果 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 vs composite action:怎麼選

容易搞混的兩個機制:

特性 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 是正解。


安裝陷阱清單

實作時容易踩的坑:

1. Registration token 24 小時過期

GitHub 的 runner registration token 只有 24 小時有效。但這個 token 只在「第一次註冊」時用,註冊成功後 runner 自己會儲存 long-lived 認證,之後重啟都不需要再拿 token。所以 ansible/automation script 要寫成 idempotent — 偵測 .runner 設定檔已存在就跳過註冊步驟。

2. Runner 的 docker 權限 ≈ root

把 runner user 加到 docker group 是最簡單的權限解法,但這等於給 runner root 等價權限(任何人能跑 docker run -v /:/host 就能讀寫整個檔案系統)。

緩解:

不要把「部署用的高權限 runner」和「跑公開 OSS 測試的 runner」放同一台。

3. Single point of failure

一台 runner 掛掉 = 所有 repo 部署都 queue 住。

第一階段可以先接受。後續加 backup runner 很簡單:在另外的 node 上裝相同設定(同 labels),GitHub 自動 round-robin 派送。

4. Build 擠到 k8s workload

如果 runner 跟 k8s 共用 node,重的 build 會拖慢 pod。systemd unit 加:

CPUQuota=200%   # 最多吃 2 core
MemoryMax=4G    # 最多 4GB memory

實測一陣子再決定是否要再隔離。

5. Concurrency control

同一個 project 兩次 push 撞到同時部署會混亂。Reusable workflow 內加:

concurrency:
  group: deploy-${{ inputs.project }}
  cancel-in-progress: false

cancel-in-progress: false 是關鍵 — 跑到一半的部署不要被新的 push cancel 掉,不然會留下半部署狀態。新 job 排隊等舊的跑完即可。


實戰補充:private repo + k3s 環境的坑(事後加註)

下面是實際完整跑過一輪後再補回來的,前面的 5 條是設計階段就想到的,這邊是「踩了才知道」。

6. User account 沒有 org-level runner

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

修法兩條:

7. Private repo 的 reusable workflow 預設不允許跨 repo 用

新建 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 拿得到)。

8. Dockerfile 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

修法(兩個都要做):

  1. Dockerfile 裡 pin 版本corepack prepare pnpm@10 --activate
  2. package.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 期不一致。

9. k3s 自帶的 kubectl wrapper 預設讀 root-only kubeconfig

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 時三個一起做才保險):

  1. k3s.yaml 複製到 runner user home(mode 600 owned by runner)
  2. systemd unit 加 Environment=KUBECONFIG=/path/to/runner/.kube/config
  3. Workflow job 也加 env: KUBECONFIG: ...

第 2、3 都做的原因:systemd Environment 會傳給 GHA runner process 與其 children,但實測有的工具會清/重設 env,job step 自己再設一次最保險。或乾脆只在 workflow 設,更直白。

10. Docker daemon 的 insecure-registries ≠ k3s containerd 的 registry config

這兩個是不同層次的設定:

用途 設定檔 何時需要
Runner build & push image /etc/docker/daemon.jsoninsecure-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

11. workflow_dispatch 需要 workflow 檔在 default branch

新加的 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。


學到的事

  1. GHA self-hosted runner 是 outbound polling 設計 — 完全可以在純內網跑,不需 public IP / port forwarding,這個常被誤解
  2. Build/push 慢的瓶頸是上行頻寬,不是 build 本身 — 把 build/push 搬進 LAN 比優化 Dockerfile cache 效益大很多
  3. Org-level runner + reusable workflow 是多 repo 部署的標準解 — 改邏輯只動一處,加新 repo 只寫薄薄的 caller
  4. systemd 跑 runner 比 ARC(k8s pod runner)簡單 10 倍 — 沒到大規模就別追先進方案,YAGNI
  5. cancel-in-progress: false 是部署用 concurrency 的標準寫法 — 部署不像 CI 測試,跑到一半被 cancel 掉會留半成品狀態
  6. Reusable workflow 與 composite action 不是替代品 — 前者是「整個 job 的封裝」,後者是「step 片段封裝」,看你想抽什麼層級的東西
  7. Private repo 跨 repo 引用要顯式打開 actions permissionsactions/permissions/access 預設 none 是最難 debug 的失敗(無 job、只說 workflow file issue)
  8. corepack 在 build 期與 runtime 期可能拉不同版本 — 一定要用 package.json.packageManager 欄位 pin 死,光在 Dockerfile 寫 corepack prepare X --activate 不夠
  9. k3s 的 kubectl 是 wrapper、不讀 ~/.kube/config — 跑 kubectl 的非 root user 必須設 KUBECONFIG 環境變數
  10. Docker push 與 k8s pull 是兩個獨立的 registry trust 設定 — daemon.json vs registries.yaml,要兩邊都設

參考資料