Kubernetes cho Frontend Developer · Phần 8 — ConfigMap, Secret và health check probe
Tách cấu hình khỏi image bằng ConfigMap và Secret, ba cách đưa config vào container, vì sao đổi ConfigMap mà Pod không thấy, rồi readiness, liveness, startup probe và cạm bẫy liveness quá gắt gây CrashLoopBackOff.
Bạn có một image frontend:v1.4.2 đã được QA duyệt ở staging. Lên production, nó phải gọi API khác, bật feature flag khác, gửi lỗi về Sentry project khác. Nếu những giá trị đó nằm trong image, bạn phải build một image khác cho production, và cái lên production không còn là cái QA đã test. Bài này giải quyết nửa đầu của vấn đề bằng ConfigMap và Secret: cùng một image, cấu hình tách ra ngoài. Nửa sau là probe: K8s không tự biết container của bạn khoẻ hay treo, bạn phải khai báo cách kiểm tra, và khai sai thì Pod bị giết oan hoặc nhận traffic khi chưa sẵn sàng. Đọc xong bạn biết ba cách đưa config vào container, vì sao đổi ConfigMap mà Pod không đổi, và phân biệt được readiness với liveness đủ để không tự tạo ra CrashLoopBackOff.
Nguyên tắc: tách cấu hình khỏi image
Cùng một image phải chạy được ở dev, staging, production, chỉ khác cấu hình. Đây là nguyên tắc “config trong môi trường” của 12-factor app, và nó có hai lý do rất thực tế:
- Cái test là cái deploy. Image đã qua QA ở staging chính là image lên production, chỉ đổi biến. Build lại là tạo ra một artifact mới chưa ai kiểm.
- Đổi config không cần build. Đổi API URL, bật feature flag, đổi mức log: sửa ConfigMap rồi restart Pod, mất một phút thay vì chạy lại cả pipeline.
K8s cho hai tài nguyên: ConfigMap cho dữ liệu không nhạy cảm, Secret cho dữ liệu nhạy cảm. Cả hai đều là tập key-value, khác nhau ở cách K8s đối xử (Secret được base64, có thể mã hoá at rest, RBAC tách riêng) chứ không khác ở cách bạn dùng.
Ghi chú: Với SPA, có một cái bẫy lớn hơn nằm ở tầng build: Vite đóng băng
VITE_*vào bundle lúcnpm run build, nên ConfigMap mount vào Pod không đổi được gì trong file JS. Phần 10 dành riêng cho bài toán đó. Bài này lo phần K8s: config vào tới container thế nào.
ConfigMap: dữ liệu không nhạy cảm
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend-config
namespace: production
data:
API_BASE_URL: 'https://api.congty.com'
FEATURE_NEW_CHECKOUT: 'true'
SENTRY_ENVIRONMENT: 'production'
# Có thể chứa cả một file
nginx.conf: |
server {
listen 80;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
}
Vài điều cần biết khi viết:
- Mọi value là chuỗi.
FEATURE_NEW_CHECKOUT: "true"phải có nháy; viếttruetrần thì YAML hiểu là boolean và apiserver từ chối vì ConfigMap chỉ nhận string. Cùng bẫy vớiversion: 1.10ở Phần 3. - Một key có thể là cả file. Dấu
|giữ nguyên xuống dòng, nênnginx.confở trên là file hoàn chỉnh, sau này mount thành/etc/nginx/conf.d/default.conf. - Giới hạn 1 MiB cho toàn bộ ConfigMap. Đủ cho config, không phải chỗ nhét asset.
- Tạo từ file có sẵn khi không muốn chép tay:
kubectl create configmap frontend-config \
--from-literal=API_BASE_URL=https://api.congty.com \
--from-file=nginx.conf=./nginx.conf \
-n production
# Muốn xem YAML mà không tạo, để commit vào Git:
kubectl create configmap frontend-config --from-file=nginx.conf \
--dry-run=client -o yaml > k8s/configmap.yaml
Thêm immutable: true vào ConfigMap khi bạn quản lý nó theo kiểu “mỗi phiên bản một tên” (Kustomize làm vậy ở Phần 15): apiserver từ chối mọi sửa đổi sau đó, và kubelet không phải poll kiểm tra thay đổi, giảm tải cho cluster lớn.
Secret: dữ liệu nhạy cảm
# Tạo bằng lệnh (khuyến nghị - không để lộ trong Git)
kubectl create secret generic frontend-secrets \
--from-literal=SENTRY_DSN='https://abc@sentry.io/123' \
--from-literal=ANALYTICS_KEY='xyz789' \
-n production
# Xem (value bị encode base64)
kubectl get secret frontend-secrets -o yaml
# Giải mã để kiểm tra
kubectl get secret frontend-secrets -o jsonpath='{.data.SENTRY_DSN}' | base64 -d
Nếu viết Secret bằng YAML, có hai trường: data nhận giá trị đã base64, stringData nhận chuỗi thường và K8s tự encode khi lưu. Dùng stringData cho dễ đọc, nhưng nhớ file đó không được commit.
apiVersion: v1
kind: Secret
metadata:
name: frontend-secrets
namespace: production
type: Opaque
stringData:
SENTRY_DSN: https://abc@sentry.io/123
Ba type bạn sẽ gặp: Opaque (mặc định, key-value tuỳ ý), kubernetes.io/dockerconfigjson (credential pull image, tạo bằng create secret docker-registry ở Phần 4), và kubernetes.io/tls (cặp tls.crt + tls.key cho Ingress).
Nguy hiểm: Secret không được mã hoá, chỉ được encode base64. base64 là encoding, ai cũng giải được trong một giây; nó tồn tại để chứa được dữ liệu nhị phân, không phải để giấu. Mặc định Secret nằm dạng plaintext trong etcd, và ai có quyền
get secrettrong namespace là đọc được. Tuyệt đối không commit file Secret vào Git. Giải pháp production: Sealed Secrets (mã hoá bằng public key của cluster, commit được bản đã mã hoá), External Secrets Operator (đồng bộ từ AWS Secrets Manager / Vault / GCP Secret Manager vào Secret của K8s), hoặc bật encryption at rest cho etcd.
Góc nhìn frontend: những giá trị như Sentry DSN hay analytics key vốn sẽ nằm trong bundle JS gửi tới trình duyệt, nên chúng không thật sự “bí mật”. Secret thật (API key có quyền ghi, database password, signing key) phải ở backend, không bao giờ đi qua Pod frontend. Với Next.js SSR thì khác: biến server-side như SESSION_SECRET là bí mật thật và đúng chỗ để dùng Secret, xem Phần 11.
Ba cách đưa config vào container
spec:
containers:
- name: web
image: my-app:v1
# CÁCH 1: từng biến một (rõ ràng, dễ kiểm soát, đổi tên được)
env:
- name: API_URL
valueFrom:
configMapKeyRef:
name: frontend-config
key: API_BASE_URL
- name: SENTRY_DSN
valueFrom:
secretKeyRef:
name: frontend-secrets
key: SENTRY_DSN
# CÁCH 2: nạp toàn bộ ConfigMap / Secret thành biến môi trường
envFrom:
- configMapRef:
name: frontend-config
- secretRef:
name: frontend-secrets
# CÁCH 3: mount thành file (dùng cho nginx.conf, cert)
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d/default.conf
subPath: nginx.conf
readOnly: true
volumes:
- name: nginx-config
configMap:
name: frontend-config
items:
- key: nginx.conf
path: nginx.conf
| Cách | Dùng khi | Lưu ý |
|---|---|---|
env + valueFrom | Vài biến, muốn đổi tên (API_BASE_URL → API_URL) | Dài dòng khi có nhiều biến |
envFrom | Nạp cả ConfigMap, tên key giữ nguyên | Key nginx.conf cũng thành biến môi trường (vô hại, nhưng env sẽ in ra cả file) |
| Mount file | Nội dung là file: nginx.conf, config.js, chứng chỉ | subPath chọn đúng một file thay vì đè cả thư mục, nhưng mất khả năng tự cập nhật |
Về subPath: nếu mount cả ConfigMap vào /etc/nginx/conf.d/ mà không có subPath, K8s thay thế toàn bộ thư mục bằng nội dung ConfigMap, mọi file có sẵn trong image ở đó biến mất. subPath: nginx.conf chỉ đặt đúng một file lên đúng một đường dẫn, là cách an toàn khi thư mục đích còn file khác. Đổi lại, file mount qua subPath không tự cập nhật khi ConfigMap đổi.
Kiểm tra config đã vào tới container chưa, đây là hai lệnh dùng nhiều nhất trong cả series:
kubectl exec <pod> -- env | grep API
kubectl exec <pod> -- cat /etc/nginx/conf.d/default.conf
Cạm bẫy: đổi ConfigMap không tự restart Pod
Bạn sửa API_BASE_URL trong ConfigMap, kubectl apply, mở app: vẫn giá trị cũ. Đây là chuyện bình thường, không phải bug:
- Dạng biến môi trường (
env,envFrom): biến được đọc một lần lúc container khởi động, giốngprocess.envđược chụp khi Node.js start. Pod không bao giờ thấy giá trị mới cho tới khi được tạo lại. Phải chạykubectl rollout restart deploy/my-app. - Dạng mount file (không
subPath): kubelet cập nhật file trong Pod sau khoảng một phút (chu kỳ sync cộng TTL cache), nhưng nginx đã đọc config lúc start, phảinginx -s reloadmới nhận. Mount quasubPaththì không cập nhật gì cả.
Giải pháp chuyên nghiệp: đưa hash của ConfigMap vào annotation của Pod template. Hash đổi → template đổi → Deployment tự rolling update, không ai phải nhớ chạy restart. Helm làm việc này bằng checksum/config (Phần 15); Kustomize làm bằng cách gắn hậu tố hash vào tên ConfigMap. Nếu chưa dùng cả hai, một công cụ như Reloader (theo dõi ConfigMap và tự restart Deployment tham chiếu nó) cũng giải quyết được; kiểm tra docs trước khi cài vào cluster công ty.
Thực hành 8.1: ConfigMap và Secret
- Apply
configmap.yamlở trên và Deploymentmy-frontendtừ Phần 6, thêm khốienvFromtrỏ tớifrontend-config. kubectl exec <pod> -- env | grep -E "API|FEATURE": phải thấy hai biến với đúng giá trị.- Sửa
API_BASE_URLthànhhttps://api-v2.congty.com, apply lại ConfigMap, chạy lại lệnh ở bước 2. Giá trị chưa đổi. kubectl rollout restart deploy/my-frontend, chờrollout status, chạy lại bước 2. Giờ đã đổi.- Thêm
volumeMounts+volumesmountnginx.confnhư cách 3, apply.kubectl exec <pod> -- cat /etc/nginx/conf.d/default.confphải ra nội dung từ ConfigMap. Mởport-forward, F5 ở một route con: vẫn raindex.htmlnhờtry_files. - Tạo Secret bằng
kubectl create secret generic,kubectl get secret -o yamlđể thấy base64, rồibase64 -dđể thấy nó không hề bí mật.
Kết quả mong đợi: Bước 3 chứng minh biến môi trường không tự cập nhật; bước 4 chứng minh restart là cách sửa. Nếu bước 5 Pod rơi vào
CrashLoopBackOff,kubectl logssẽ báo nginx lỗi cú pháp config: kiểm tra thụt lề khốinginx.conf: |trong ConfigMap.
Health check: liveness, readiness, startup probe
K8s cần biết container của bạn có khoẻ không. Nó không đoán được: một tiến trình nginx có thể đang chạy nhưng treo, một Next.js có thể đã start nhưng còn 10 giây nữa mới nhận request. Bạn phải khai báo cách kiểm tra. Có ba loại probe, mỗi loại trả lời một câu hỏi khác nhau, và hậu quả khi fail khác nhau hoàn toàn.
| Probe | Câu hỏi | Nếu FAIL thì sao |
|---|---|---|
| readiness | ”Đã sẵn sàng nhận traffic chưa?” | Pod bị gỡ khỏi Service endpoints. Pod vẫn sống, chỉ không nhận request. Pass lại thì được thêm vào. |
| liveness | ”Còn sống không hay treo rồi?” | Container bị giết và khởi động lại. |
| startup | ”Khởi động xong chưa?” | Tạm hoãn hai probe kia cho tới khi pass. Dùng cho app khởi động chậm. |
Readiness là thứ nối bài này với Phần 7: endpoints của Service chỉ chứa Pod đã pass readiness. Nó cũng là thứ làm rolling update không mất request: Pod mới chỉ nhận traffic khi đã sẵn sàng, Pod cũ chỉ bị tắt khi Pod mới đã ready. Không có readiness probe, K8s coi container là ready ngay khi start, và người dùng nhận 502 trong vài giây đầu mỗi lần deploy.
Cấu hình chi tiết
readinessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 3 # chờ 3s sau khi container start mới kiểm tra
periodSeconds: 5 # kiểm tra mỗi 5s
timeoutSeconds: 2 # quá 2s không trả lời = fail
successThreshold: 1 # 1 lần OK là coi như ready
failureThreshold: 3 # fail 3 lần liên tiếp mới gỡ khỏi Service
livenessProbe:
httpGet:
path: /healthz
port: 80
initialDelaySeconds: 15 # PHẢI lớn hơn thời gian khởi động
periodSeconds: 20
failureThreshold: 3
startupProbe: # cho app SSR khởi động chậm
httpGet:
path: /healthz
port: 3000
failureThreshold: 30
periodSeconds: 5 # cho phép tối đa 30 x 5 = 150 giây để khởi động
Cách đọc các con số:
- Thời gian tối đa từ lúc probe bắt đầu fail tới lúc K8s hành động là
periodSeconds × failureThreshold. Readiness ở trên: 5 × 3 = 15 giây kể từ khi app bắt đầu lỗi thì Pod rời endpoints. Liveness: 20 × 3 = 60 giây treo thì bị restart. startupProbethay thếinitialDelaySecondsmột cách thông minh: thay vì đoán “app mất 40 giây”, bạn nói “cho tối đa 150 giây, xong lúc nào chạy lúc đó”. Khi startup pass, readiness và liveness mới bắt đầu, vớiinitialDelaySecondscủa riêng chúng.timeoutSecondsmặc định là 1 giây. App SSR đang bận render có thể trả/healthzchậm hơn 1 giây và bị tính fail oan; đặt 2 đến 3 giây.successThresholdphải là 1 với liveness và startup; chỉ readiness cho phép lớn hơn 1 (bắt app ổn định vài lần liên tiếp mới nhận traffic).
Cạm bẫy chết người: liveness probe quá gắt. Đặt
initialDelaySecondsnhỏ hơn thời gian khởi động thật → K8s giết container ngay khi nó chưa kịp sẵn sàng → container restart → lại bị giết →CrashLoopBackOffvĩnh viễn, vàkubectl logskhông hề có lỗi ứng dụng nào. Dấu hiệu nhận biết:kubectl describe podcó EventsLiveness probe failedrồiKilling container, còn log app dừng giữa chừng lúc đang khởi động.
Nguy hiểm hơn: đặt liveness probe gọi vào endpoint có phụ thuộc database hay API khác. Database chậm một lúc → toàn bộ Pod fail liveness cùng lúc → bị giết hàng loạt → sập dây chuyền, và restart không hề giúp vì lỗi không nằm ở Pod. Nguyên tắc: liveness phải cực kỳ đơn giản, chỉ kiểm tra tiến trình còn phản hồi HTTP. Mọi kiểm tra phụ thuộc bên ngoài để ở readiness (Pod tạm rời endpoints rồi quay lại khi backend hồi), hoặc không kiểm tra gì cả.
Ba kiểu probe
# httpGet - phổ biến nhất cho web, pass khi status 200-399
httpGet: { path: /healthz, port: 80 }
# tcpSocket - chỉ kiểm tra port có mở không
tcpSocket: { port: 3000 }
# exec - chạy lệnh trong container, exit code 0 = pass
exec:
command: ['sh', '-c', 'test -f /tmp/ready']
httpGet được kubelet gửi tới IP của Pod, không phải localhost, nên app phải listen 0.0.0.0; app bind 127.0.0.1 sẽ fail probe dù curl localhost bên trong container chạy tốt. tcpSocket hợp với dịch vụ không nói HTTP, nhưng với web app nó quá yếu: cổng mở không có nghĩa app trả lời được. exec tốn tài nguyên hơn (fork tiến trình mỗi lần) và hợp cho các tình huống đặc biệt.
Probe cho SPA và cho Next.js
Với SPA tĩnh phục vụ bằng nginx, bạn không cần viết code health check. Chỉ cần thêm vào nginx.conf:
location /healthz {
access_log off;
return 200 "ok\n";
}
access_log off để log không ngập mỗi 5 giây một dòng GET /healthz từ kubelet. Đừng dùng / làm đường dẫn probe: nó trả về cả index.html, tốn băng thông vô ích, và nếu sau này try_files đổi thì probe cũng đổi hành vi theo.
Với Next.js (App Router), tạo route handler:
// app/api/health/route.ts
export const dynamic = 'force-dynamic';
export async function GET() {
return Response.json({ status: 'ok', ts: Date.now() });
}
force-dynamic để Next.js không cache tĩnh route này lúc build. Nếu bạn muốn readiness “thật” hơn cho SSR, có thể kiểm tra thêm kết nối tới backend trong handler này, nhưng nhớ nguyên tắc ở trên: chỉ dùng nó cho readiness, và tạo một route riêng, ví dụ /api/live, chỉ trả 200 trần cho liveness.
Đọc lỗi probe
kubectl describe pod <pod>
# Events:
# Warning Unhealthy Readiness probe failed: HTTP probe failed with statuscode: 404
# Warning Unhealthy Liveness probe failed: Get "http://10.244.1.5:80/healthz": dial tcp: connection refused
# Normal Killing Container web failed liveness probe, will be restarted
Ba dạng thông báo hay gặp và nghĩa của chúng:
statuscode: 404: đường dẫn sai hoặcnginx.confchưa cólocation /healthz. Vào Pod kiểm tra:kubectl exec <pod> -- wget -qO- http://localhost/healthz.connection refused: saiport, hoặc app listen127.0.0.1, hoặc app chưa kịp mở cổng vìinitialDelaySecondsquá ngắn.context deadline exceeded(timeout): app trả lời chậm hơntimeoutSeconds; tăng timeout hoặc làm endpoint nhẹ hơn.
Thực hành 8.2: Probe
- Thêm
readinessProbevàlivenessProbenhư cấu hình chi tiết ở trên vào Deploymentmy-frontend(imagenginx:1.27-alpinemặc định không có/healthz). Apply vàkubectl get pods -w. - Pod lên
Runningnhưng READY là0/1mãi;kubectl get endpoints my-frontend-svcrỗng; sau 60 giây liveness fail và RESTARTS tăng dần.kubectl describe podcho thấystatuscode: 404. - Sửa: mount
nginx.conftừ ConfigMap ở Thực hành 8.1, thêm blocklocation /healthz. Apply, READY thành1/1, endpoints có IP trở lại. - Cố tình đổi
livenessProbe.initialDelaySecondsthành0vàperiodSecondsthành1,failureThresholdthành1, đồng thời thêmcommand: ["sh", "-c", "sleep 10 && nginx -g 'daemon off;'"]để giả lập app khởi động chậm. Quan sátCrashLoopBackOffxuất hiện dù app không hề lỗi. Đây chính là liveness quá gắt. - Khôi phục cấu hình đúng, hoặc thay bằng
startupProbevớifailureThreshold: 30, periodSeconds: 1và xem nó chờ app khởi động xong.
Kết quả mong đợi: Bước 2 thấy
READY 0/1và endpoints rỗng (readiness fail không giết Pod); bước 4 thấy RESTARTS tăng và trạng tháiCrashLoopBackOff(liveness fail giết Pod). Nếu bạn nhớ được sự khác nhau của hai bước này, bạn đã hiểu probe.
Bảng tra cứu nhanh
| Triệu chứng | Nghĩ tới | Lệnh kiểm tra |
|---|---|---|
| Đổi ConfigMap, app vẫn giá trị cũ | Biến env chỉ đọc lúc start | kubectl rollout restart deploy/<name> |
| File mount không đổi theo ConfigMap | Mount qua subPath | Bỏ subPath hoặc restart |
| Thư mục trong container “mất” file có sẵn | Mount ConfigMap đè cả thư mục | Dùng subPath hoặc mount vào thư mục riêng |
Running nhưng READY 0/1 | Readiness fail | describe pod, exec wget localhost/healthz |
CrashLoopBackOff mà log app sạch | Liveness quá gắt | Events có Liveness probe failed + Killing |
| Mọi Pod restart cùng lúc khi backend chậm | Liveness phụ thuộc bên ngoài | Chuyển kiểm tra đó sang readiness |
| Secret “mã hoá” nhưng đọc được | base64 chỉ là encode | Sealed Secrets / External Secrets / encryption at rest |
Tóm tắt
- Một image chạy mọi môi trường; cấu hình tách ra ConfigMap (không nhạy cảm) và Secret (nhạy cảm). Mọi value của ConfigMap là chuỗi, một key có thể là cả file.
- Secret chỉ base64, không mã hoá; không commit vào Git. Production dùng Sealed Secrets, External Secrets Operator hoặc encryption at rest. Secret thật của frontend nằm ở backend.
- Ba cách đưa config vào container:
envtừng biến,envFromcả bộ, mount file chonginx.conf.subPathmount đúng một file nhưng không tự cập nhật. - Đổi ConfigMap không restart Pod: biến env cần
rollout restart; giải pháp lâu dài là hash config vào annotation của Pod template (Helmchecksum/config, Kustomize hash suffix). - Readiness fail → rời endpoints, Pod vẫn sống. Liveness fail → container bị giết. Startup hoãn hai probe kia cho app khởi động chậm.
- Liveness phải đơn giản tuyệt đối và
initialDelaySecondsphải lớn hơn thời gian khởi động thật; kiểm tra phụ thuộc bên ngoài để ở readiness. - SPA:
location /healthztrongnginx.conf. Next.js: route handlerapp/api/health/route.tsvớiforce-dynamic.
Đọc thêm
- ConfigMaps trong docs Kubernetes
- Secrets trong docs Kubernetes
- Configure Liveness, Readiness and Startup Probes
Phần 9 tiếp tục với ba thứ còn lại của Pod: requests/limits và QoS (Pod nào bị giết trước khi node hết RAM), volume nào FE thật sự cần, và chuỗi sự kiện khi Pod bị xoá, thứ gây ra 502 lúc deploy.