OOMKilled — Pod bị kill dù RAM còn thừa

Phong Hy

Pod chết, mà node vẫn dư RAM

Cảnh này ai xài Kubernetes cũng gặp: kubectl describe pod ghi OOMKilled, exit code 137, nhưng kubectl top node báo node còn 6GB RAM rảnh. Vậy ai giết pod?

Không phải scheduler. Là kernel, thông qua cgroup. Node còn RAM chẳng có nghĩa gì nếu cgroup của container đã chạm trần.

Request vs Limit — hai thứ khác nhau hoàn toàn

  • requests: chỉ để scheduler biết xếp pod lên node nào. Không giới hạn gì cả.
  • limits: ghi thẳng vào cgroup.memory.max (cgroup v2). Vượt là bị kill ngay, không thương lượng.

Nên chuyện "node còn RAM" và "container bị kill" là hai chuyện độc lập. Trần nằm ở container, không nằm ở node.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
spec:
  template:
    spec:
      containers:
        - name: api
          image: ghcr.io/phonghy/payment-api:1.4.2
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "1000m"
          env:
            - name: GOMEMLIMIT
              value: "400MiB"
            - name: GOGC
              value: "100"

Cái bẫy: runtime ngôn ngữ giữ heap lớn hơn bạn nghĩ

Go, Java, Node đều không trả RAM về OS ngay. Go GC mặc định (GOGC=100) chỉ chạy khi heap tăng 100% so với lần trước, và RSS thường cao hơn "memory thật" rất nhiều vì runtime giữ vùng nhớ đã cấp phát.

Thực tế: service Go dùng thật 80MB, nhưng RSS phình 300-400MB sau vài giờ dưới tải. Container limit 256Mi → OOMKilled. Bạn sẽ đi tìm leak, trong khi vấn đề chỉ là GC chưa được thúc.

Từ Go 1.19 có GOMEMLIMIT — soft limit cho heap. Đặt nó ~80% memory limit là Go sẽ GC siêng hơn khi tới gần trần, thay vì phình tới chết:

// main.go — không cần code gì, chỉ cần biến môi trường.
// GOMEMLIMIT=400MiB khi memory limit 512Mi
// pprof để soi heap khi cần
import _ "net/http/pprof"

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux()}
    log.Println("listen :8080")
    log.Fatal(srv.ListenAndServe())
}

Debug trong đúng 4 lệnh

# 1. Lý do chết: OOMKilled hay Error?
kubectl get pod payment-api-7d9f -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}'

# 2. Đang ăn bao nhiêu (cần metrics-server)
kubectl top pod payment-api-7d9f --containers

# 3. Trần cgroup thật sự là bao nhiêu (cgroup v2)
kubectl exec -it payment-api-7d9f -- cat /sys/fs/cgroup/memory.max
kubectl exec -it payment-api-7d9f -- cat /sys/fs/cgroup/memory.current

So memory.max với memory.current: nếu current sát trần trong lúc rảnh, thì không phải tải cao mà là leak thật.

Kinh nghiệm xương máu

Service Go của mình từng bị OOM đều đặn mỗi ~2 ngày, limit 512Mi. Đọc heap profile bằng pprof thì ra thủ phạm: http.ResponseWriter bị bọc trong buffer không bao giờ Close(), lớn dần theo mỗi request lỗi. GOMEMLIMIT chỉ giúp sống thêm vài giờ, leak vẫn phải fix bằng code.

Bài học: GOMEMLIMIT giảm đau, không chữa bệnh. Bật pprof trước khi đoán.

Vài nguyên tắc mình áp dụng tới giờ:

  1. limits.memory = 1.5-2x mức dùng p99 thật, đừng lấy số đẹp cho dễ nhìn.
  2. GOMEMLIMIT = ~80% memory limit (Go 1.19+), đặt luôn từ đầu chứ đừng chờ sự cố.
  3. Với Go: requests == limits để pod vào QoS class Guaranteed, ổn định hơn dưới áp lực node.
  4. Đừng hạ limit cho "tiết kiệm tài nguyên". Vòng lặp CrashLoopBackOff tốn kém hơn nhiều so với vài trăm MB RAM.

Exit code 137 = 128 + 9 (SIGKILL). Không phải app tự chết, mà là bị bắn hạ."