jvinhit//lab

Search posts

Type to search across journal entries.

↑ ↓ navigate ↵ open esc close

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úc npm 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ết true trần thì YAML hiểu là boolean và apiserver từ chối vì ConfigMap chỉ nhận string. Cùng bẫy với version: 1.10 ở Phần 3.
  • Một key có thể là cả file. Dấu | giữ nguyên xuống dòng, nên nginx.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 secret trong 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áchDùng khiLưu ý
env + valueFromVài biến, muốn đổi tên (API_BASE_URL → API_URL)Dài dòng khi có nhiều biến
envFromNạp cả ConfigMap, tên key giữ nguyênKey nginx.conf cũng thành biến môi trường (vô hại, nhưng env sẽ in ra cả file)
Mount fileNộ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ống process.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ạy kubectl 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ải nginx -s reload mới nhận. Mount qua subPath thì 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

  1. Apply configmap.yaml ở trên và Deployment my-frontend từ Phần 6, thêm khối envFrom trỏ tới frontend-config.
  2. kubectl exec <pod> -- env | grep -E "API|FEATURE": phải thấy hai biến với đúng giá trị.
  3. Sửa API_BASE_URL thành https://api-v2.congty.com, apply lại ConfigMap, chạy lại lệnh ở bước 2. Giá trị chưa đổi.
  4. kubectl rollout restart deploy/my-frontend, chờ rollout status, chạy lại bước 2. Giờ đã đổi.
  5. Thêm volumeMounts + volumes mount nginx.conf như cách 3, apply. kubectl exec <pod> -- cat /etc/nginx/conf.d/default.conf phải ra nội dung từ ConfigMap. Mở port-forward, F5 ở một route con: vẫn ra index.html nhờ try_files.
  6. Tạo Secret bằng kubectl create secret generic, kubectl get secret -o yaml để thấy base64, rồi base64 -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 logs sẽ báo nginx lỗi cú pháp config: kiểm tra thụt lề khối nginx.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.

Service traffic in Ready? → traffic (readiness gate) Pod readiness: GET /ready container liveness: GET /healthz liveness fail → restart
Readiness quyết định Pod có được nhận traffic từ Service; liveness fail thì container bị restart
ProbeCâu hỏiNế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.
  • startupProbe thay thế initialDelaySeconds mộ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ới initialDelaySeconds của riêng chúng.
  • timeoutSeconds mặc định là 1 giây. App SSR đang bận render có thể trả /healthz chậm hơn 1 giây và bị tính fail oan; đặt 2 đến 3 giây.
  • successThreshold phả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 initialDelaySeconds nhỏ 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 → CrashLoopBackOff vĩnh viễn, và kubectl logs không hề có lỗi ứng dụng nào. Dấu hiệu nhận biết: kubectl describe pod có Events Liveness probe failed rồi Killing 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ặc nginx.conf chưa có location /healthz. Vào Pod kiểm tra: kubectl exec <pod> -- wget -qO- http://localhost/healthz.
  • connection refused: sai port, hoặc app listen 127.0.0.1, hoặc app chưa kịp mở cổng vì initialDelaySeconds quá ngắn.
  • context deadline exceeded (timeout): app trả lời chậm hơn timeoutSeconds; tăng timeout hoặc làm endpoint nhẹ hơn.

Thực hành 8.2: Probe

  1. Thêm readinessProbe và livenessProbe như cấu hình chi tiết ở trên vào Deployment my-frontend (image nginx:1.27-alpine mặc định không có /healthz). Apply và kubectl get pods -w.
  2. Pod lên Running nhưng READY là 0/1 mãi; kubectl get endpoints my-frontend-svc rỗng; sau 60 giây liveness fail và RESTARTS tăng dần. kubectl describe pod cho thấy statuscode: 404.
  3. Sửa: mount nginx.conf từ ConfigMap ở Thực hành 8.1, thêm block location /healthz. Apply, READY thành 1/1, endpoints có IP trở lại.
  4. Cố tình đổi livenessProbe.initialDelaySeconds thành 0 và periodSeconds thành 1, failureThreshold thành 1, đồng thời thêm command: ["sh", "-c", "sleep 10 && nginx -g 'daemon off;'"] để giả lập app khởi động chậm. Quan sát CrashLoopBackOff xuất hiện dù app không hề lỗi. Đây chính là liveness quá gắt.
  5. Khôi phục cấu hình đúng, hoặc thay bằng startupProbe với failureThreshold: 30, periodSeconds: 1 và xem nó chờ app khởi động xong.

Kết quả mong đợi: Bước 2 thấy READY 0/1 và endpoints rỗng (readiness fail không giết Pod); bước 4 thấy RESTARTS tăng và trạng thái CrashLoopBackOff (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ứngNghĩ tớiLệnh kiểm tra
Đổi ConfigMap, app vẫn giá trị cũBiến env chỉ đọc lúc startkubectl rollout restart deploy/<name>
File mount không đổi theo ConfigMapMount qua subPathBỏ subPath hoặc restart
Thư mục trong container “mất” file có sẵnMount ConfigMap đè cả thư mụcDùng subPath hoặc mount vào thư mục riêng
Running nhưng READY 0/1Readiness faildescribe pod, exec wget localhost/healthz
CrashLoopBackOff mà log app sạchLiveness quá gắtEvents có Liveness probe failed + Killing
Mọi Pod restart cùng lúc khi backend chậmLiveness phụ thuộc bên ngoàiChuyển kiểm tra đó sang readiness
Secret “mã hoá” nhưng đọc đượcbase64 chỉ là encodeSealed 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: env từng biến, envFrom cả bộ, mount file cho nginx.conf. subPath mount đú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 (Helm checksum/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à initialDelaySeconds phả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 /healthz trong nginx.conf. Next.js: route handler app/api/health/route.ts với force-dynamic.

Đọc thêm

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.