Roy-Try-Catch
← Back to list

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

Roy • Updated 2026-10-16 10:09:49

前言

上篇把 blog 與 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、架構與零中斷滾動更新;下一篇:在 ARM VM 上用 k3s 部署同一個 chart,準備中)

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

chart 與 values:一行 install 整套起來

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

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

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 會被跳過,那幾分鐘的排程就漏掉了;長任務要用 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
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: Never
          containers:
            - name: schedule
              image: app-b:v2
              imagePullPolicy: IfNotPresent
              command: ["php", "artisan", "schedule:run"]

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

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

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

還沒做的事與誠實的限制

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

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

下一步:k3s on ARM VM

這個 chart 之後會拿去當冒煙測試。下一步(我叫它 C3)是另開一台 ARM VM 跑 k3s,部署同一個 chart:Kubernetes API 不對外,Mac 經 SSH tunnel 連進去;對外服務走 Cloudflare Tunnel;等監控滿 7 天之後開始。到時候 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.

請先登入