Trong nhiều tuần, một cron job tại Elevare Digital thức dậy đúng lịch, kiểm tra hàng đợi và ghi lại trạng thái thành công một cách trơn tru. Nó phê duyệt chính xác bằng không bản thảo. Mười chín nội dung vẫn nằm chờ đợi. Nhóm chỉ phát hiện ra sau đó, khi khoảng trống im lặng đã phát triển từ một điều kỳ lạ thành một lượng công việc tồn đọng nhỏ. Không có gì bị sập. Không có cảnh báo nào được kích hoạt. Hệ thống về mặt kỹ thuật thì vẫn khỏe mạnh nhưng về mặt chức năng thì đã "chết".

Đây chính là nỗi kinh hoàng thầm lặng của các pipeline tự động. Khi bạn loại bỏ con người khỏi quy trình, bạn cũng đồng thời loại bỏ người sẽ nhận ra rằng không có gì đang diễn ra cả.

Đường ống tự vận hành

Elevare Digital vận hành một quy trình công việc nội dung hoàn toàn tự động. Các software agents tạo ra các bản thảo. Một cron job phê duyệt được lập lịch đóng vai trò là người gác cổng, xem xét các bản thảo đó và đẩy các mục đã được phê duyệt trực tiếp đến khâu xuất bản. Không có con người nào mở bảng điều khiển để phê duyệt từng đợt. Mục tiêu chính là để máy móc xử lý những công việc nhàm chán trong khi đội ngũ tập trung vào các vấn đề khác.

Dưới mô hình này, sự tin tưởng trở thành giao diện chính của bạn. Bạn tin rằng bộ lập lịch sẽ chạy. Bạn tin rằng công việc sẽ được thực hiện. Bạn tin vào exit code. Khi các logs hiển thị nhịp đập ổn định của các phản hồi 200 OK, bạn mặc định rằng công việc đang được tiến hành. Trong nhiều tuần, nhịp đập đó thật hoàn hảo. Cron chạy đúng giờ, mọi lúc. Chỉ là nó chưa bao giờ thực sự làm việc.

Mười chín bản thảo và không có cảnh báo

Việc phát hiện ra sự cố là tình cờ. Cuối cùng, ai đó đã nhận thấy hàng đợi xuất bản trở nên im ắng, hoặc có lẽ họ đã kiểm tra một downstream metric và thấy một đường thẳng tắp. Những gì họ tìm thấy là một kho gồm mười chín bản thảo nằm hoàn toàn chưa được chạm tới. Trình phê duyệt vẫn chạy một cách tận tụy, ghi nhật ký thành công mỗi ngày, nhưng không xử lý được bản thảo nào trong số đó.

Trong một quy trình thủ công, một người kiểm duyệt sẽ nhận ra hộp thư trống rỗng hoặc một đống các mục đang chờ xử lý ngay từ ngày đầu tiên. Trong phiên bản tự động, sự thiếu vắng hoạt động trông giống hệt như sự thiếu vắng công việc. Cron không có quản lý để làm thất vọng. Nó chỉ đơn giản là cứ điểm danh rồi về sớm.

Hai lỗi, một kết quả trống rỗng

Sự cố này có hai nguyên nhân chính. Không có lỗi nào là lỗi cú pháp, lỗi timeout hay lỗi mất kết nối phụ thuộc. Cả hai đều là những lỗi ngữ nghĩa (semantic mistakes) đã biến mười chín hàng hợp lệ thành con số không trong mắt công cụ truy vấn.

Đầu tiên là sự không khớp kiểu dữ liệu (type mismatch). Tác nhân tạo bản thảo đã ghi các bản ghi được gắn thẻ là article. Trong khi đó, cron phê duyệt lại truy vấn cụ thể cho các loại thread. Đây là kiểu sai lệch thường xảy ra khi bên sản xuất (producers) và bên tiêu thụ (consumers) phát triển trên các lộ trình song song. Một nhóm—hoặc một tác nhân—quyết định đầu ra là một article. Nhóm khác lại viết trình tiêu thụ với giả định rằng nó sẽ tiếp nhận các thread. Không có hệ thống kiểu dữ liệu nào báo lỗi khi biên dịch vì đây có thể là các thẻ chuỗi lỏng lẻo, có lẽ là các trường JSON hoặc các giá trị varchar không được ràng buộc. Cơ sở dữ liệu đơn giản là không tìm thấy kết quả khớp nào và trả về một tập hợp rỗng. Đối với công cụ truy vấn, đó không phải là một điều kiện lỗi. Đó là một câu trả lời chính xác cho một câu hỏi sai.

Thứ hai, một phép inner join trong truy vấn của trình phê duyệt đã âm thầm "nuốt chửng" toàn bộ các hàng. Nếu truy vấn thực hiện join bảng bản thảo với một bảng khác—có thể là để tra cứu metadata, cờ trạng thái hoặc quy tắc định tuyến—và điều kiện join thất bại, thì phép inner join sẽ hoạt động chính xác như thiết kế. Nó loại bỏ các hàng không khớp. Không có hàng mồ côi nào xuất hiện trong tập kết quả. Không có giá trị null nào báo hiệu vấn đề. Mười chín bản thảo đi qua truy vấn như nước chảy qua rây, và lớp ứng dụng nhận được một danh sách trống rỗng hoàn hảo.

Vì truy vấn không trả về hàng nào, hàm đã kết thúc một cách trơn tru. Không có exception nào được đẩy lên. Phản hồi HTTP là 200 OK. Cron ghi nhật ký thành công và tiếp tục đi ngủ.

Cái bẫy của con số "đã xử lý: 0"

Đây chính là mấu chốt của vấn đề. Trong một hệ thống dựa trên hàng đợi, bên tiêu thụ thường xuyên thấy không có hàng nào để xử lý. Hàng đợi trống rỗng. Công nhân hoàn thành nhanh chóng. Nhật ký ghi processed: 0 và đội ngũ coi đó là tin tốt: chúng ta đang đáp ứng kịp nhu cầu. Đó là một trạng thái lành mạnh.

Nhưng processed: 0 lại mã hóa hai thực tế hoàn toàn khác nhau:

  • Trạng thái lành mạnh: Đã xử lý bằng không vì không có mục nào đang chờ. Hàng đợi trống. Hệ thống ở trạng thái nghỉ theo thiết kế.
  • Trạng thái lỗi: Đã xử lý bằng không vì bên tiêu thụ không nhìn thấy công việc. Hàng đợi có mười chín hàng. Hệ thống đang bị "mù", chứ không phải đang nghỉ.

Nếu không có một bước kiểm tra độc lập về queue depth, hai trạng thái này sẽ phát ra các telemetry giống hệt nhau. Chúng trông giống nhau trên dashboards, có vẻ giống nhau trong các trình tổng hợp logs và gây ra sự im lặng tương tự trong PagerDuty. Bạn đã xây dựng một chiến lược giám sát chỉ phát hiện khi công nhân "la hét", chứ không phải khi nó "thì thầm" đi ngang qua một đống công việc thực sự.

Thu hẹp khoảng cách

Elevare Digital đã giải quyết vấn đề bằng cách thay đổi những gì họ giám sát. Họ ngừng việc chỉ dựa dẫm duy nhất vào tỷ lệ lỗi và trạng thái thành công. Thay vào đó, họ bắt đầu cảnh báo dựa trên khoảng cách giữa lượng công việc hiện có và lượng công việc đã hoàn thành.

Sau mỗi đợt xử lý (batch), giờ đây họ thực hiện một bước kiểm tra bất biến (invariant check) đơn giản:

  • Nếu processed bằng 0 số hàng đang chờ (pending rows) lớn hơn 0, hãy kích hoạt cảnh báo mức độ nghiêm trọng cao.

Quy tắc này được thiết kế một cách có chủ đích để không phụ thuộc vào nguyên nhân. Nó không quan tâm liệu sự sai sót là do bộ lọc lỗi, một phép join bị hỏng, hay một chuỗi enum bị nhập sai. Nó chỉ quan tâm rằng có công việc tồn tại nhưng không có công việc nào được thực hiện. Điều này chuyển đổi việc giám sát từ "Quy trình có báo lỗi không?" sang "Công việc có được tiến triển không?".

Để hỗ trợ điều này, họ coi độ sâu của hàng đợi (queue depth) là một chỉ số quan trọng (first-class metric), được theo dõi theo thời gian chứ không chỉ là kiểm tra ngẫu nhiên. Nếu bên sản xuất (producer) liên tục thêm các hàng trong khi bên tiêu thụ (consumer) liên tục báo cáo thành công, xu hướng độ sâu sẽ trở thành bằng chứng đanh thép (smoking gun). Một ảnh chụp tức thời (static snapshot) có thể đánh lừa, nhưng một lượng công việc tồn đọng (backlog) đang tăng dần thì không bao giờ.

Bài học cho các Hệ thống Tự vận hành

Sự cố tại Elevare chứa đựng một vài quy tắc thực tế cho bất kỳ ai đang vận hành các đường ống (pipelines) tự động hóa hoàn toàn.

Ghi log số hàng đã quét (scanned rows) tách biệt với số hàng đã xử lý (processed rows). Bên tiêu thụ có thể thực thi một truy vấn chạm đến 40 hàng, nhưng lại lọc bỏ tất cả chúng thông qua các tiêu chí sai và báo cáo processed: 0. Nếu bạn chỉ ghi log số lượng cuối cùng, bạn sẽ bỏ lỡ sự tương tác "ma" này. Chỉ số scanned-rows sẽ tiết lộ rằng worker đã xuất hiện, đã xem qua công việc, rồi rời đi trong sự bối rối. Khoảng cách giữa số hàng đã quét và số hàng đã xử lý thường là tín hiệu sớm nhất của bạn.

Theo dõi độ sâu hàng đợi dưới dạng chuỗi thời gian (time-series). Một hàng đợi trống tạm thời thì không sao. Nhưng một hàng đợi tăng trưởng liên tục (monotonically) trong khi các worker vẫn báo trạng thái xanh (hoạt động tốt) thì không ổn. Hãy vẽ biểu đồ độ sâu so với thông lượng (throughput) của bên tiêu thụ. Khi hai yếu tố này phân kỳ, hãy kiểm tra ngay lập tức, ngay cả khi mọi bước kiểm tra sức khỏe (health check) đều đang vượt qua.

Kiểm thử bên tiêu thụ với đầu ra thực tế của bên sản xuất, thay vì chỉ dùng mock. Các bài kiểm thử đơn vị (unit tests) với dữ liệu giả (mocked data) luôn mang theo những giả định của người kiểm thử. Nếu bộ tạo mock tạo ra các kiểu thread và bên tiêu thụ cũng mong đợi các kiểu thread, các bài kiểm thử của bạn sẽ vượt qua trong khi môi trường thực tế lại thất bại. Hãy chạy các bài kiểm thử tích hợp (integration tests) lấy các bản ghi thực tế từ đầu ra của bên sản xuất. Hãy đảm bảo rằng bên tiêu thụ thực sự có thể nhìn thấy những gì bên sản xuất ghi xuống.

Coi các kiểu dữ liệu và giá trị enum như các hợp đồng (contracts). Các thẻ chuỗi lỏng lẻo trong các khối JSON rất tiện lợi cho đến khi chúng trở thành những điểm lỗi vô hình. Hãy định nghĩa schema một cách rõ ràng. Chia sẻ các hằng số (constants). Xác thực payload tại điểm tiếp giáp giữa bên sản xuất và bên tiêu thụ. Nếu hợp đồng bị phá vỡ, hệ thống nên báo lỗi một cách rõ ràng tại ranh giới, chứ không phải im lặng bên trong một mệnh đề WHERE.

Bài học cốt lõi

Các hệ thống tự vận hành không thất bại giống như con người. Chúng không xin nghỉ ốm, không ném ra ngoại lệ (exceptions) mọi lúc, hay để lại các bản ghi lỗi (crash dumps) rõ ràng. Chúng trả về mã 200 OK và để mặc cho dữ liệu tồn kho mục nát. Nếu các cảnh báo của bạn chỉ lắng nghe những tiếng hét, bạn sẽ bỏ lỡ những thất bại tốn kém nhất—những thất bại mà mọi thứ trông có vẻ ổn nhưng thực tế chẳng có việc gì được hoàn thành.

Hãy thiết kế khả năng quan sát (observability) của bạn để theo dõi khoảng cách. Hãy đo lường lượng công việc đi vào so với lượng công việc đi ra. Khi hai con số này không còn khớp nhau, hãy giả định rằng máy móc đang nói dối bạn. Bởi vì đôi khi, một bản ghi log thành công hoàn hảo lại là triệu chứng duy nhất của một hệ thống đã hoàn toàn trở nên mù quáng.