Roy-Try-Catch
← Back to list

【k8s】把 docker compose 上的 Laravel 搬上 Kubernetes(下):Helm chart、queue worker 與 CronJob

Roy • Updated 2026-10-17 13:43:43

前言

上篇把 app-c 與 app-b 兩個 Laravel 服務做成自帶程式碼的 image,在 Mac 上 OrbStack 的單節點 k8s 跑起來,也做到了 app-b 滾動更新 0 / 241 失敗。但那時還是一堆 manifest。這篇把它們打包成 Helm chart,處理設定、機密與 DB 遷移,再把 app-b 的 queue worker 與排程搬上 k8s,最後請另一個 AI agent 照 README 從零重裝驗收。

【系列】從 docker compose 到 k3s 第 2 篇
(上一篇:【k8s】把 docker compose 上的 Laravel 搬上 Kubernetes(上):image、架構與零中斷滾動更新;下一篇:在 VM 上用 k3s 部署同一個 chart,準備中)

關於文中的程式碼。和上篇一樣,練習的 repo 沒有公開,所以文中的 YAML 與指令都是精簡過的示意寫法,不是 repo 原文,每段第一行都會標「示意」;數字則是當時實測。為了好讀,片段多半省略了 envFrom(ConfigMap/Secret)與部分欄位,image tag 寫的是渲染後的樣子。

chart 與 values:一行 install 整套起來

上篇的八個步驟總覽裡,前 7 步是一堆 manifest,第 8 步整理成 chart:app-c 是 charts/app-c,app-b 是 charts/app-b,可調的部分(image tag、副本數)放進 values.yaml。判準最嚴格的一條是:直接 kubectl delete ns app-c,再用一行 helm install 把整套弄回來。實測從空的 namespace 一行 install,app-c 43 秒、app-b 50 秒整套起來。

以 app-b 為例,依上篇的架構圖整理,chart 裡大致有:

  • web 的 Deployment(2 副本,Octane+FrankenPHP)與它的 Service;
  • 3 個 queue worker 的 Deployment,以及每分鐘跑 schedule:run 的 CronJob;
  • 資料層的 MySQL 與 Redis StatefulSet+PVC(Redis 同時放 queue、cache 與 session);
  • ConfigMap、Secret,以及跑 migrate 的 hook Job。

要做到「一行」,chart 裡有三件事得自己處理好:設定改了要能觸發重建、機密在 upgrade 時不能被換掉、DB 遷移要自動跑。

ConfigMap 與 Secret:設定改了怎麼觸發重建

compose 時代一個 .env 全包;到了 k8s 拆成兩份:ConfigMap 放一般設定,Secret 放 APP_KEY、DB 與 Redis 的密碼。

第 5 步的完成判準是「改一個 ConfigMap 的值,Pod 依序重建、新值生效」。難處在於:Pod 只在啟動時讀環境變數,ConfigMap 改了,Deployment 本身沒變,k8s 不會自己重建 Pod。純 manifest 階段,最簡單的做法是改完 ConfigMap 後執行 kubectl -n app-c rollout restart deployment/app-c-web,讓 Pod 依序重建。打包成 Helm chart 之後,常見做法是把設定檔的雜湊值寫進 Pod template 的 annotation:設定一改,雜湊就變,Pod template 跟著變,於是觸發滾動更新。下面是 Helm 官方文件的寫法,repo 實際用哪種方法我的紀錄沒寫:

# 示意(Helm template,精簡過,不是 repo 原文)
spec:
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}

Secret 用 lookup 沿用

APP_KEY 和 DB 密碼如果由 template 每次產生,helm upgrade 就會把它們換掉:APP_KEY 換掉,加密過的資料與既有的 session 都會出問題;DB 密碼換掉,應用程式就連不上已經初始化好的資料庫。

做法是在 Secret 的 template 裡用 Helm 的 lookup 先查叢集裡有沒有這個 Secret:有就沿用舊值,沒有(第一次 install)才產生。下面逐個 key 補預設值,之後新增 key 時,舊 Secret 裡沒有的那個也會被補上:

# 示意(Helm template,精簡過,不是 repo 原文;Redis 密碼同樣寫法)
{{- $old := (lookup "v1" "Secret" .Release.Namespace "app-b-secret").data | default dict }}
apiVersion: v1
kind: Secret
metadata:
  name: app-b-secret
type: Opaque
data:
  APP_KEY: {{ get $old "APP_KEY" | default (printf "base64:%s" (randAlphaNum 32 | b64enc) | b64enc) }}
  DB_PASSWORD: {{ get $old "DB_PASSWORD" | default (randAlphaNum 24 | b64enc) }}

lookup 有三個坑:

  • 新增 key。如果用「整份 Secret 存在就全部沿用」的寫法,之後新加的 key 在舊 Secret 裡不存在,會渲染成空值;上面逐 key default 就是為了這個。
  • helm uninstall 不會刪 PVC。它會刪掉 Secret,但 StatefulSet 由 volumeClaimTemplates 建出的 PVC 留著;重新 install 會產生新的 DB 密碼,MySQL 資料目錄卻是用舊密碼初始化的,結果連不上。kubectl delete ns 會連 PVC 一起刪,所以這次的測法剛好沒遇到。可以在 Secret 加 "helm.sh/resource-policy": keep 避開。
  • 本機渲染查不到叢集。helm template 與預設(client)的 --dry-run 時,lookup 查不到叢集,會走「產生新值」那條路,看到的輸出跟實際 upgrade 不同;--dry-run=server 才會真的查叢集。

另外提醒:Kubernetes Secret 預設只是 base64,不是加密。真實密碼不要寫進 values.yaml 或 commit 進 git,正式環境應搭配 etcd 加密、Sealed Secrets 或 External Secrets 之類的做法。這篇用的全是練習用的隨機值與假值。

DB 遷移做成 hook Job

上篇第 4 步用 Job 跑 migrate;打包成 chart 後改成 hook Job,Helm 會在對應時機自己跑,不用另外記得手動 migrate。我的紀錄裡 app-b 的 hook 是 post-upgrade;首次 install 怎麼建表,我的紀錄沒寫,這裡用 post-install 補上,照抄才不會在一行 install 之後拿到空的 DB:

# 示意(Helm template,精簡過,不是 repo 原文)
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-migrate
  annotations:
    "helm.sh/hook": post-install,post-upgrade
    "helm.sh/hook-delete-policy": before-hook-creation
spec:
  backoffLimit: 0
  template:
    spec:
      restartPolicy: Never
      containers:
        - name: migrate
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          command: ["php", "artisan", "migrate", "--force"]
          envFrom:                      # 要拿得到 DB 連線設定
            - configMapRef:
                name: app-b-config
            - secretRef:
                name: app-b-secret

before-hook-creation 的意思是「下次建立同名 hook 前,先刪掉上一個」,所以叢集裡永遠會留著最近一次的 migrate Job,失敗時可以 kubectl logs 回頭查。另外要知道 hook 資源不歸 release 管,helm uninstall 不會刪掉它,要自己清。想讓成功的 Job 自動消失,可以改成 before-hook-creation,hook-succeeded,失敗的留著除錯;或在 Job 設 ttlSecondsAfterFinished,讓它跑完一段時間後自動清掉。

要注意 post-upgrade 在新 Pod 都換完之後才跑,中間有一段時間是新程式碼配舊 schema,migration 要向後相容。

hook 掛在哪個時機,各有代價,沒有哪個是免費的:

時機中間狀態代價
post-upgrade(本文做法)新程式碼配舊 schema新程式碼要能在 migration 跑完前正常運作;migration 失敗時新版已經在接流量
pre-upgrade舊程式碼配新 schemamigration 要讓舊程式碼還能跑;migration 失敗時 upgrade 直接中止,舊版不受影響

不管選哪個,正式環境比較穩的做法是把破壞性的 schema 變更拆成兩次上線(常見說法是 expand/contract):第一次只「加」欄位或表,新舊程式碼都能跑;確認新版穩定、舊 Pod 全部退場後,下一次上線才「刪」舊欄位。這樣不論 hook 在前在後、要不要 rollback,任何時刻跑著的程式碼都對得上 schema。練習環境用的是假資料,所以這一層我沒有實際驗證。

helm upgrade 換 tag,以及 rollback

打包成 chart 之後,日常操作就剩幾個指令:

# 示意(不是 repo README 原文;release 與 namespace 都用服務名)
helm install app-b charts/app-b -n app-b --create-namespace --wait

# 換版:Mac 本機 build 新 tag,再 upgrade(values.yaml 的 image.tag 用 --set 蓋掉)
docker build -t app-b:v2 .
helm upgrade app-b charts/app-b -n app-b --set image.tag=v2 --wait

helm test app-b -n app-b       # 叢集內跑測試
helm history app-b -n app-b    # 看 revision
helm rollback app-b -n app-b   # 不帶 revision 就回上一版

helm upgrade 之後的滾動更新流程,見上篇的「滾動更新流程」圖:新 Pod 的 readiness 通過才接流量,舊 Pod 才退場。這裡補兩個跟 Helm 有關的點:

  • --wait 逾時不會自己回滾。要手動 helm rollback,或者 upgrade 時加上自動回滾的參數(Helm 3 是 --atomic,Helm 4 改名為 --rollback-on-failure)。在 rollback 之前,因為新 Pod 沒 ready、舊 Pod 不會被砍,服務一直由舊 Pod 撐著。
  • helm rollback 只換回 manifest,不會倒回已經跑過的 migration,rollback 也不會觸發 post-upgrade hook。

queue worker:至少執行一次

web 的零中斷處理好之後,背景程序是另一個問題:worker 被砍掉的時候,正在跑的 job 怎麼辦?app-b 有 3 種 queue(default、high、low),各一個 Deployment,可以分開擴縮與重啟。

Laravel 的 queue 語意是至少執行一次:worker 被砍掉時,job 不會遺失,它在 Redis 裡只是被「保留」,超過連線設定的 retry_after 還沒被確認完成,就會放回佇列,由別的 worker 重跑。所以業務邏輯要設計成可以重複執行,同一個 job 跑兩次不能出事。

這裡有三個時間要對齊:

  • grace period 140 秒,大於 worker 的 --timeout 120 秒。這兩個是當時實測的設定。worker 收到 SIGTERM 會先把手上的 job 做完再退出;k8s 等滿 grace period 才強制砍掉,所以這段時間要夠長,讓正在跑的 job 有機會在 timeout 內跑完。
  • config/queue.php 的 retry_after 要大於 --timeout。這條是補充建議,repo 實際值我的紀錄沒寫。Redis 連線的 retry_after 預設是 90 秒,比 120 小,一個還在跑的 job 會在 90 秒時被另一個 worker 再拿去跑一次。示意值可以設 150。整理起來是:retry_after(150)> --timeout(120),grace period(140)> --timeout(120)。
  • SIGTERM 會讓 job 內的 sleep() 提早返回(worker 註冊了訊號處理,訊號一到,正在睡的 sleep() 就被打斷)。寫 job 時不要假設 sleep() 一定睡滿。

排程:CronJob 而不是常駐 schedule:work

Laravel 排程搬到 k8s,有兩條路:一個常駐 Pod 跑 php artisan schedule:work,或是用 k8s 的 CronJob 每分鐘起一次。我選了 CronJob:

CronJob(每分鐘)常駐 schedule:work
單次失敗不影響下一次Pod 掛掉到重啟之前,所有排程一起停
歷史紀錄kubectl get jobs 看得到(預設保留成功 3 筆、失敗 1 筆)要另外看 log
資源較省,跑完就結束;代價是每分鐘起一個新 Pod、bootstrap 一次 Laravel一直佔著一個 Pod
即時性以分鐘為單位較即時

concurrencyPolicy: Forbid 讓上一次還沒跑完時,不會再疊一個新的上去。要注意 schedule:run 預設在前景依序跑任務,某個任務超過 1 分鐘時,下一次 CronJob 到點時前一個還在跑,Forbid 不會馬上建立新的 Job;前一個結束後,只要還在 startingDeadlineSeconds 期限內,控制器會補建那一次,超過期限才放棄。補建的 schedule:run 是依「執行當下」的時間判斷哪些任務該跑,所以晚幾秒開始不影響;延遲跨過整分鐘,那一分鐘的任務就漏掉了。長任務要用 runInBackground() 或改成派 queue。worker 與 CronJob 的寫法:

# 示意(精簡過,不是 repo 原文;default、high、low 三種 queue 各一個 Deployment,這裡只列 high;省略 resources,實際請比照 web 設定)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: app-b-worker-high
  namespace: app-b
spec:
  replicas: 1
  selector:
    matchLabels:
      app: app-b-worker-high
  template:
    metadata:
      labels:
        app: app-b-worker-high
    spec:
      terminationGracePeriodSeconds: 140   # 要大於 --timeout
      containers:
        - name: worker
          image: app-b:v2
          imagePullPolicy: IfNotPresent
          command: ["php", "artisan", "queue:work", "--queue=high", "--timeout=120"]
---
apiVersion: batch/v1
kind: CronJob
metadata:
  name: app-b-schedule
  namespace: app-b
spec:
  schedule: "* * * * *"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3   # 預設值,明寫出來
  failedJobsHistoryLimit: 1
  startingDeadlineSeconds: 50     # 錯過時間超過 50 秒就放棄這一次,不補跑
  jobTemplate:
    spec:
      backoffLimit: 0             # 預設 6:失敗會重試,同一分鐘的任務可能重複執行
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: schedule
              image: app-b:v2
              imagePullPolicy: IfNotPresent
              command: ["php", "artisan", "schedule:run"]

CronJob 這段 yaml 有兩個欄位是為了「失敗與漏跑」:

  • backoffLimit: 0。Job 預設失敗會重試 6 次,每次重試都是重新跑一次 schedule:run,同一分鐘裡已經跑過的任務會被再跑一次。排程的重試交給下一分鐘就好,所以不讓 Job 自己重試;真的不能重複的任務,再用 withoutOverlapping()(多台機器時加 onOneServer(),需要共用的 cache 當鎖)。
  • startingDeadlineSeconds。控制器重啟或叢集忙碌時,排程可能錯過時間點。設了這個值,太晚的那一次直接放棄,不會事後一口氣補跑;沒設的話,錯過超過 100 次排程時,k8s 會停止建立新的 Job,要手動處理。

這兩個欄位是補充建議,練習時的紀錄沒有寫,沒有做過對照測試。

驗收:請另一個 AI agent 照 README 從零重裝

做完之後,我沒有自己說「好了」就算數,而是請另一個 AI agent 照 README 從零重裝一次。兩個服務都通過。這樣驗的不只是「叢集現在是好的」,而是「照文件做,別人也能做出來」。全程也確認沒有碰正式 VM、沒有用到正式資料與密碼、沒有 push。

測試的部分,app-b 用 helm test 在叢集內跑,34 / 34 通過;app-c 只有 Laravel 預設的 2 個範例測試,沒什麼參考價值,老實寫出來。app-b 對外的依賴全部處理成不會真的打出去:外部服務的連線設定一律留空或設成假值,讓程式走 early-return;APP_ENV 也是練習用的本機設定,不是 production。

還沒做的事與誠實的限制

上篇列過單節點、資料層沒有 HA、沒有 registry 等限制,這裡只補跟 Helm 與背景工作有關的:

  • 測試條件不是正式條件。34 / 34 是在練習用的本機設定(不是 production 的 APP_ENV)、外部依賴全是假值的條件下跑的,沒有驗到真的第三方回應。
  • 練習用的假密碼寫在 manifest 裡。其中一份 manifest 寫死了練習用假值,公開 repo 前要改成由外部注入。
  • 除錯工具別帶進正式環境。Laravel Telescope 這類除錯套件,部署前要確認在正式環境已關閉或加上存取保護;練習環境我已關掉。

從練習到正式環境還差什麼

這兩篇比較適合當成遷移的實作筆記,不是正式環境的部署標準。把上下篇的限制整理成一份清單,要拿去正式環境之前,至少要補這些:

項目練習環境正式環境要補
可用性單節點;web 1~2 副本多節點、至少 2 副本,用 topologySpreadConstraints 或 anti-affinity 把副本分散到不同節點,加 PodDisruptionBudget,避免節點維護時所有副本一起被趕走
擴縮副本數寫死web 與 queue worker 依負載設 HPA;worker 也可以依 queue 長度擴縮
image本機 build,用 tag由 CI build,跑完測試與 image 弱點掃描才推到 registry;部署時用 digest 固定版本,避免同一個 tag 被覆蓋後內容不同
機密Secret 由 chart 產生,練習用假值用 Sealed Secrets 或 External Secrets 等方式從外部注入,不進 repo;用 RBAC 限制誰能讀 Secret,並規劃密碼輪替(lookup 只會沿用舊值,不會輪替)
容器權限以 root 執行runAsNonRoot、allowPrivilegeEscalation: false,可以的話根檔案系統唯讀
網路namespace 之間全通NetworkPolicy 只開必要的連線,例如只有 web 與 worker 能連 DB
DB 遷移post-upgrade hookschema 變更拆成 expand/contract,上線與 rollback 都安全;並用接近正式的資料量實際演練一次 migration 失敗後的復原(從備份還原或手動修正),而不是只停在設計
資料層單副本 StatefulSet,沒有備份正式 DB 建議先留在 k8s 外(託管服務或獨立主機),並有定期備份與還原演練
對外入口ingress-nginx(已退役)改用 Gateway API 或仍在維護的 Ingress controller,並設定真的 TLS 憑證
檔案儲存練習時沒有處理上傳檔案Pod 重建後,容器內寫入的檔案不會保留,多副本之間也不共用;有用 Laravel local/public disk 存上傳或產出檔案的話,改用 S3 相容的物件儲存(app-c 的圖片本來就放在物件儲存),或規劃支援多節點讀寫的共享儲存
驗證單節點、單次測試、無真實流量用接近真實的流量重複測滾動更新,再加上節點故障、部署失敗(image 拉不到、migration 失敗)的演練,確認回滾流程真的走得通
健康檢查readiness 檢查 DB 與 cache依服務決定檢查範圍,並搭配告警,避免 DB 抖動時全站被摘出 Service 卻沒人發現

下一步:在另一台 VM 上跑 k3s

這個 chart 之後會拿去當冒煙測試。下一步是另開一台 VM 跑 k3s,部署同一個 chart:Kubernetes API 不對外,Mac 經 SSH tunnel 連進去;對外服務走 Cloudflare Tunnel。到時候 image 從哪裡來、單節點之外的問題,都要重新面對。

ingress 也要重新選:Kubernetes 官方已經宣布 ingress-nginx 退役,best-effort 維護在 2026 年 3 月結束,之後不再有新版與修正;而 k3s 預設部署的是 Traefik,下一篇會直接遇到。

小結

  1. Secret 用 lookup 沿用。helm upgrade 不會換掉 APP_KEY 與 DB 密碼;ConfigMap 改了則要讓 Pod 重建(rollout restart 或 checksum annotation)。
  2. migrate 做成 hook,但 rollback 不會倒回 migration。migration 要向後相容。
  3. queue 是至少執行一次。job 要能重複執行;retry_after 與 grace period 都要大於 --timeout。
  4. 排程用 CronJob。單次失敗不影響下次、看得到歷史,配 concurrencyPolicy: Forbid。

系列文章:從 docker compose 到 k3s

Comments

No comments yet.

請先登入