Có một câu hỏi rợn người trong nghề canh gác: ai canh người canh gác? Một máy báo khói tốt kêu inh ỏi khi có cháy — nhưng nếu chính nó hết pin, nó không kêu, và sự im lặng của nó trông y hệt như "nhà đang an toàn". Đó là kiểu hỏng độc ác nhất: thứ càng hỏng nặng lại càng trông yên bình. Hệ giám sát của bạn chính là cái máy báo khói đó, và bài này tôi rút pin nó ra từng cái một để xem cái gì còn kêu.

Ba mươi bảy phần trước đều ngầm giả định rằng hệ giám sát đang chạy. Phần này bỏ giả định đó và hỏi một câu khó chịu: nếu chính hệ giám sát chết, bạn biết bằng cách nào? Câu hỏi ấy không trừu tượng. Một hệ giám sát chết là hệ giám sát im lặng, mà im lặng thì trông y hệt như "mọi thứ đều ổn". (Đo bằng Prometheus + Alertmanager thật trong container, không có phần cứng đặc biệt.)

Bảng số liệu

Hệ đo: exporter phơi chỉ số dich_vu_hong, Prometheus thu thập và đánh giá luật mỗi 10 giây, Alertmanager với group_wait: 0s và repeat_interval: 1m, một máy nhận webhook ghi mốc thời gian. Bốn luật cảnh báo chạy song song trên cùng hệ đó:

DichVuHong    expr: dich_vu_hong > 0        <- luat ban that su viet ra
MucTieuChet   expr: up{job="app"} == 0      <- Prometheus tu sinh chi so "up"
ChiSoBienMat  expr: absent(dich_vu_hong)
NhipTim       expr: vector(1)               <- luon luon keu (dead man's switch)

Rồi giết lần lượt từng thành phần. Ba lần chạy độc lập.

Hỏng ở đâu DichVuHong MucTieuChet ChiSoBienMat NhipTim
Dịch vụ hỏng thật kêu, 0,1–13,5 s — — đến
Exporter biến mất im lặng, 3/3 lần kêu, 3,5–17,6 s kêu, 3,5–37,6 s đến
Prometheus chết im im im vẫn đến
Alertmanager chết im im im im

Luật bắt sự cố lại im đúng lúc cần nhất

Luật bạn viết ra để bắt sự cố là luật im lặng đúng lúc cần nhất. Khi exporter biến mất, dich_vu_hong > 0 không kêu — cả ba lần. Lý do đơn giản đến mức dễ bỏ qua: biểu thức ấy cần có chuỗi dich_vu_hong để so sánh. Không có chuỗi thì không có gì để so, và PromQL trả về tập rỗng. Tập rỗng không lớn hơn 0. Nó cũng không nhỏ hơn 0. Nó chỉ đơn giản là không có gì.

Đây là khác biệt giữa "tôi đo được và nó ổn" với "tôi không đo được gì cả". Một luật ngưỡng thuần tuý gộp cả hai vào cùng một trạng thái: yên lặng. Hai luật bắt được nó — up == 0 và absent() — đều không nhắc tới điều kiện hỏng. Chúng nói về sự tồn tại của dữ liệu, không phải về giá trị của dữ liệu.

Đáng chú ý là hai luật ấy không kêu cùng lúc. up == 0 bắt được ở giây 3,5, 10,9 và 17,6; absent() ở giây 3,5, 10,9 và 37,6. Lần thứ ba chênh nhau 20 giây, vì up chuyển sang 0 ngay ở lần thu thập hỏng đầu tiên, còn absent() phải đợi tới khi chuỗi cũ thật sự rời khỏi cửa sổ tra cứu của Prometheus. Cả hai đều đủ nhanh, nhưng up nhanh hơn và ổn định hơn — thêm một lý do để nó là luật đầu tiên bạn dựng.

Nhịp tim vẫn đập sau khi tim đã ngừng. Đây là con số làm tôi phải đo riêng. Giết Prometheus ở mốc 0, rồi theo dõi 420 giây:

Lần Nhịp tim vẫn đến ở Nhịp cuối
1 70 s, 130 s, 190 s 190 s
2 7 s, 67 s, 127 s, 187 s 187 s
3 44 s, 104 s, 164 s 164 s

Ba đến bốn nhịp nữa, kéo dài 164–190 giây sau khi Prometheus đã chết hẳn. Nguyên nhân: nhịp tim đó không do Prometheus phát trực tiếp tới máy nhận. Prometheus gửi cảnh báo sang Alertmanager kèm một hạn dùng đặt trước vài phút, rồi chính Alertmanager mới là bên phát lại đều đặn theo repeat_interval. Prometheus chết đi, Alertmanager không hay biết — nó vẫn còn một cảnh báo chưa hết hạn trong tay và vẫn phát lại đúng lịch, cho tới khi hạn ấy trôi qua. Nghĩa là một dead man's switch đi qua Alertmanager có khoảng mù xấp xỉ ba phút. Bộ theo dõi bên ngoài đặt ngưỡng "không thấy nhịp trong 2 phút thì báo động" sẽ không bao giờ kêu, vì trong hai phút đó nhịp vẫn về đều.

Vì sao mỗi lớp chỉ thấy lớp dưới

Bốn thành phần, mỗi cái chết đi thì phần còn lại thấy khác nhau:

  • Exporter chết — Prometheus vẫn sống và tự sinh chỉ số up{job="app"} = 0. Đây là chỉ số Prometheus tạo ra cho mỗi lần thu thập, không phải do ứng dụng phơi ra, nên nó tồn tại kể cả khi ứng dụng đã biến mất. Đó là lý do up == 0 là tấm lưới rẻ nhất và đáng dựng nhất.
  • Prometheus chết — không còn ai đánh giá luật. Mọi luật đều im, kể cả up == 0, vì up cũng do chính Prometheus sinh ra.
  • Alertmanager chết — Prometheus vẫn đánh giá luật và vẫn cố gửi, nhưng không còn ai chuyển tiếp. Đây là kiểu hỏng khó chịu nhất: mọi thứ trong Prometheus trông hoàn hảo, bảng điều khiển xanh, luật đang FIRING — mà không ai nhận được gì.

Chuỗi phụ thuộc chỉ đi một chiều: mỗi lớp chỉ giám sát được lớp dưới nó. Không lớp nào tự giám sát được chính mình, và không lớp nào giám sát được lớp trên. Muốn bịt đầu trên thì phải có một thứ nằm ngoài hẳn — và thứ đó không được đi qua bất kỳ thành phần nào nó đang canh chừng.

Nghĩa là gì trong công việc hằng ngày

Ba việc, xếp theo tỷ lệ lợi ích trên công sức:

  • Dựng up == 0 cho mọi job. Một luật, bắt được kiểu hỏng phổ biến nhất, và bắt trong 3,5–17,6 giây. Đây là thứ rẻ nhất trong cả bài.
  • Với chỉ số quan trọng, thêm absent(). up chỉ biết mục tiêu còn trả lời hay không; nó không biết mục tiêu có còn phơi ra đúng chỉ số bạn cần hay không. Một lần đổi tên chỉ số khi nâng cấp là đủ để up vẫn xanh mà luật ngưỡng thì mù.
  • Dead man's switch phải đi đường riêng. Nếu nhịp tim của bạn đi qua chính Alertmanager mà nó đang canh, khoảng mù là ba phút. Cách đúng là để một dịch vụ bên ngoài — không dùng chung hạ tầng — chờ tín hiệu; và ngưỡng chờ phải lớn hơn khoảng mù đó, nếu không bạn sẽ có báo động giả mỗi lần Alertmanager khởi động lại.

Một cách kiểm nhanh: tắt exporter của một dịch vụ thật trong giờ hành chính và bấm giờ. Nếu sau năm phút không ai nhận được gì, bạn vừa biết chính xác mình đang mù ở đâu — với chi phí bằng đúng năm phút.

Cùng một mù "vắng dữ liệu ≠ dữ liệu tốt", đổi ngăn xếp

Chuyện "một phép kiểm về giá trị im lặng khi dữ liệu vắng mặt, và bạn phải kiểm riêng sự tồn tại" không riêng gì Prometheus — nó là cái bẫy chung của mọi hệ báo động dựa trên điều kiện.

Trong thế giới Java, đây đúng là lý do Spring Boot Actuator tách liveness khỏi readiness, và vì sao một health check tốt phải phân biệt "thành phần trả lời UP" với "thành phần không trả lời". Một HealthIndicator chỉ kiểm giá trị (ví dụ "hàng đợi dưới ngưỡng") sẽ im khi chính cái indicator đó không chạy được — đúng cái bẫy dich_vu_hong > 0. Và Micrometer đẩy metric lên backend cũng cần "no data" alert riêng, vì mất metric trông giống hệt metric tốt.

Trong Go — ngôn ngữ viết Prometheus/Alertmanager — cùng bài học là lý do cộng đồng luôn khuyên đôi up == 0 + absent(), và dùng một dead man's switch ngoài cụm (như healthchecks.io hay một cron ngoài) chứ không đặt trong chính hệ đang canh. Đây là hiện thân trực tiếp của "thứ canh đầu trên phải nằm ngoài hẳn".

Còn ở cơ sở dữ liệu, họ hàng gần nhất là phân biệt NULL/vắng dòng với giá trị. Một truy vấn WHERE errors > 0 trả về rỗng khi không có dòng nào — trông giống "không có lỗi", nhưng có thể là "job ghi log đã chết nên chẳng có dòng nào". Cách đúng là kiểm cả sự tồn tại: COUNT(*) = 0 trên bảng heartbeat, hay một LEFT JOIN để phân biệt "không lỗi" với "không dữ liệu". Cùng cái bẫy tập rỗng.

Mẫu số chung: một phép kiểm về giá trị chỉ hoạt động khi dữ liệu tồn tại — mà đúng lúc hệ hỏng nặng nhất thì dữ liệu thường biến mất, và "vắng dữ liệu" bị đọc nhầm thành "dữ liệu tốt". Muốn thấy được kiểu hỏng câm lặng, bạn phải kiểm sự tồn tại của dữ liệu tách khỏi giá trị của nó, và đặt phép kiểm cuối cùng ở một nơi độc lập với chính thứ nó canh. Nên câu hỏi đúng cho bất kỳ hệ báo động nào không phải "luật của tôi có bắt được sự cố không" mà là "khi thứ tạo ra dữ liệu chết, có gì kêu lên không, hay tôi chỉ nhận được sự im lặng trông giống bình yên".

Muốn tự soát, mở tệp luật cảnh báo của bạn ra và đếm hai con số: bao nhiêu luật nói về giá trị (> 0, > 0.9, < 100), và bao nhiêu luật nói về sự tồn tại (up ==, absent(). Nếu con số thứ hai bằng không thì toàn bộ hệ cảnh báo của bạn chỉ bắt được đúng một kiểu hỏng: kiểu mà mọi thứ vẫn chạy và báo cáo trung thực về việc mình đang hỏng.