Bảo mật & giới hạn tài nguyên: chạy an toàn, chạy công bằng

Bảy bài qua bạn đã chạy được gần như mọi thứ trên K8s. Nhưng "chạy được" và "chạy đủ an toàn để lên production" là hai chuyện. Mặc định, K8s dễ dãi một…

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

Bảy bài qua bạn đã chạy được gần như mọi thứ trên K8s. Nhưng "chạy được" và "chạy đủ an toàn để lên production" là hai chuyện. Mặc định, K8s dễ dãi một cách nguy hiểm:

  • Container chạy bằng root. Một lỗ hổng trong app → kẻ tấn công có quyền root trong container, và từ đó tìm đường ra node.
  • Container ăn vô hạn CPU/RAM nếu bạn không giới hạn. Một Pod rò bộ nhớ có thể kéo sập cả node, lôi theo mọi Pod hàng xóm.
  • Mọi Pod nói chuyện được với mọi Pod (Bài 04). Một Pod bị chiếm có thể quét và tấn công ngang sang database, internal API...

Bài này dựng ba lớp hàng rào cho ba nỗi đau đó: SecurityContext & Pod Security (chạy quyền tối thiểu), QoS & LimitRange (chia tài nguyên công bằng), NetworkPolicy (tường lửa giữa các Pod). Đây là bài biến cluster "chạy được" thành cluster "dám giao production".


Vấn đề: mặc định là mở toang

   ❌ Container chạy root:
      Nhầu hết image chạy process bằng UID 0 (root).
      App bị RCE → attacker là root trong container → dễ thoát ra host.

   ❌ Không giới hạn tài nguyên:
      1 Pod rò RAM ăn hết bộ nhớ node → kernel OOM → GIẾT bừa các Pod khác
      trên cùng node → sự cố lan rộng (noisy neighbor).

   ❌ Mạng phẳng:
      Pod frontend bị chiếm → nói thẳng được với Pod database (không firewall)
      → di chuyển ngang (lateral movement), leo thang tn công.

Ba lỗ này đều "tàng hình" khi cluster nhỏ và tin cậy — rồi bùng lên đúng lúc bạn lên production thật. Ta bịt lần lượt.


Lớp 1 — SecurityContext: chạy quyền tối thiểu

SecurityContext khai báo Pod/container chạy với quyền gì. Nguyên tắc xuyên suốt: least privilege — cho ít nhất có thể.

spec:
  securityContext:                 # cấp POD (áp cho mọi container)
    runAsNonRoot: true             # CẤM chạy root — thất bại nếu image cố chạy root
    runAsUser: 1000                # chạy bằng UID 1000
    runAsGroup: 3000
    fsGroup: 2000                  # nhóm sở hữu volume mount
  containers:
    - name: app
      image: myapp:1.0
      securityContext:             # cấp CONTAINER (đè cấp Pod)
        allowPrivilegeEscalation: false   # cấm leo quyền (sudo/setuid)
        readOnlyRootFilesystem: true      # filesystem chỉ ĐỌC — app không ghi bừa
        capabilities:
          drop: ["ALL"]            # bỏ HẾT Linux capabilities
          add: ["NET_BIND_SERVICE"]  # chỉ thêm lại cái thật sự cần (bind cổng <1024)

Giải nghĩa từng lá chắn:

   runAsNonRoot: true          container KHÔNG được là root. Lá chắn số 1.
   readOnlyRootFilesystem      app không ghi được vào hệ thống file gốc
                               → malware khó cắm rễ. (Cần ghi tạm? mount emptyDir.)
   allowPrivilegeEscalation:   chặn tiến trình con giành thêm quyền
     false
   capabilities: drop ALL      bỏ mọi "siêu quyền" Linux, chỉ add lại cái cần
   privileged: true            ⚠️ TUYỆT ĐỐI TRÁNH — container = gần như root
                               trên host. Chỉ dùng cho agent hệ thống rất đặc biệt.

⚠️ readOnlyRootFilesystem: true khiến app cần ghi tạm (log, cache, /tmp) sẽ lỗi — mount một emptyDir (Bài 05) vào đúng thư mục cần ghi để vừa an toàn vừa chạy được.

💡 Đây chính là bản K8s của bài học chặng Docker: "đừng chạy container bằng root, dùng USER trong Dockerfile". SecurityContext ép điều đó ở tầng cluster, kể cả khi image quên đặt USER.


Lớp 1.5 — Pod Security Standards: ép cả namespace

SecurityContext là tự nguyện — dev quên đặt thì Pod vẫn chạy root. Pod Security Standards (PSS) + Pod Security Admission (PSA) ép luật ở cấp namespace, chặn ngay lúc tạo Pod.

Ba mức chuẩn:

   privileged   không giới hạn gì — cho workload hệ thống (CNI, agent)
   baseline     chặn các cấu hình nguy hiểm rõ ràng (privileged, hostNetwork...)
   restricted   siết mạnh: bắt buộc runAsNonRoot, drop capabilities,
                readOnlyRootFilesystem... — chuẩn cho app thường

Bật bằng cách gắn label lên namespace:

# Namespace "prod": ÉP mức restricted — Pod vi phạm bị TỪ CHỐI ngay
kubectl label namespace prod \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted
   enforce=restricted   Pod vi phạm → TỪ CHỐI tạo (chặn cứng)
   warn=restricted      cho tạo nhưng CẢNH BÁO (hợp giai đoạn chuyển đổi)
   audit=restricted     ghi log vi phạm để rà soát

→ Sau lệnh này, ai kubectl apply một Pod chạy root vào ns prod sẽ bị chặn thẳng, kèm thông báo vi phạm điều gì. Không còn phụ thuộc dev có nhớ đặt SecurityContext hay không.

PSS thay thế PodSecurityPolicy (đã bị gỡ từ K8s 1.25). Nếu đọc tài liệu cũ thấy PSP, hãy quên nó đi — giờ là PSA/PSS.


Lớp 2 — Chia tài nguyên công bằng

Nhớ requests/limits ở Bài 02? Giờ ta thấy hệ quả sâu của chúng: QoS classthứ tự bị giết khi node hết RAM.

QoS class — ai bị hy sinh trước

K8s tự xếp mỗi Pod vào một hạng dựa trên requests/limits:

   Guaranteed   requests == limits (cho cả CPU & RAM)
                → hạng cao nhất, BỊ GIẾT SAU CÙNG khi node thiếu RAM
                → cho app quan trọng (DB, payment)

   Burstable    có requests nhưng < limits (hoặc chỉ đặt 1 phần)
                → hạng giữa

   BestEffort   KHÔNG đặt requests/limits gì cả
                → hạng bét, BỊ GIẾT ĐẦU TIÊN khi node cạn RAM
                → đừng để app production rơi vào hạng này!
   Node hết RAM → kubelet "trục xuất" (evict) Pod theo thứ t:
      BestEffort  →  Burstable (vượt requests nhiều nhất)  →  Guaranteed
      (bị giết trước)                                          (an toàn nhất)

⚠️ Không đặt requests/limits = Pod rơi vào BestEffort = ứng viên số một bị giết khi node căng. App production quan trọng nên đặt requests == limits để lên Guaranteed.

LimitRange — mặc định cho cả namespace

Bắt mọi dev nhớ đặt requests/limits là không thực tế. LimitRange đặt giá trị mặc định cho namespace — Pod không khai thì tự nhận:

apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: dev
spec:
  limits:
    - type: Container
      default:                    # limit mặc định nếu Pod không khai
        cpu: "500m"
        memory: "256Mi"
      defaultRequest:             # request mặc định nếu Pod không khai
        cpu: "100m"
        memory: "128Mi"
      max:                        # trần: Pod không được xin quá mức này
        cpu: "2"
        memory: "2Gi"

Ghép với ResourceQuota (Bài 04 — trần tổng cho cả namespace) ta có bộ đôi hoàn chỉnh:

   LimitRange    → luật cho TỪNG Pod/container (mặc định + trần mỗi cái)
   ResourceQuota → trần TỔNG cho cả namespace (tổng CPU/RAM/số Pod)
   → cùng nhau: không Pod nào quá tham, và cả team không vượt khẩu phần.

Lớp 3 — NetworkPolicy: tường lửa giữa các Pod

Bài 04 giới thiệu; giờ làm thật. Mặc định mạng K8s phẳng — mọi Pod gọi được mọi Pod. NetworkPolicy là firewall tầng Pod, chọn lọc bằng label (Bài 03).

⚠️ Điều kiện tiên quyết: NetworkPolicy chỉ có tác dụng nếu CNI hỗ trợ (Calico, Cilium — Flannel cơ bản thì bỏ qua, luật vẫn tạo nhưng không thực thi). Nhớ chọn CNI phù hợp ở Bài 01/09.

Mẫu quan trọng nhất: default-deny

Chiến lược đúng là "cấm hết, rồi mở có chọn lọc":

# 1. CẤM mọi traffic vào các Pod trong namespace (nền tảng zero-trust)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-ingress
  namespace: prod
spec:
  podSelector: {}                  # áp cho MỌI Pod trong ns
  policyTypes: [Ingress]
  # không có rule ingress nào → cấm hết vào
# 2. Rồi MỞ có chọn lọc: chỉ frontend được gọi database cổng 3306
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: db-allow-frontend
  namespace: prod
spec:
  podSelector:
    matchLabels: { app: database }   # luật này áp cho Pod database
  policyTypes: [Ingress]
  ingress:
    - from:
        - podSelector:
            matchLabels: { app: frontend }   # CHỈ frontend được vào
      ports:
        - { protocol: TCP, port: 3306 }
   Trước:  frontend ─┐
           attacker ─┼──► database   (ai cũng gọi được — nguy hiểm)
           any-pod  ─┘

   Sau default-deny + allow:
           frontend ────► database   ✅ (được phép)
           attacker ──╳   database   ❌ (bị firewall chặn)

NetworkPolicy kiểm soát cả ingress (traffic vào) và egress (traffic ra — vd cấm Pod gọi ra Internet ngoài các đích cho phép). Đây là nền của kiến trúc zero-trust trong cluster.


Vài lá chắn nữa nên biết

  • Tắt tự mount token ServiceAccount nếu Pod không cần gọi API Server: automountServiceAccountToken: false → giảm bề mặt tấn công.
  • Image an toàn: đừng dùng tag :latest (không tái lập, khó rollback — nhớ Bài 03); scan lỗ hổng image (Trivy); kéo từ registry tin cậy; ưu tiên image nhỏ (distroless/alpine) để ít lỗ hổng.
  • RBAC tối thiểu (Bài 04): đừng phát cluster-admin bừa. Tài khoản app dùng ServiceAccount quyền hẹp.
  • Đừng nhét secret vào ConfigMap / image (Bài 05): dùng Secret + encryption-at-rest.

→ Bảo mật K8s là nhiều lớp mỏng chồng lên nhau (defense in depth), không phải một viên đạn bạc. Mỗi lớp trong bài này chặn một loại tấn công khác nhau.


🚀 Lab — hàng rào an toàn & tài nguyên

Dùng cluster kind.

1. Pod chạy non-root, filesystem chỉ đọc

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata: { name: secure-pod }
spec:
  securityContext: { runAsNonRoot: true, runAsUser: 1000 }
  containers:
    - name: app
      image: nginxinc/nginx-unprivileged:1.25    # image thiết kế chạy non-root
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities: { drop: ["ALL"] }
      volumeMounts: [{ name: tmp, mountPath: /tmp }]   # emptyDir cho chỗ cần ghi
  volumes: [{ name: tmp, emptyDir: {} }]
EOF
kubectl exec secure-pod -- id       # uid=1000 — KHÔNG phải root ✅

2. Pod Security ép cả namespace

kubectl create namespace secured
kubectl label namespace secured pod-security.kubernetes.io/enforce=restricted

# Thử tạo Pod root trong ns restricted → BỊ TỪ CHỐI
kubectl -n secured run bad --image=nginx
# Error ... violates PodSecurity "restricted": ... runAsNonRoot != true ...
# → PSA chặn ngay, không phụ thuộc dev có nhớ SecurityContext hay không.

3. LimitRange đặt mặc định

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: LimitRange
metadata: { name: defaults, namespace: default }
spec:
  limits:
    - type: Container
      default: { cpu: "300m", memory: "256Mi" }
      defaultRequest: { cpu: "100m", memory: "128Mi" }
EOF
kubectl run nolimits --image=nginx
kubectl get pod nolimits -o jsonpath='{.spec.containers[0].resources}'
# → thấy requests/limits TỰ được gán dù ta không khai! (QoS Burstable)

4. Quan sát QoS class

kubectl get pod nolimits -o jsonpath='{.status.qosClass}'    # Burstable
# Pod không đặt gì (nếu không có LimitRange) → BestEffort → bị giết đầu tiên.

5. Dọn

kubectl delete pod secure-pod nolimits 2>/dev/null
kubectl delete namespace secured 2>/dev/null
kubectl delete limitrange defaults 2>/dev/null

→ Khoảnh khắc "à há" ở bước 2: dù dev cố tình tạo Pod root, namespace restricted chặn thẳng — an ninh không còn dựa vào "hy vọng dev nhớ", mà được ép ở tầng cluster.


Câu chuyện thực tế

Hai lần cluster dạy tôi rằng "mặc định dễ dãi" là kẻ thù thầm lặng.

Lần một — noisy neighbor. Một service xử lý ảnh có rò bộ nhớ nhẹ, và không ai đặt limits (nó rơi vào QoS BestEffort). Bình thường không sao. Một hôm nó nhận loạt ảnh lớn, RAM phình vọt, ăn sạch bộ nhớ node. Kernel OOM nhảy vào giết Pod để cứu node — nhưng nó giết cả các Pod hàng xóm vô can trên cùng node, trong đó có một Pod API quan trọng. Sự cố lan từ một service phụ sang service chính, chỉ vì thiếu vài dòng limits. Chúng tôi áp LimitRange cho mọi namespace ngay sau đó: không Pod nào được chạy "vô hạn" nữa, và app quan trọng đặt requests == limits để lên Guaranteed — được bảo vệ khỏi bị trục xuất.

Lần hai — mạng phẳng. Trong một buổi diễn tập bảo mật nội bộ, người kiểm thử "chiếm" được một Pod frontend (giả lập một lỗ hổng web). Từ Pod đó, họ nslookup ra mysql-service, rồi kết nối thẳng vào database — không có gì chặn, vì mạng K8s mặc định phẳng. Trong 15 phút, họ chứng minh có thể đọc dữ liệu nhạy cảm. Không ai bị mất mát thật (là diễn tập), nhưng bài học rõ ràng. Chúng tôi áp default-deny NetworkPolicy cho các namespace nhạy cảm, rồi mở đúng đường cần (frontend→api, api→db). Lần diễn tập sau, Pod frontend bị chiếm không còn "sờ" được database — firewall tầng Pod chặn đứng.

Bài học:

  1. Container non-root là mặc định đúng. runAsNonRoot: true + drop capabilities + readOnlyRootFilesystem. Đây là bản K8s của "đừng chạy Docker bằng root".

  2. Ép an ninh ở namespace, đừng trông vào dev nhớ. SecurityContext tự nguyện thì sẽ có người quên. Pod Security enforce=restricted chặn ở cửa.

  3. Không đặt limits = Pod BestEffort = mồi bị giết đầu tiên. Và tệ hơn, một Pod tham kéo sập hàng xóm. LimitRange cho mọi namespace là bắt buộc, không phải tùy chọn.

  4. requests == limits cho app quan trọng → QoS Guaranteed → sống sót lâu nhất khi node căng.

  5. Mạng phẳng là mời gọi lateral movement. default-deny NetworkPolicy rồi mở có chọn lọc là nền zero-trust. (Nhớ chọn CNI hỗ trợ — Calico/Cilium.)

  6. Bảo mật là nhiều lớp mỏng. Không lớp nào đủ một mình; SecurityContext + PSA + NetworkPolicy + RBAC + image sạch cùng nhau mới thành hàng rào thật.


Pitfalls

  1. Container chạy root vì không đặt SecurityContext. App bị RCE → nguy cơ thoát ra host. Đặt runAsNonRoot: true.

  2. Dựa vào SecurityContext tự nguyện. Dev quên là Pod root vẫn chạy. Ép bằng Pod Security enforce ở namespace.

  3. Không đặt requests/limits → Pod BestEffort, bị evict đầu tiên, và có thể kéo sập node. Dùng LimitRange.

  4. readOnlyRootFilesystem: true mà app cần ghi → app lỗi. Mount emptyDir vào thư mục cần ghi (/tmp, cache).

  5. Dùng privileged: true cho tiện → container gần như root trên host. Gần như không bao giờ nên dùng.

  6. NetworkPolicy trên CNI không hỗ trợ (Flannel cơ bản) → luật tạo ra nhưng không thực thi, tạo cảm giác an toàn giả. Dùng Calico/Cilium.

  7. Áp default-deny mà quên mở CoreDNS/egress → Pod không phân giải được DNS, "tự nhiên" hỏng hết. Nhớ cho phép egress tới kube-system/DNS.

  8. Dùng tag :latest → không tái lập, không rollback được, và có thể kéo bản mới dính lỗ hổng. Luôn pin version.

  9. requests quá cao so với thực tế → lãng phí, scheduler không xếp nổi Pod (Pending) dù node còn trống thật. Đo rồi đặt sát.

  10. Quên tắt automount token SA cho Pod không cần API → token đó lộ là mở đường vào cluster. automountServiceAccountToken: false.

  11. ResourceQuota bật mà Pod không đặt requests → Pod bị từ chối tạo (quota bắt buộc khai báo). Ghép ResourceQuota với LimitRange để có mặc định.


Tóm tắt

  • Mặc định K8s dễ dãi nguy hiểm: container root, tài nguyên vô hạn, mạng phẳng. Production phải dựng hàng rào.
  • SecurityContext (least privilege): runAsNonRoot, readOnlyRootFilesystem, allowPrivilegeEscalation: false, capabilities: drop ALL. Tránh privileged.
  • Pod Security Standards (privileged/baseline/restricted) ép qua label namespace (enforce/warn/audit) — chặn Pod vi phạm ở cửa. Thay cho PodSecurityPolicy cũ.
  • QoS class: Guaranteed (requests==limits, giết sau cùng), Burstable, BestEffort (không đặt gì, giết đầu tiên). App quan trọng → Guaranteed.
  • LimitRange (mặc định + trần mỗi container) + ResourceQuota (trần tổng namespace) = chia tài nguyên công bằng.
  • NetworkPolicy: default-deny rồi mở chọn lọc (zero-trust). Cần CNI hỗ trợ (Calico/Cilium). Kiểm soát cả ingress lẫn egress.
  • Thêm: tắt automount token SA, image sạch (không :latest, scan, registry tin cậy), RBAC tối thiểu.
  • Defense in depth — nhiều lớp mỏng, không viên đạn bạc.

Bài sau (Bài 09 — Ingress & TLS/HTTPS) lấp lỗ hổng cuối cùng của phần nền tảng. Bài 04 mới giới thiệu Ingress; giờ ta làm thật: định tuyến theo domain và path, các annotation hay dùng, và quan trọng nhất — HTTPS tự động bằng cert-manager + Let's Encrypt, để chứng chỉ tự xin và tự gia hạn thay vì hết hạn lúc 2 giờ sáng.

Nếu hôm nay bạn nhìn một Pod chạy root và thấy nó "sai", biết đặt LimitRange để một Pod tham không kéo sập hàng xóm, và hiểu vì sao default-deny là nền zero-trust — bạn đã có tư duy vận hành K8s an toàn.

Minh Hưng

← Bài trước
Job, CronJob & workload đặc biệt: việc chạy-rồi-xong
Bài tiếp theo →
Ingress & TLS: một cổng vào, HTTPS tự động
Zalo tư vấn