前言
上篇把 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 | 舊程式碼配新 schema | migration 要讓舊程式碼還能跑;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-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 到點時前一個還在跑,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 hook | schema 變更拆成 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,下一篇會直接遇到。
小結
- 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 篇:在 VM 上用 k3s 部署同一個 chart(準備中)
No comments yet.