Trên Cloud VPS, mỗi domain mới là một lần chạy certbot trên balancer. Trong cluster, chứng chỉ phải được cấp ngay bên trong Kubernetes, gắn với Ingress của từng site và tự gia hạn mà không ai phải nhớ. Tài liệu này hướng dẫn cài hai add-on nền tảng bằng Helm: ingress-nginx làm cổng vào và cert-manager lo chứng chỉ Let's Encrypt. Bài giải thích cách cert-manager cấp chứng chỉ qua HTTP-01, chuỗi đối tượng Certificate, CertificateRequest, Order, Challenge, hai lỗi cấu hình "im lặng" hay gặp và cách xử lý challenge bị kẹt.

1. Helm: cài add-on bằng chart

Ingress controller và cert-manager mỗi thứ gồm hàng chục đối tượng: Deployment, Service, ServiceAccount, RBAC, webhook, CRD. Tự viết YAML cho chừng đó thứ là không thực tế. Helm giải quyết việc này: tác giả đóng gói sẵn thành chart, người dùng cài chart thành một release trong cluster, mỗi lần nâng cấp tạo thêm một revision, và có thể quay lại revision trước nếu hỏng. Ở bước này, Helm chỉ dùng để cài phần mềm của người khác; ứng dụng vẫn deploy bằng manifest thuần như bài Manifest Kubernetes đầu tiên: namespace, Deployment và MySQL StatefulSet.

2. Cài ingress-nginx: một NodeBalancer cho cả cluster

helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx --create-namespace \
  --version <phiên-bản-chart>        # luôn ghim phiên bản

kubectl get svc -n ingress-nginx ingress-nginx-controller
# TYPE: LoadBalancer   EXTERNAL-IP: <IP-NodeBalancer>   PORT(S): 80:3xxxx/TCP,443:3xxxx/TCP

Cài với giá trị mặc định, chart cho bạn: một replica controller, một Service LoadBalancer nên Linode CCM tạo đúng một NodeBalancer, externalTrafficPolicy: Cluster, và một IngressClass tên nginx. Đó là mô hình trong bài Ingress và NodeBalancer: đưa traffic vào cluster Kubernetes: mọi domain trỏ về một IP, controller định tuyến theo Host. Mặc định cũng có giá: ứng dụng chưa thấy IP thật của người dùng, và controller một replica là điểm lỗi chung. Khi lên production, nên tăng controller.replicaCount và cấu hình lấy IP thật.

3. Cài cert-manager và tạo ClusterIssuer

helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager --create-namespace \
  --version <phiên-bản-còn-hỗ-trợ> \
  --set crds.enabled=true             # bản cũ dùng installCRDs=true

kubectl get pods -n cert-manager
# cert-manager, cert-manager-cainjector, cert-manager-webhook: Running

Tham số bật CRD để chart cài luôn các CRD cert-manager.io và acme.cert-manager.io thay vì phải apply riêng. Ghim phiên bản là đúng, vì cài lại sẽ ra đúng thứ đã chạy. Nhưng hãy ghim một phiên bản còn được hỗ trợ và đã được kiểm thử với phiên bản Kubernetes của cluster. cert-manager chỉ hỗ trợ vài bản phát hành gần nhất; một bản cũ vẫn chạy được không có nghĩa là còn được vá lỗi. Đặt lịch rà phiên bản add-on mỗi khi nâng cấp cluster.

cert-manager cần biết xin chứng chỉ ở đâu và chứng minh quyền sở hữu domain bằng cách nào. Thông tin đó nằm trong một ClusterIssuer, dùng chung cho mọi namespace:

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod           # ClusterIssuer không có namespace
spec:
  acme:
    email: <email-nhận-cảnh-báo>
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-prod-account    # khoá tài khoản ACME, cert-manager tự tạo
    solvers:
    - http01:
        ingress:
          ingressClassName: nginx       # solver đi qua ingress-nginx

ClusterIssuer là đối tượng cấp cluster, nên dòng namespace dưới metadata (hay bị chép từ ví dụ Issuer) là vô nghĩa. Ở bản cert-manager cũ, trường solver là class: nginx thay vì ingressClassName.

4. HTTP-01 trong cert-manager hoạt động thế nào

Cơ chế HTTP-01 vẫn như khi dùng certbot, đã giải thích trong bài SSL cho hàng trăm domain với Certbot: Let's Encrypt đưa một token, bạn phải trả token đó tại http://<domain>/.well-known/acme-challenge/<token>. Khác biệt là cert-manager tự dựng chỗ trả token bên trong cluster, và mỗi bước được ghi lại thành một đối tượng Kubernetes riêng:

Certificate          "cần cert cho app.example.com, lưu vào secret X"
   └─ CertificateRequest   một lần xin cert cụ thể (kèm CSR)
        └─ Order           đơn hàng ACME gửi tới Let's Encrypt
             └─ Challenge  mỗi domain một challenge HTTP-01
                  ├─ pod   cm-acme-http-solver-xxxxx   trả token
                  ├─ Service cho pod solver
                  └─ Ingress tạm: /.well-known/acme-challenge/<token> → solver

Trước khi báo Let's Encrypt kiểm tra, cert-manager tự gọi thử URL challenge từ trong cluster (self check). Self check qua thì Let's Encrypt mới được mời kiểm tra từ Internet. Xong, cert-manager xoá pod, Service và Ingress tạm, ghi chứng chỉ vào Secret kiểu kubernetes.io/tls. ingress-nginx đọc Secret đó và phục vụ HTTPS. Tới gần hạn, cert-manager tự lặp lại cả chuỗi. Chi tiết về solver nằm trong tài liệu HTTP01 của cert-manager.

5. Khai báo Certificate và Ingress

Có hai cách bảo cert-manager cấp chứng chỉ: thêm annotation lên Ingress để thành phần ingress-shim tự sinh Certificate, hoặc tự viết Certificate. Cách thứ hai dài hơn nhưng mọi thứ hiện rõ: cert cho domain nào, issuer nào, lưu vào đâu, gia hạn khi nào.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-tls
  namespace: app
spec:
  secretName: app-tls
  duration: 2160h      # 90 ngày
  renewBefore: 360h    # gia hạn trước 15 ngày
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
  - app.example.com
  - www.app.example.com
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  namespace: app
  annotations:
    nginx.ingress.kubernetes.io/from-to-www-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [app.example.com, www.app.example.com]
      secretName: app-tls            # Secret do Certificate ở trên tạo ra
  rules:
  - host: app.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service: {name: web, port: {number: 80}}

Annotation from-to-www-redirect tự redirect www về domain chính, nên rules chỉ cần một host. Theo tài liệu annotation của ingress-nginx, annotation này bị bỏ qua nếu đã có Ingress khai báo chính host www. Nhưng chứng chỉ vẫn phải chứa cả hai tên, nếu không trình duyệt báo lỗi trước khi kịp redirect. Khi Ingress có khối tls, ingress-nginx mặc định chuyển HTTP sang HTTPS.

6. Hai lỗi annotation khiến cert-manager im lặng

Một Ingress dùng annotation thường bị viết sai như sau:

metadata:
  annotations:
    kubernetes.io/ingress.class: nginx            # annotation cũ, thừa
    cert-manager.io/issuer: letsencrypt-prod      # nhưng letsencrypt-prod là ClusterIssuer
spec:
  ingressClassName: nginx
  rules: ...                                      # và không có khối tls

Hai lỗi chồng lên nhau, và không lỗi nào báo đỏ khi kubectl apply:

  1. Sai loại issuer. cert-manager.io/issuer nghĩa là tìm một Issuer trong chính namespace của Ingress. Nếu đối tượng là ClusterIssuer, annotation đúng là cert-manager.io/cluster-issuer.
  2. Ingress không có tls. ingress-shim chỉ sinh Certificate cho các host nằm trong khối tls và lấy tên Secret từ đó. Không có tls thì dù issuer đúng, cert-manager cũng không làm gì.

Không khai báo đồng thời annotation cũ kubernetes.io/ingress.class và trường ingressClassName; chỉ giữ trường mới. Sau mỗi lần apply, chạy kubectl get certificate -A: không thấy Certificate nào xuất hiện là dấu hiệu annotation chưa đúng.

7. Lỗi thường gặp khi cert-manager cấp chứng chỉ

Domain chưa trỏ về NodeBalancer: challenge kẹt

Đây là lỗi gặp nhiều nhất. Khách chưa đổi DNS, hoặc đổi rồi mà bản ghi chưa lan tới, Certificate vẫn được tạo và chuỗi xin cấp vẫn bắt đầu. Self check thất bại, Challenge đứng ở pending, pod solver chạy mãi:

kubectl get certificate,certificaterequest,order,challenge -n <namespace>
kubectl describe challenge -n <namespace> <tên-challenge>
# Reason: Waiting for HTTP-01 challenge propagation: failed to perform self check
#   GET request 'http://site-a.example/.well-known/acme-challenge/...':
#   dial tcp: lookup site-a.example ...: no such host

Trỏ DNS về IP của NodeBalancer rồi chờ cert-manager tự thử lại. Đừng xoá đi tạo lại Certificate liên tục: mỗi lần là một lượt xin cấp mới, dễ chạm giới hạn của Let's Encrypt. Quy trình đúng: kiểm tra DNS trước, tạo Ingress có tls sau.

Domain đi qua proxy hoặc đã có redirect cứng

Nếu domain nằm sau một proxy như Cloudflare với luật ép HTTPS, hoặc có Ingress khác chiếm đường dẫn /.well-known/, request kiểm tra không tới được pod solver. Dấu hiệu là self check trả mã 3xx, 403 hoặc 404 thay vì "no such host".

Thử nghiệm mà dùng server production

Khi còn đang sửa cấu hình, tạo thêm một ClusterIssuer trỏ tới server staging https://acme-staging-v02.api.letsencrypt.org/directory. Chứng chỉ staging không được trình duyệt tin, nhưng giới hạn nới hơn nhiều.

Certificate Ready nhưng trình duyệt thấy chứng chỉ giả

ingress-nginx trả chứng chỉ tự ký "Kubernetes Ingress Controller Fake Certificate" khi không tìm thấy Secret khớp host. Kiểm tra secretName trong tls của Ingress có trùng secretName của Certificate không, và hai đối tượng có cùng namespace không.

8. So sánh cert-manager với certbot trên balancer

certbot trên Cloud VPScert-manager trong cluster
Cấp chứng chỉChạy lệnh trên balancerKhai báo Certificate hoặc annotation
Gia hạncertbot.timer, deploy hook reload nginxTự gia hạn, ingress-nginx tự nạp Secret mới
Theo dõi lỗiĐọc log certbotkubectl describe từng tầng Certificate → Challenge
Rủi roQuên một domain khi chuyển máyThêm thành phần phải nâng cấp; cấu hình sai thì im lặng

cert-manager giải phóng bạn khỏi việc gia hạn tay, nhưng nó là phần mềm chạy trong cluster, cần được nâng cấp như mọi phần mềm khác. Đổi lại, thêm một site chỉ còn là thêm một Ingress và một Certificate.

Checklist

  • ingress-nginx và cert-manager đều ghim phiên bản còn được hỗ trợ.
  • Có ClusterIssuer staging để thử và ClusterIssuer production để chạy thật.
  • DNS trỏ đúng trước khi tạo Ingress có tls.
  • Annotation dùng đúng cluster-issuer hoặc viết Certificate tường minh.
  • dnsNames chứa cả domain chính lẫn www.

Bài tiếp theo

Lỗi clear_env: Laravel trong container không đọc được biến môi trường. ConfigMap và Secret đã nạp đủ vào container, vậy mà Laravel vẫn kết nối sai database. Thủ phạm là một dòng mặc định trong cấu hình PHP-FPM.