前言
上篇把 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-upgradehook。
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 的
--timeout120 秒。這兩個是當時實測的設定。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,下一篇會直接遇到。
小結
- Secret 用 lookup 沿用。
helm upgrade不會換掉APP_KEY與 DB 密碼;ConfigMap 改了則要讓 Pod 重建(rollout restart或 checksum annotation)。 - migrate 做成 hook,但 rollback 不會倒回 migration。migration 要向後相容。
- queue 是至少執行一次。job 要能重複執行;
retry_after與 grace period 都要大於--timeout。 - 排程用 CronJob。單次失敗不影響下次、看得到歷史,配
concurrencyPolicy: Forbid。
系列文章:從 docker compose 到 k3s
- 第 1 篇:【k8s】把 docker compose 上的 Laravel 搬上 Kubernetes(上):image、架構與零中斷滾動更新
- 第 2 篇:【k8s】把 docker compose 上的 Laravel 搬上 Kubernetes(下):Helm chart、queue worker 與 CronJob(本篇)
- 第 3 篇:在 ARM VM 上用 k3s 部署同一個 chart(準備中)
No comments yet.