Ingress & TLS: một cổng vào, HTTPS tự động

Bài 04 giới thiệu Ingress bằng một sơ đồ và một lời hứa: "chi tiết ở bài sau". Đây là bài sau đó.

13 tháng 7, 2026 · 30 phút đọc

Bài 04 giới thiệu Ingress bằng một sơ đồ và một lời hứa: "chi tiết ở bài sau". Đây là bài sau đó.

Tám bài qua bạn đã chạy được app, phơi được ra ngoài bằng NodePort/LoadBalancer. Nhưng chưa có web production nào ra đời với địa chỉ kiểu http://192.168.200.85:31847. Production thật cần: một domain đẹp, HTTPS, nhiều app dùng chung một cổng vào — và cái chứng chỉ HTTPS đó phải tự gia hạn, chứ không phải đánh thức bạn lúc 2 giờ sáng vì nó hết hạn.

Bài này làm ba việc: định tuyến L7 cho ra hồn (host/path/annotation), TLS termination, và cert-manager + Let's Encrypt để chứng chỉ tự xin, tự gia hạn, mãi mãi.


Vấn đề: mỗi app một cổng vào là không xuể

   ❌ Không Ingress — mỗi service tự phơi ra:

   webapp    → LoadBalancer → IP 192.168.200.85
   api       → LoadBalancer → IP 192.168.200.86     ← mỗi IP tn tiền (cloud)
   admin     → LoadBalancer → IP 192.168.200.87        / tn IP LAN (on-prem)
   grafana   → NodePort     → :31847                 ← xấu, khó nhớ

   → Cần HTTPS? Tự cài cert vào TỪNG app. 4 app = 4 lần cấu hình TLS.
   → Cert hết hạn (Let's Encrypt: 90 ngày)? Gia hạn tay 4 chỗ.
   → Muốn cty.vn/api và cty.vn/admin về 2 service khác nhau? LoadBalancer
     làm ở tầng 4 (TCP) — nó KHÔNG BIẾT đường dẫn HTTP là gì. Bó tay.

Vấn đề gốc: LoadBalancer/NodePort làm việc ở tầng 4 — chúng chỉ thấy IP và cổng, không đọc được HTTP. Mà web thật lại cần định tuyến theo domainđường dẫn — thứ chỉ tồn tại ở tầng 7.


Ingress = luật, Ingress Controller = người thi hành

Đây là điểm nhiều người lẫn, nên khắc sâu ngay:

   Ingress             một OBJECT K8s chứa LUẬT định tuyến
                       ("cty.vn/api → api-service:80")
                       → Chỉ là tờ giấy. Tnó KHÔNG làm gì cả.

   Ingress Controller  một Pod thật (nginx/traefik/HAProxy) ĐỌC các luật đó
                       và thực thi: nhận request, đọc Host/Path, chuyển tiếp.
                       → Đây mới là thứ chạy. Không cài nó = Ingress vô dụng.

⚠️ Tạo Ingress mà chưa cài Controller → không có gì xảy ra, và K8s cũng không báo lỗi. Pitfall kinh điển của người mới. (Ta đã cài ingress-nginx ở phần capstone — bài sau.)

                     Internet
                        │
                        ▼  1 IP duy nhất (từ MetalLB / cloud LB)
              ┌──────────────────────┐
              │  Ingress Controller   │  ← Pod nginx thật, đọc luật
              │  (đọc Host + Path)    │     + kết thúc TLS tại đây
              └──────────┬───────────┘
        ┌────────────────┼────────────────┐
        ▼                ▼                ▼
   cty.vn/          cty.vn/api      admin.cty.vn
        │                │                │
   web-service      api-service     admin-service   ← toàn ClusterIP (Bài 04)
        │                │                │
      Pods             Pods             Pods

Một IP, một cổng 443, phục vụ vô số app. Và TLS chỉ cần cấu hình một chỗ — tại Ingress Controller.


Định tuyến: host và path

Theo domain (host-based)

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: multi-host
  namespace: myapp
spec:
  ingressClassName: nginx          # ← controller nào xử lý (BẮT BUỘC ghi rõ)
  rules:
    - host: cty.vn
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-service
                port: { number: 80 }
    - host: api.cty.vn             # domain khác → service khác
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-service
                port: { number: 80 }

Theo đường dẫn (path-based)

  rules:
    - host: cty.vn
      http:
        paths:
          - path: /api             # cty.vn/api/...  → api-service
            pathType: Prefix
            backend:
              service: { name: api-service, port: { number: 80 } }
          - path: /admin           # cty.vn/admin/... → admin-service
            pathType: Prefix
            backend:
              service: { name: admin-service, port: { number: 80 } }
          - path: /                # còn lại → web-service (đặt CUỐI)
            pathType: Prefix
            backend:
              service: { name: web-service, port: { number: 80 } }

pathType — đừng chọn bừa

   Prefix    khớp theo ĐOẠN đường dẫn:  /api khớp /api, /api/v1, /api/users
                                         (KHÔNG khớp /apixyz — khớp theo đoạn!)
   Exact     khớp CHÍNH XÁC:            /api chỉ khớp đúng /api
   ImplementationSpecific  tùy controller diễn giải (tránh — khó đoán)

IngressClass — ai xử lý luật này

spec:
  ingressClassName: nginx      # nginx-ingress xử lý
  # ingressClassName: traefik  # nếu bạn dùng Traefik

⚠️ Cluster có thể chạy nhiều controller (vd nginx cho public, một cái khác cho internal). Không ghi ingressClassName → có thể không controller nào nhận, Ingress nằm im. Luôn ghi rõ.


Annotations — nơi sức mạnh thật nằm

Bản thân object Ingress khá nghèo nàn. Mọi tính năng nâng cao đến từ annotation (mỗi controller có bộ riêng — dưới đây là của ingress-nginx):

metadata:
  annotations:
    # Ép HTTPS: ai vào http:// sẽ bị chuyển sang https://
    nginx.ingress.kubernetes.io/ssl-redirect: "true"

    # Viết lại đường dẫn: /api/users → gửi tới backend thành /users
    nginx.ingress.kubernetes.io/rewrite-target: /$2

    # Cho upload file lớn (mặc định nginx chỉ 1MB → lỗi 413!)
    nginx.ingress.kubernetes.io/proxy-body-size: "50m"

    # Timeout cho request chạy lâu
    nginx.ingress.kubernetes.io/proxy-read-timeout: "300"

    # Giới hạn tốc độ — chống lạm dụng (request/giây mỗi IP)
    nginx.ingress.kubernetes.io/limit-rps: "10"

    # Backend chạy HTTPS (không phải HTTP)
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"

    # CORS
    nginx.ingress.kubernetes.io/enable-cors: "true"

rewrite-target — cái bẫy hay gặp nhất

Người mới hay viết /api rồi ngạc nhiên vì backend nhận cả /api/users thay vì /users:

metadata:
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2    # $2 = nhóm bắt thứ 2
spec:
  rules:
    - host: cty.vn
      http:
        paths:
          - path: /api(/|$)(.*)          # regex: bắt phần sau /api
            pathType: ImplementationSpecific
            backend:
              service: { name: api-service, port: { number: 80 } }

cty.vn/api/users → backend nhận /users. Không có rewrite, backend nhận nguyên /api/users và thường trả 404.


TLS — HTTPS trong Ingress

TLS được kết thúc (terminate) tại Ingress Controller: client ↔ Ingress là HTTPS mã hóa; Ingress ↔ Pod thường là HTTP trong mạng nội bộ cluster.

   Client ══HTTPS (mã hóa)══► Ingress Controller ──HTTP──► Pod
                              (giải mã ở đây)      (mạng nội bộ)

Chứng chỉ nằm trong một Secret loại kubernetes.io/tls (nhớ Bài 05 — loại Secret chuyên dụng):

kubectl create secret tls cty-vn-tls \
  --cert=fullchain.pem --key=privkey.pem \
  -n myapp
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - cty.vn
        - api.cty.vn
      secretName: cty-vn-tls       # ← Secret chứa cert + key
  rules:
    - host: cty.vn
      ...

⚠️ Secret TLS phải nằm CÙNG namespace với Ingress. Ingress ở ns myapp không dùng được Secret ở ns default (đúng luật namespaced của Bài 04/05). Lỗi này khiến Ingress im lặng phục vụ cert mặc định (self-signed) → trình duyệt báo đỏ.


cert-manager — chứng chỉ tự xin, tự gia hạn

Tạo Secret TLS bằng tay nghĩa là: xin cert → copy vào cluster → 90 ngày sau làm lại (Let's Encrypt chỉ sống 90 ngày). Quên một lần là web đỏ chót cảnh báo bảo mật.

cert-manager tự động hóa toàn bộ: xin chứng chỉ từ Let's Encrypt, tạo Secret, và tự gia hạn trước khi hết hạn — vĩnh viễn, không cần bạn nhớ.

Cài

helm repo add jetstack https://charts.jetstack.io
helm repo update
helm install cert-manager jetstack/cert-manager \
  -n cert-manager --create-namespace \
  --set crds.enabled=true

💡 cert-manager cài các CRD (Custom Resource Definition) — nó mở rộng K8s bằng loại tài nguyên mới (Certificate, ClusterIssuer). Đây là cách hệ sinh thái K8s lớn lên: không sửa K8s, mà thêm loại object mới + một controller canh chúng (đúng reconciliation loop của Bài 01).

ClusterIssuer — "ai cấp chứng chỉ cho tôi"

apiVersion: cert-manager.io/v1
kind: ClusterIssuer            # ClusterIssuer = dùng được ở MỌI namespace
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@cty.vn        # LE gửi cảnh báo hết hạn vào đây
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:                # chứng minh sở hữu domain qua HTTP
          ingress:
            class: nginx

⚠️ Luôn thử với letsencrypt-staging trước (https://acme-staging-v02.api.letsencrypt.org/directory). Let's Encrypt giới hạn 5 lần cấp/domain/tuần — sai cấu hình vài lần trên prod issuer là bạn bị khóa cả tuần. Staging không giới hạn (cert không được trình duyệt tin, nhưng đủ để kiểm chứng luồng).

HTTP-01 vs DNS-01

   HTTP-01   LE gi http://cty.vn/.well-known/acme-challenge/xxx để xác thựcCn cng 80 MRA INTERNET, domain trỏ đúng vIngress.
             → ❌ KHÔNG cp được wildcard (*.cty.vn)
             → Đơn gin nhtdùng khi web public.

   DNS-01    cert-manager tto bn ghi TXT trên DNS để chng minh shuCn credential API ca nhà cung cp DNS (Cloudflare, Route53...)
             → ✅ Cp được WILDCARD (*.cty.vn)
             → ✅ Chy được ckhi cluster KHÔNG public (internal)

Xin cert: chỉ cần một annotation

Phần đẹp nhất — bạn không phải tạo Certificate bằng tay. Chỉ thêm annotation vào Ingress:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: myapp
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod    # ⭐ dòng ma thuật
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts: [cty.vn]
      secretName: cty-vn-tls        # cert-manager sẽ TỰ TẠO Secret này
  rules:
    - host: cty.vn
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service: { name: web-service, port: { number: 80 } }
   Bạn apply Ingress
        │
        ▼
   cert-manager thấy annotation → ttạo Certificate → gọi Let's Encrypt
        │
        ▼
   Giải bài toán HTTP-01 (tạo Pod + Ingress tạm cho đường /.well-known/...)
        │
        ▼
   LE cấp cert → cert-manager LƯU vào Secret "cty-vn-tls"
        │
        ▼
   Ingress Controller nạp cert → https://cty.vn xanh lá 🔒
        │
        ▼
   ~30 ngày TRƯỚC hạn: cert-manager TỰ GIA HẠN. Mãi mãi. Bạn không phải nhớ gì.
kubectl get certificate -n myapp        # READY: True là xong
kubectl describe certificate cty-vn-tls -n myapp   # debug nếu chưa Ready
kubectl get certificaterequest,order,challenge -A  # soi từng bước ACME

🚀 Lab — Ingress + TLS trên kind

1. Cluster kind có cổng vào

cat <<EOF | kind create cluster --name ing --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    extraPortMappings:
      - { containerPort: 80,  hostPort: 80,  protocol: TCP }
      - { containerPort: 443, hostPort: 443, protocol: TCP }
EOF

# Cài ingress-nginx (bản dành cho kind)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
kubectl wait --namespace ingress-nginx --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller --timeout=120s

2. Hai app, định tuyến theo path

kubectl create deployment web --image=nginx --port=80
kubectl expose deployment web --port=80
kubectl create deployment api --image=hashicorp/http-echo \
  -- /http-echo -text="xin chao tu API" -listen=:80
kubectl expose deployment api --port=80

cat <<EOF | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo
spec:
  ingressClassName: nginx
  rules:
    - host: demo.local
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend: { service: { name: api, port: { number: 80 } } }
          - path: /
            pathType: Prefix
            backend: { service: { name: web, port: { number: 80 } } }
EOF

curl -H "Host: demo.local" http://localhost/          # → trang nginx (web)
curl -H "Host: demo.local" http://localhost/api       # → "xin chao tu API" ✨
# → MỘT cổng vào, hai app, phân biệt bằng đường dẫn.

3. TLS bằng cert tự ký (không cần Internet)

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout tls.key -out tls.crt -subj "/CN=demo.local"

kubectl create secret tls demo-tls --cert=tls.crt --key=tls.key

kubectl patch ingress demo --type=merge -p \
  '{"spec":{"tls":[{"hosts":["demo.local"],"secretName":"demo-tls"}]}}'

curl -k -H "Host: demo.local" https://localhost/      # HTTPS chạy 🔒

4. cert-manager (luồng thật — cần domain public)

helm repo add jetstack https://charts.jetstack.io && helm repo update
helm install cert-manager jetstack/cert-manager -n cert-manager \
  --create-namespace --set crds.enabled=true
kubectl get pods -n cert-manager

# ClusterIssuer STAGING (luôn thử staging trước!)
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata: { name: letsencrypt-staging }
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: admin@cty.vn
    privateKeySecretRef: { name: letsencrypt-staging-key }
    solvers:
      - http01: { ingress: { class: nginx } }
EOF
kubectl get clusterissuer      # READY True
# (Bước xin cert thật cần domain public trỏ về cluster — làm ở cluster on-prem
#  của bài sau, thêm annotation cert-manager.io/cluster-issuer vào Ingress.)

5. Dọn

kind delete cluster --name ing

Câu chuyện thực tế

Ba sự cố Ingress, ba bài học.

Chứng chỉ hết hạn lúc 2 giờ sáng. Năm đầu, chúng tôi xin cert Let's Encrypt bằng tay, copy vào Secret. Ai đó đặt nhắc lịch 90 ngày sau. Rồi người đó đổi việc. Ngày thứ 91, một sáng thứ Bảy, toàn bộ web hiện cảnh báo đỏ "kết nối không an toàn". Khách hoảng, gọi tổng đài, tưởng bị hack. Chúng tôi mất hơn một tiếng để xin cert mới và deploy (còn phải tỉnh ngủ đã). Sau vụ đó tôi cài cert-manager trong nửa buổi — và từ đó không bao giờ phải nghĩ về chứng chỉ nữa. Nó tự gia hạn, im lặng, mỗi 60 ngày, suốt nhiều năm.

Bị Let's Encrypt khóa một tuần. Lần đầu dùng cert-manager, tôi cấu hình sai solver và cứ apply đi apply lại trên issuer production. Sau 5 lần thất bại, LE trả về too many certificates already issuedkhóa domain đó một tuần. Không xin thêm cert được, dù đã sửa đúng. Bài học đắt: luôn dùng letsencrypt-staging cho tới khi thấy Certificate READY: True, rồi mới đổi sang prod issuer.

Lỗi 413 bí ẩn. Người dùng báo không upload được ảnh > 1MB, mà log app sạch trơn — vì request chưa bao giờ tới app. Thủ phạm: nginx-ingress mặc định giới hạn body 1MB, và nó từ chối ngay tại cổng. Thêm một dòng proxy-body-size: "50m" là xong. Bài học: Ingress là một tầng riêng — bug có thể nằm ở đó, không phải ở app. Khi log app trống trơn mà client vẫn lỗi, hãy soi Ingress Controller.

Bài học:

  1. Chứng chỉ thủ công là bom hẹn giờ 90 ngày. Con người sẽ quên — cert-manager thì không. Nửa buổi cài, đổi lấy sự yên tâm nhiều năm.

  2. Staging issuer trước, prod issuer sau. LE giới hạn 5 cert/domain/tuần. Sai vài lần trên prod = khóa một tuần, không cứu được.

  3. Ingress là một tầng, và bug có thể ở tầng đó. Log app trống mà client vẫn lỗi (413, 502, 504) → soi Ingress Controller và annotation trước.

  4. Một Ingress Controller thay cho N LoadBalancer. Tiết kiệm IP/tiền, và TLS chỉ cấu hình một chỗ thay vì mỗi app một lần.

  5. kubectl describe certificate là bạn. Khi cert không Ready, chuỗi Certificate → CertificateRequest → Order → Challenge cho biết chính xác kẹt ở đâu (thường là DNS chưa trỏ, hoặc cổng 80 bị chặn).


Pitfalls

  1. Tạo Ingress mà chưa cài Ingress Controller → không có gì xảy ra, không lỗi. Phải cài controller (nginx/traefik).

  2. Quên ingressClassName → không controller nào nhận luật, Ingress nằm im. Luôn ghi rõ.

  3. Secret TLS khác namespace với Ingress → Ingress phục vụ cert mặc định (self-signed), trình duyệt báo đỏ. Phải cùng namespace.

  4. Dùng thẳng letsencrypt-prod khi đang thử → sai vài lần là bị rate limit khóa cả tuần. Staging trước.

  5. HTTP-01 mà cổng 80 không mở ra Internet / DNS chưa trỏ đúng → challenge fail mãi. Cluster nội bộ hoặc cần wildcard → phải dùng DNS-01.

  6. Xin wildcard (*.cty.vn) bằng HTTP-01 → không bao giờ được. Wildcard bắt buộc DNS-01.

  7. Quên rewrite-target → backend nhận cả tiền tố (/api/users thay vì /users) → 404 khó hiểu.

  8. Lỗi 413 khi uploadproxy-body-size mặc định 1MB. Tăng lên.

  9. Thứ tự path sai — đặt path: / (Prefix) trước /api → mọi request rơi vào /. Đặt path cụ thể trước, / cuối cùng.

  10. Backend chạy HTTPS mà không khai backend-protocol: HTTPS → Ingress gửi HTTP tới cổng HTTPS → 502.

  11. Tưởng TLS được mã hóa suốt tới Pod. Không — mặc định TLS kết thúc ở Ingress; đoạn Ingress→Pod là HTTP nội bộ. Cần mã hóa cả chặng trong → service mesh (mTLS).

  12. Quên ssl-redirect → người dùng vẫn vào được http:// không mã hóa. Bật để ép sang HTTPS.


Tóm tắt

  • LoadBalancer/NodePort = tầng 4 (chỉ IP+cổng). Web cần định tuyến theo domain và pathtầng 7Ingress.
  • Ingress = luật; Ingress Controller = Pod thực thi. Không cài controller → Ingress vô dụng (và không báo lỗi).
  • Một IP + một cổng 443 phục vụ vô số app; TLS cấu hình một chỗ.
  • Định tuyến: host-based, path-based; pathType (Prefix / Exact); ingressClassName (bắt buộc ghi).
  • Annotations là nơi sức mạnh nằm: ssl-redirect, rewrite-target, proxy-body-size (413!), limit-rps, backend-protocol, CORS.
  • TLS: Secret loại kubernetes.io/tls, khai trong spec.tls, phải cùng namespace với Ingress. TLS kết thúc tại Controller.
  • cert-manager: ClusterIssuer (Let's Encrypt) + một annotation cert-manager.io/cluster-issuer → cert tự xin, tự tạo Secret, tự gia hạn mãi mãi.
  • ⚠️ Staging issuer trước (LE giới hạn 5 cert/domain/tuần). HTTP-01 (đơn giản, cần cổng 80 public, không wildcard) vs DNS-01 (cần API DNS, wildcard, chạy được cả cluster nội bộ).
  • Debug: kubectl describe certificate → chuỗi CertificateRequest → Order → Challenge.

Bài sau là đỉnh của chặng: Bài 10 — Capstone. Ta gom toàn bộ 9 bài — Pod, Deployment, Service, Namespace, ConfigMap/Secret, Storage/StatefulSet, Job/CronJob, hàng rào bảo mật, và Ingress/TLS vừa học — để tự tay dựng cluster on-prem 3 node bằng kubeadm (containerd → CNI → MetalLB → Ingress → Storage), rồi migrate cả stack từ Docker Compose lên K8s hoàn chỉnh.

Nếu hôm nay bạn không bao giờ phải đặt lịch nhắc gia hạn chứng chỉ nữa — cert-manager đã trả công cho nửa buổi bạn bỏ ra, và sẽ còn trả tiếp trong nhiều năm.

Minh Hưng

← Bài trước
Bảo mật & giới hạn tài nguyên: chạy an toàn, chạy công bằng
Bài tiếp theo →
Capstone: Dựng cluster on-prem & đưa Compose lên K8s
Zalo tư vấn