Một thay đổi cấu hình rất thường gặp trên Kubernetes: đổi dbHost trong values của hàng loạt site, ví dụ khi chuyển database từ MySQL StatefulSet trong cluster sang một VPS MySQL riêng. Với Helm, việc này trông như một dòng YAML và một lệnh helm upgrade. Nhưng nếu chart thiếu checksum annotation trên pod template, helm upgrade chỉ cập nhật ConfigMap, còn pod đang chạy vẫn giữ biến môi trường cũ. Không có lỗi, không có cảnh báo, site vẫn trả HTTP 200, chỉ là đang nói chuyện với database cũ. Bài này giải thích vì sao, cách kiểm chứng bằng helm template, cách kiểm tra từng pod và cách thêm checksum annotation vào chart.

Triệu chứng: không có triệu chứng

Đó chính là điều nguy hiểm của việc thiếu checksum annotation. Sau khi đổi values, mọi tín hiệu đều xanh: helm upgrade trả về has been upgraded, revision tăng, kubectl get configmap cho thấy DB_HOST mới, pod vẫn Running. Database cũ vẫn chạy nên ứng dụng không báo lỗi kết nối. Nếu pod giữ host cũ, mọi đơn hàng hay bài viết mới sau thời điểm dump sẽ ghi vào database cũ, và chỉ lộ ra khi database cũ bị tắt: lúc đó dữ liệu đã tách làm hai.

Manh mối thường nằm ở một thói quen: các pipeline đổi cấu hình đều kết thúc bằng kubectl rollout restart, và tài liệu nội bộ ghi chú "nhớ restart sau khi đổi host" (như bài MySQL trong Kubernetes hay ngoài cluster: vì sao đưa database ra VPS). Câu hỏi cần đặt ra: vì sao mọi thay đổi cấu hình đều cần restart tay, chart có checksum annotation hay không, và site nào đã thật sự được restart?

Điều tra

Bước 1: đọc template Deployment, tìm checksum annotation

Ứng dụng lấy cấu hình qua envFrom, ở cả initContainer lẫn container chính. Pod template chỉ có annotation do người dùng truyền vào (mặc định rỗng) và một nhãn gắn với lượt deploy code:

  template:
    metadata:
      {{- with .Values.podAnnotations }}      # values mặc định: podAnnotations: {}
      annotations:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      labels:
        commit: {{ .Values.commitId }}
    spec:
      containers:
        - envFrom:
            - configMapRef:
                name: {{ include "app.fullname" . }}-config   # DB_HOST nằm ở đây
            - secretRef:
                name: {{ include "app.fullname" . }}-secret   # DB_PASSWORD, APP_KEY...

Deployment controller chỉ tạo pod mới khi spec.template đổi. Biến môi trường từ ConfigMap được đọc đúng một lần, lúc container khởi động; tài liệu ConfigMap của Kubernetes nói rõ biến môi trường không tự cập nhật. Không có checksum annotation, đổi ConfigMap không làm template đổi.

Bước 2: kiểm chứng bằng helm template

Render chart hai lần với cùng values, chỉ khác dbHost, rồi so từng object:

$ helm template site-a ./chart -f values-old.yaml > old.yaml
$ helm template site-a ./chart -f values-new.yaml > new.yaml
$ diff old.yaml new.yaml
42c42
<   DB_HOST: mysql.database.svc.cluster.local
---
>   DB_HOST: 10.0.1.20

Certificate, Ingress, Service, Secret và Deployment giống hệt; chỉ ConfigMap khác. Helm áp đúng phần khác đó, Kubernetes thấy Deployment không đổi, và không có pod nào được tạo lại. Đây là hành vi đúng thiết kế, không phải lỗi của Helm hay Kubernetes; thiếu checksum annotation mới là lỗ hổng của chart.

Bước 3: site nào đã được restart, và vì sao

Lịch sử Git của repo values thường trả lời câu hỏi này. Nếu sau commit đổi database có một lượt deploy code mới, nhãn commit trong pod template đổi theo, pod được tạo lại và tình cờ nạp DB_HOST mới. Những site không có lượt deploy nào sau đó vẫn chạy với cấu hình cũ, chỉ có rollout restart bằng tay mới làm pod đọc cấu hình mới.

Nói cách khác, việc một site có dùng đúng database hay không phụ thuộc vào chuyện có ai push code cho site đó hay không. Một hệ thống vận hành không nên dựa vào sự tình cờ như vậy.

Bước 4: cách kiểm tra từng pod

Giá trị trong ConfigMap chưa chứng minh được gì. Thứ cần so là biến môi trường bên trong container đang chạy, và thời điểm pod khởi động so với lúc helm upgrade:

NS=site-a
kubectl get configmap -n $NS app-config -o jsonpath='{.data.DB_HOST}'; echo
kubectl exec -n $NS deploy/app -- printenv DB_HOST
kubectl get pod -n $NS -o custom-columns=NAME:.metadata.name,START:.status.startTime
helm history $NS -n $NS | tail -2

Hai giá trị DB_HOST khác nhau, hoặc pod khởi động trước revision Helm cuối cùng, thì pod đó đang chạy với cấu hình cũ. Với nhiều site, gói các lệnh trên vào vòng lặp qua mọi namespace và in ra những site lệch.

Nguyên nhân gốc

  • Chart thiếu checksum annotation cho ConfigMap và Secret, nên mọi thay đổi chỉ nằm trong hai object này đều không kích hoạt rollout.
  • Cơ chế rollout duy nhất là nhãn commit, gắn với lượt deploy code chứ không gắn với thay đổi cấu hình. Nhãn này lấp chỗ trống của checksum annotation một cách tình cờ.
  • Kiểm tra sau deploy chỉ hỏi HTTP 200, mà một site nói chuyện với database cũ vẫn trả 200.

Khắc phục

Tạm thời: rollout restart có kiểm tra

Sau mỗi lần đổi ConfigMap hoặc Secret, chạy kubectl rollout restart rồi chờ rollout xong và kiểm tra giá trị trong pod, thay vì chỉ gọi restart rồi đi tiếp:

kubectl rollout restart deployment/app -n $NS
kubectl rollout status  deployment/app -n $NS --timeout=300s
kubectl exec -n $NS deploy/app -- printenv DB_HOST

Ghi chú về chứng chỉ TLS: Secret TLS do ingress controller đọc, không phải do pod ứng dụng đọc. ingress-nginx theo dõi Secret và tự nạp chứng chỉ mới, nên rollout restart sau khi đổi chứng chỉ là không cần thiết. Cũng không nên sửa Secret bằng kubectl apply ngoài Helm, vì values trong repo sẽ không còn khớp với cluster và lần helm upgrade sau có thể ghi đè ngược.

Lâu dài: thêm checksum annotation vào chart

Cách làm chuẩn có trong Chart Development Tips and Tricks của Helm: băm nội dung template ConfigMap và Secret, đặt kết quả vào annotation của pod template. Nội dung đổi thì hash đổi, template đổi, Deployment tự rollout:

  template:
    metadata:
      annotations:
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}

Chạy lại phép so ở bước 2 với chart đã có checksum annotation, Deployment không còn giống hệt nữa:

$ diff old.yaml new.yaml
42c42
<   DB_HOST: mysql.database.svc.cluster.local
---
>   DB_HOST: 10.0.1.20
121c121
<         checksum/config: c320c9a1efdc…
---
>         checksum/config: 6f2d48708723…

Ba lưu ý khi áp checksum annotation:

  • Lần helm upgrade đầu tiên sau khi thêm annotation sẽ restart mọi site dùng chart, nên chạy theo lô, ngoài giờ cao điểm.
  • Checksum annotation chỉ bắt được thay đổi đi qua Helm. Ai sửa ConfigMap bằng kubectl edit hay kubectl apply vẫn phải tự restart.
  • Restart không phải lúc nào cũng vô hại. Nếu pod có initContainer chạy php artisan migrate hay bước copy code, mỗi lần đổi cấu hình là một lần khởi động đầy đủ. Cần có readiness probe và nhiều hơn một replica nếu muốn không gián đoạn.

Phòng ngừa: checksum annotation là chuẩn bắt buộc

  • Mọi chart phải có checksum annotation cho ConfigMap và Secret mà pod dùng qua envFrom hay env. Đưa vào checklist review chart.
  • Pipeline đổi cấu hình kiểm tra giá trị bên trong pod (printenv), không chỉ kiểm tra HTTP 200.
  • Khi chuyển database: chặn ghi hoặc tắt hẳn database cũ sau khi chuyển, để pod còn trỏ nhầm báo lỗi ngay thay vì âm thầm ghi vào đó.
  • Không sửa object do Helm quản lý bằng kubectl apply; thay đổi đi qua values và helm upgrade.
  • Thêm bước helm template + diff vào review để thấy trước object nào sẽ đổi.

Bài học

Helm làm đúng điều nó được yêu cầu: render template và áp phần khác biệt. Khoảng trống nằm ở chỗ chart chưa nói cho Deployment biết rằng ConfigMap là một phần của nó, và checksum annotation chính là cách nói điều đó. Một nhãn gắn với lượt deploy code có thể che khoảng trống này rất lâu, vì site nào cũng được deploy lại thường xuyên, cho tới lần đầu tiên đổi cấu hình mà không đổi code. Bài học rộng hơn: một bước kiểm tra chỉ hỏi "site có sống không" sẽ bỏ lọt mọi lỗi kiểu "site sống, nhưng sống sai".

Khái niệm ConfigMap, Secret và các cách nạp cấu hình có ở bài ConfigMap và Secret: biến môi trường Laravel trên Kubernetes; cấu trúc chart có ở bài Chart dùng chung: tái sử dụng Helm cho Laravel, MySQL và Redis.

Bài tiếp theo

OpenSSL lệch phiên bản làm vỡ git và ssh trong image CI. Khi một gói cài thêm lúc chạy job kéo theo thư viện mới, chính git và ssh trong image CI có thể ngừng hoạt động.