昨天學了 Helm 的基本概念,今天自己動手把 Todo App 的所有 YAML 包成一個 Helm Chart,包含前端、API 和 MySQL,一個指令就能把整套服務裝起來。
今天完成後,Chart 會長這樣:
todo-app/
├── Chart.yaml # Chart 的基本資訊(名稱、版本)
├── values.yaml # 預設的可設定參數
└── templates/ # YAML 模板,用 Go template 語法寫
├── mysql-secret.yaml
├── mysql-statefulset.yaml
├── mysql-service.yaml
├── api-deployment.yaml
├── api-service.yaml
├── api-hpa.yaml
├── frontend-deployment.yaml
├── frontend-service.yaml
├── configmap.yaml
├── secret.yaml
└── ingress.yaml
Helm 的模板使用 Go template 語法,用 {{ }} 插入變數。例如原本寫死的:
metadata:
name: todo-api
spec:
replicas: 2
改成模板後:
metadata:
name: {{ .Release.Name }}-api
spec:
replicas: {{ .Values.api.replicas }}
安裝時 Helm 會把變數換成實際的值,產生最終的 YAML 送進 K8s。
幾個常用的模板變數:
{{ .Release.Name }}:安裝時指定的 Release 名稱,例如 helm install my-todo 的 my-todo
{{ .Values.xxx }}:values.yaml 裡定義的參數{{ .Chart.Name }}:Chart 名稱{{ .Chart.Version }}:Chart 版本今天還會用到幾個語法:
{{- if .Values.xxx }} ... {{- else }} ... {{- end }}:條件判斷,{{- if not .Values.xxx }} 則是條件不成立時才渲染| quote:幫值加上雙引號,避免 true、8000 這類值被 YAML 當成布林或數字required "訊息" .Values.xxx:必填參數,沒給值時直接報錯並顯示訊息所有資源名稱都加上 {{ .Release.Name }} 當前綴,這樣同一個 Chart 裝兩次(例如明天的 dev 和 staging)名稱也不會撞在一起。
今天目標:把 MySQL、todo-api、todo-frontend、HPA、ConfigMap、Secret、Ingress 包成一個 Helm Chart,用 helm install 一次部署整套服務,並練習升級和查看歷史。
/Todo-App 底下建立 helm 資料夾並進入:mkdir helm
cd helm
helm create 建立骨架:helm create todo-app
這個指令會建立一個預設的 Chart,裡面有範例的 Deployment 和 Service。
templates/ 裡的預設檔案清掉,等等換成我們自己的:cd todo-app
rm -rf templates/*
Windows PowerShell 的寫法:
cd todo-app
Remove-Item -Path templates/* -Recurse -Force
接下來 Step2~Step8 都是在
todo-app/資料夾裡編輯檔案。
把 Chart.yaml 的內容改成:
apiVersion: v2
name: todo-app
description: A Helm chart for Todo App
type: application
version: 0.1.0
appVersion: "1.0.0"
version:Chart 本身的版本,改了模板就要往上加appVersion:Chart 裡應用程式的版本,只是標示用把 values.yaml 的內容整個換成:
# MySQL 資料庫
mysql:
image:
repository: mysql
tag: "8.0"
storage: "5Gi"
database: "tododb"
user: "todo" # 不能設成 root,root 帳號由 MySQL 自動建立
password: "" # 安裝時用 --set 傳入
rootPassword: "" # 安裝時用 --set 傳入
resources:
requests:
cpu: "250m"
memory: "512Mi"
limits:
cpu: "500m"
memory: "1Gi"
# Todo API 後端
api:
replicas: 2 # autoscaling.enabled 為 false 時才會用到
image:
repository: yourname/todo-app-api
tag: v1.0.0
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 5
targetCPUUtilizationPercentage: 70
# Todo Frontend 前端
frontend:
replicas: 2
image:
repository: yourname/todo-frontend
tag: v1.0.0
resources:
requests:
cpu: "50m"
memory: "64Mi"
limits:
cpu: "200m"
memory: "128Mi"
# Ingress
ingress:
enabled: true
host: "" # 空白代表用 IP 存取
# 一般設定(對應 Day 10 的 ConfigMap)
config:
appEnv: "development"
logLevel: "info"
兩個密碼都留空,不要把真實密碼寫進 values.yaml(這個檔案會 commit 進 git),安裝時再用 --set 傳入。
api.autoscaling 對應之前的 todo-api-hpa.yaml。enabled 為 true 時,API 的副本數交給 HPA 管理,replicas 就不會被使用。
image 的
repository和tag要跟你實際推到 Docker Hub 的名稱一模一樣,寫錯的話 Pod 會卡在ErrImagePull。不確定的話,可以查之前正常運作的 Deployment 用的是哪個 image:kubectl get deployment todo-api -o jsonpath="{.spec.template.spec.containers[0].image}" kubectl get deployment todo-frontend -o jsonpath="{.spec.template.spec.containers[0].image}"
MySQL 是有狀態的服務,跟 Day 12 一樣用 StatefulSet 部署。
templates/mysql-secret.yaml,存放 MySQL 的密碼:apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-mysql-secret
type: Opaque
stringData:
MYSQL_ROOT_PASSWORD: {{ required "請用 --set mysql.rootPassword=... 傳入 MySQL root 密碼" .Values.mysql.rootPassword | quote }}
MYSQL_PASSWORD: {{ required "請用 --set mysql.password=... 傳入 MySQL 密碼" .Values.mysql.password | quote }}
stringData:可以直接寫原始值,K8s 會自動做 base64 編碼,不用像 Day 11 那樣手動 echo | base64
required:安裝時沒傳入密碼,Helm 會直接報錯並顯示提示訊息templates/mysql-statefulset.yaml:apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ .Release.Name }}-mysql
spec:
serviceName: {{ .Release.Name }}-mysql-service
replicas: 1
selector:
matchLabels:
app: {{ .Release.Name }}-mysql
template:
metadata:
labels:
app: {{ .Release.Name }}-mysql
spec:
containers:
- name: mysql
image: "{{ .Values.mysql.image.repository }}:{{ .Values.mysql.image.tag }}"
ports:
- containerPort: 3306
env:
- name: MYSQL_DATABASE
value: {{ .Values.mysql.database | quote }}
- name: MYSQL_USER
value: {{ .Values.mysql.user | quote }}
envFrom:
- secretRef:
name: {{ .Release.Name }}-mysql-secret # MYSQL_ROOT_PASSWORD、MYSQL_PASSWORD
readinessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "127.0.0.1"]
initialDelaySeconds: 10
periodSeconds: 5
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
resources:
requests:
cpu: {{ .Values.mysql.resources.requests.cpu }}
memory: {{ .Values.mysql.resources.requests.memory }}
limits:
cpu: {{ .Values.mysql.resources.limits.cpu }}
memory: {{ .Values.mysql.resources.limits.memory }}
volumeClaimTemplates:
- metadata:
name: mysql-data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: {{ .Values.mysql.storage }}
MYSQL_DATABASE、MYSQL_USER、MYSQL_PASSWORD 自動建立資料庫和使用者readinessProbe 用 mysqladmin ping 確認 MySQL 真的可以連線了,才讓 Service 把流量導過來volumeClaimTemplates 會為 Pod 建立專屬的 PVC,資料存在這裡,Pod 重啟資料也不會消失templates/mysql-service.yaml:apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-mysql-service
spec:
clusterIP: None # Headless Service,StatefulSet 需要
selector:
app: {{ .Release.Name }}-mysql
ports:
- port: 3306
targetPort: 3306
API 會透過 {{ .Release.Name }}-mysql-service 這個名稱連到 MySQL。
templates/api-deployment.yaml:apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
spec:
{{- if not .Values.api.autoscaling.enabled }}
replicas: {{ .Values.api.replicas }}
{{- end }}
selector:
matchLabels:
app: {{ .Release.Name }}-api
template:
metadata:
labels:
app: {{ .Release.Name }}-api
spec:
containers:
- name: api
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag }}"
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: {{ .Release.Name }}-config
- secretRef:
name: {{ .Release.Name }}-secret
resources:
requests:
cpu: {{ .Values.api.resources.requests.cpu }}
memory: {{ .Values.api.resources.requests.memory }}
limits:
cpu: {{ .Values.api.resources.limits.cpu }}
memory: {{ .Values.api.resources.limits.memory }}
envFrom引用了{{ .Release.Name }}-config和{{ .Release.Name }}-secret,這兩個會在 Step7 建立。少了它們,Pod 會卡在CreateContainerConfigError。
replicas用if not包起來:開啟 HPA 時不寫replicas,副本數完全交給 HPA。如果兩邊都寫,每次helm upgrade都會把副本數重設回values.yaml的值,蓋掉 HPA 剛擴展出來的數量。
templates/api-service.yaml:apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-api-service
spec:
type: ClusterIP
selector:
app: {{ .Release.Name }}-api
ports:
- port: 80
targetPort: 8000
templates/api-hpa.yaml:{{- if .Values.api.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ .Release.Name }}-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ .Release.Name }}-api
minReplicas: {{ .Values.api.autoscaling.minReplicas }}
maxReplicas: {{ .Values.api.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.api.autoscaling.targetCPUUtilizationPercentage }}
{{- end }}
todo-api-hpa.yaml 一樣,只是 scaleTargetRef.name 改成 {{ .Release.Name }}-api,對到這個 Release 的 API Deploymentif:autoscaling.enabled 為 false 時整個 HPA 不會建立resources.requests.cpu」算使用率,所以 API 的 requests 一定要設定templates/frontend-deployment.yaml:apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-frontend
spec:
replicas: {{ .Values.frontend.replicas }}
selector:
matchLabels:
app: {{ .Release.Name }}-frontend
template:
metadata:
labels:
app: {{ .Release.Name }}-frontend
spec:
containers:
- name: frontend
image: "{{ .Values.frontend.image.repository }}:{{ .Values.frontend.image.tag }}"
ports:
- containerPort: 80
env:
- name: API_HOST
value: {{ .Release.Name }}-api-service
- name: API_PORT
value: "80"
resources:
requests:
cpu: {{ .Values.frontend.resources.requests.cpu }}
memory: {{ .Values.frontend.resources.requests.memory }}
limits:
cpu: {{ .Values.frontend.resources.limits.cpu }}
memory: {{ .Values.frontend.resources.limits.memory }}
/api/ 的請求轉給後端,後端位址由環境變數 API_HOST、API_PORT 決定。image 內建的預設值是手動部署時的 Service 名稱 todo-api-service
{{ .Release.Name }}-api-service,所以要用環境變數覆蓋預設值。不加的話,nginx 啟動時找不到 todo-api-service 會直接退出,前端 Pod 會一直 Error
API_HOST 跟 Step5 的 Service 名稱用同一個寫法,不管 Release 叫什麼名字,前端都會連到同一個 Release 裡的後端API_PORT 填的是 Service 的 port 80,不是容器的 8000。K8s 的環境變數值必須是字串,所以要加引號templates/frontend-service.yaml:apiVersion: v1
kind: Service
metadata:
name: {{ .Release.Name }}-frontend-service
spec:
type: ClusterIP
selector:
app: {{ .Release.Name }}-frontend
ports:
- port: 80
targetPort: 80
Ingress 的 / 會導到這個 Service,少了它前端就連不到。
templates/configmap.yaml,內容對應 Day 10 的 ConfigMap:apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Release.Name }}-config
data:
APP_ENV: {{ .Values.config.appEnv | quote }}
LOG_LEVEL: {{ .Values.config.logLevel | quote }}
templates/secret.yaml,內容對應 Day 11 的 Secret:apiVersion: v1
kind: Secret
metadata:
name: {{ .Release.Name }}-secret
type: Opaque
stringData:
DATABASE_URL: "mysql+pymysql://{{ .Values.mysql.user }}:{{ required "請用 --set mysql.password=... 傳入 MySQL 密碼" .Values.mysql.password }}@{{ .Release.Name }}-mysql-service:3306/{{ .Values.mysql.database }}"
Day 11 是手動寫整串 DATABASE_URL,這裡改成由 Chart 自己組出來:帳號、密碼、資料庫名稱從 mysql 的設定拿,主機名稱用 {{ .Release.Name }}-mysql-service。這樣不管 Release 叫什麼名字,API 都會連到同一個 Release 裡的 MySQL,也不用重複輸入密碼。
密碼會直接放進連線字串,建議只用英文和數字,避免
@、:、/這類符號讓連線字串解析錯誤。
建立 templates/ingress.yaml:
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ .Release.Name }}-ingress
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
ingressClassName: nginx
rules:
{{- if .Values.ingress.host }}
- host: {{ .Values.ingress.host | quote }}
http:
{{- else }}
- http:
{{- end }}
paths:
- path: /api(/|$)(.*)
pathType: ImplementationSpecific
backend:
service:
name: {{ .Release.Name }}-api-service
port:
number: 80
- path: /()(.*)
pathType: ImplementationSpecific
backend:
service:
name: {{ .Release.Name }}-frontend-service
port:
number: 80
{{- end }}
if:ingress.enabled 設成 false 時,整個 Ingress 不會建立if:ingress.host 有值時才加上 host,沒有值就用 IP 存取。明天部署多個環境時,就是靠不同的 host 區分前端的 path 寫成
/()(.*)而不是/,是因為rewrite-target: /$2會套用到同一個 Ingress 的所有路徑。前端的規則也要有第二個捕捉群組,$2才會是完整的原始路徑,否則前端的/assets/...這類請求都會被改寫成/。
到這裡 templates/ 裡應該有 11 個檔案,可以對照最上面的目錄結構確認。
helm/):cd ..
接下來的 helm 指令都在這一層執行。如果在
todo-app/裡面執行helm lint ./todo-app,Helm 會去找todo-app/todo-app/Chart.yaml,就會出現找不到檔案的錯誤。
helm lint 檢查模板有沒有語法錯誤:helm lint ./todo-app --set mysql.password=test --set mysql.rootPassword=test
看到 1 chart(s) linted, 0 chart(s) failed 就代表沒問題
helm template 預覽渲染後的 YAML,不會實際安裝:helm template my-todo ./todo-app --set mysql.password=test --set mysql.rootPassword=test
確認輸出裡有這 11 個資源:兩個 Secret、一個 ConfigMap、一個 StatefulSet、兩個 Deployment、三個 Service、一個 HorizontalPodAutoscaler 和 Ingress。也可以找一下 DATABASE_URL,確認主機名稱是 my-todo-mysql-service;再找一下 API_HOST,確認值是 my-todo-api-service。API 的 Deployment 裡不會有 replicas,因為副本數交給 HPA 管理。
如果同一個 Namespace 裡還留著之前用
kubectl apply手動部署的todo-api、todo-frontend、mysql、Ingress 和 HPA,新舊兩個 Ingress 會搶同一組路徑,瀏覽器不一定會連到 Helm 裝的那組。建議安裝前先把舊的部署刪掉,或安裝時加上--set ingress.enabled=false,確認 Helm 裝的服務都正常後再開啟。
--set 傳入:helm install my-todo ./todo-app --set mysql.password=todopass123 --set mysql.rootPassword=rootpass123
helm list
kubectl get pods

MySQL 第一次啟動需要初始化,大約要 30 秒到 1 分鐘。這段時間 API 連不到資料庫,可能會重啟幾次(RESTARTS 大於 0),等 MySQL 變成 1/1 Running 後 API 就會恢復正常。
kubectl get pvc

會看到一個叫 mysql-data-my-todo-mysql-0 的 PVC,狀態是 Bound。
kubectl get hpa
會看到 my-todo-api-hpa,MINPODS 是 2、MAXPODS 是 5。TARGETS 剛建立時可能是 <unknown>,等一兩分鐘 metrics-server 收集到數據後就會顯示實際使用率。如果一直是 <unknown>,確認一下 metrics-server 還在不在:
kubectl get pods -n kube-system | grep metrics-server
PowerShell 把 grep 換成 Select-String。
deploy/<Deployment 名稱> 就不用先查 Pod 名稱,kubectl 會自動挑一個該 Deployment 的 Pod:kubectl exec deploy/my-todo-api -- env | grep -E "APP_ENV|LOG_LEVEL|DATABASE_URL"
PowerShell 沒有 grep,改用:
kubectl exec deploy/my-todo-api -- env | Select-String "APP_ENV|LOG_LEVEL|DATABASE_URL"

DATABASE_URL 應該會是 mysql+pymysql://todo:todopass123@my-todo-mysql-service:3306/tododb。
順便確認前端拿到的後端位址:
kubectl exec deploy/my-todo-frontend -- env | grep API
應該會看到 API_HOST=my-todo-api-service 和 API_PORT=80。
kubectl exec -it my-todo-mysql-0 -- mysql -u todo -ptodopass123 -e "SHOW DATABASES;"
輸出裡有 tododb 就代表成功了。
v1.0.0 多標一個 v2.0.0 推上去,模擬新版本:docker tag yourname/todo-app-api:v1.0.0 yourname/todo-app-api:v2.0.0
docker push yourname/todo-app-api:v2.0.0
yourname/todo-app-api要換成你在values.yaml裡設定的 repository。如果本機已經沒有v1.0.0的 image,先用docker pull拉下來再 tag。
接著升級:
helm upgrade my-todo ./todo-app --reuse-values --set api.image.tag=v2.0.0
helm upgrade預設不會沿用上次用--set傳入的值。如果沒加--reuse-values,這次升級就沒有密碼,會被required擋下來。加上--reuse-values就會保留上次的設定,只改這次指定的部分。
helm history my-todo

每次 install 或 upgrade 都會留下一筆 REVISION,之後可以用來回滾。
helm uninstall my-todo
只會刪除 Helm 裝的資源(包含 HPA),不會動到之前用 kubectl apply 手動部署的東西,也不會刪掉本機的 Chart 檔案。
helm uninstall 不會刪掉它,要另外手動刪除:kubectl delete pvc mysql-data-my-todo-mysql-0
helm list -A
kubectl get pvc
helm list -A 裡沒有 my-todo,kubectl get pvc 裡也沒有 mysql-data-my-todo-mysql-0,就完成了。
如果安裝時有用
-n指定 Namespace,上面的helm uninstall和kubectl delete pvc也要加上同樣的-n,例如-n dev。今天操作過程中如果裝錯想重來,也可以用這一步清除,再從 Step9 重新開始。
今天把 MySQL 跟應用程式包在同一個 Chart 裡,好處是一個 helm install 就能把整套服務開起來,開發和測試環境很方便。
但正式環境通常不會這樣做,資料庫會另外管理:
原因是資料庫和應用程式的生命週期不一樣。API 可能一天升級好幾次,資料庫卻很少動,也不希望跟著應用程式一起被刪掉或重裝。
今天把 Todo App 包成了 Helm Chart:
Chart.yaml、values.yaml、templates/
{{ .Release.Name }}、{{ .Values.xxx }}、| quote
{{- if .Values.xxx }} 讓 Ingress、HPA、host 這類設定可選API_HOST、API 的 DATABASE_URL 都用 {{ .Release.Name }} 組出來,換 Release 名稱也連得上DATABASE_URL 由 Chart 自動組出來replicas,副本數交給 HPArequired 確保密碼一定要在安裝時傳入,不寫進 values.yaml
--reuse-values
明天把這個 Chart 部署到 dev 和 staging 兩個 Namespace,用不同的 values 管理不同環境的設定。