Files
labs/Notebooks-CSAI/Lab4B_DietPi_Server_Health.ipynb

252 KiB

Module 4 (mở rộng) — Khám sức khoẻ máy chủ DietPi THẬT: 24 giờ, 1 tuần, 1 tháng, sự cố thật — và đánh giá cho trung thực

Cách làm: bấm vào một ô code rồi nhấn Shift + Enter để chạy ô đó và sang ô kế tiếp. Chạy lần lượt từ trên xuống. Kernel phải là Python (lớp CSAI). Ô có dòng ✏️ SỬA là chỗ mời bạn đổi số / chữ rồi chạy lại. Sửa hỏng? Menu Kernel → Restart Kernel and Run All Cells. Cả notebook chạy khoảng 1–2 phút.

🎯 Mục tiêu của bài

Học xong bài này, bạn:

  1. Đọc được sức khoẻ một máy chủ thật qua các chỉ số: CPU, iowait, load, RAM, swap, đĩa, mạng, nhiệt độ (Phần 0).
  2. Biết vì sao không được lấp chỗ trống và vì sao lấy trung bình có thể giấu mất sự cố (Phần 1).
  3. So sánh 24 giờ / 1 tuần / 1 tháng để tách "bất thường thật" khỏi "việc định kỳ trông giống bất thường" (Phần 2, 3, 4).
  4. Thấy rõ điểm mù dữ liệu: chưa thể so "giữa các tháng trong năm" (Phần 4b).
  5. Xây 3 bộ phát hiện (ngưỡng cố định, nền theo giờ trong ngày, Isolation Forest) và chấm chúng bằng sự cố thật (Phần 5).
  6. Đánh giá trung thực: vì sao một mô hình đạt điểm đẹp lại vô dụng khi chia dữ liệu sai (Phần 6).
  7. Truy service "thủ phạm" của từng sự cố: ai vượt mức bình thường của chính nó, lúc đó có lịch chạy gì (Phần 8); rồi cải thiện cách truy — điểm z bền, ai lên trước, bảng 3 bằng chứng và kiểm tra trung thực (Phần 8.5).
  8. Mang phương pháp về nhà: thang 4 mức giám sát, pipeline cố định, "làm sai thì thấy ngay", tự chấm hệ thống của mình (Phần 9).

🗺️ Bài này: bài toán gì, mô hình nào, vì sao?

  • Bài toán: phát hiện lúc máy chủ bất thường — và biết lúc nào là sự cố thật, lúc nào chỉ là cron / backup chạy đúng giờ.
  • Dữ liệu: 30 ngày số liệu thật của chính máy chủ giám sát DietPi (mỗi 30 giây), cộng sổ sự cố thật ghi giờ bắt đầu / kết thúc.
  • Vì vậy chọn: số liệu không có nhãn sẵn cho từng dòng → các cách phát hiện bất thường không giám sát: ngưỡng cố định (mốc so sánh), nền theo giờ trong ngày (vì máy chủ có nhịp ngày), Isolation Forest (nhìn nhiều chỉ số cùng lúc). Sổ sự cố chỉ dùng để chấm, không dùng để học.

📚 Mô hình và dữ liệu dùng trong bài

Mô hình / cách làm Là gì Dùng ở
Ngưỡng cố định (trung bình + k × độ lệch chuẩn) một vạch kẻ ngang cho cả 30 ngày — mốc để so Phần 5
Nền theo giờ trong ngày (trung vị + MAD từng khung 5 phút) "bình thường lúc 00:00 khác bình thường lúc 14:00" — so mỗi điểm với chính khung giờ của nó Phần 5
Isolation Forest (scikit-learn) rừng cây cô lập điểm lạ, nhìn 11 chỉ số cùng lúc Phần 5
Random Forest (scikit-learn) mô hình phân loại có giám sát — chỉ dùng để minh hoạ lỗi đánh giá Phần 6
Dữ liệu Là gì Lấy ở đâu
dietpi_health_30s.csv.gz 24 chỉ số của máy chủ DietPi, mỗi 30 giây, 13/09 → 14/10/2026 Prometheus của DietPi (node_exporter), xuất bằng export_dietpi_health.py
dietpi_health_5min.csv, dietpi_health_1h.csv cùng dữ liệu, gộp 5 phút / 1 giờ (trung bình + lớn nhất) từ file 30 giây
incidents.csv sổ sự cố thật: E = đợt bất thường, R = việc định kỳ bình thường, S = thay đổi lớn của hệ thống — mỗi dòng có mức chắc chắn đối chiếu log vận hành, báo cáo sự cố, lịch cron của máy chủ (khảo sát 14/10/2026)

Riêng tư: chỉ là số tổng hợp của máy chủ — không có IP, tên người dùng hay nội dung lệnh.

Pipeline — 7 bước của mọi bài toán AI trong khóa. Mỗi phần ghi 📍 Bước ở đầu:

Bước Câu hỏi phải trả lời Phần
1. Thu thập dữ liệu dữ liệu gì, có nhãn không? Phần 0
2. Làm sạch dữ liệu chỗ trống, độ phân giải Phần 1
3. Chọn mô hình phù hợp với bài toán nhịp ngày, nhịp tuần, thay đổi lớn → chọn cách phát hiện Phần 2, 3, 4, 5
4. Coding 3 bộ phát hiện Phần 5
5. Lưu ý khi coding lấp chỗ trống, chia dữ liệu sai, nền bị "nhiễm" sự cố Phần 1, 5, 6
6. Đánh giá các mô hình chấm bằng sự cố thật, chia theo thời gian; truy service gây ra và kiểm tra cách truy Phần 5, 6, 8
7. Quy tắc bàn tay phải ✋ tổng hợp, ứng dụng SOC, về nhà tự triển khai Phần 9, 7

📖 Thuật ngữ

Thuật ngữ Nghĩa
iowait % thời gian CPU ngồi chờ ổ đĩa. Cao = đĩa đang nghẽn
load15 trung bình 15 phút số việc đang chạy hoặc chờ chạy. Máy 4 lõi mà load 100 = quá tải nặng
swap RAM hết, máy phải đẩy bớt sang ổ đĩa — rất chậm
PSI (pressure) % thời gian có việc bị kẹt vì thiếu CPU / RAM / đĩa
scrape một lần Prometheus hỏi số; scrape_ok = 0 nghĩa là lần hỏi đó không có trả lời
nền (baseline) mức "bình thường" để so — ở đây tính riêng cho từng khung 5 phút trong ngày
MAD độ lệch trung vị — giống độ lệch chuẩn nhưng không bị vài điểm cực lớn kéo lệch
đợt (episode) các điểm báo động liền nhau gộp lại thành một lần báo
thay đổi lớn (regime change) hệ thống đổi hẳn (chuyển dịch vụ đi máy khác) → "bình thường" cũng đổi
lộ đề (data leakage) dữ liệu kiểm tra lọt vào lúc học → điểm đẹp ảo

data_pipeline

Phần 0 — Thu thập dữ liệu: 30 ngày của một máy chủ thật

📍 Bước 1 — Thu thập:

5 câu hỏi về dữ liệu Trả lời
Dữ liệu gì? 24 chỉ số sức khoẻ của máy chủ giám sát DietPi (CPU theo loại, iowait, load, RAM, swap, PSI, đĩa, mạng, nhiệt độ, tiến trình)
Lấy ở đâu? Prometheus trên chính DietPi, đọc từ node_exporter mỗi 30 giây; lúc xuất dữ liệu (14/10/2026) Prometheus chỉ giữ 30 ngày rồi tự xoá — tối 14/10 đã nâng lên 180 ngày, nhưng phần đã xoá thì không lấy lại được
Nếu muốn chẩn đoán được những sự cố như thế này ngay từ đầu? phải dựng giám sát trước khi sự cố xảy ra — số liệu không đo thì không bao giờ lấy lại được. Bốn việc tối thiểu: ① giữ số liệu đủ chi tiết (mỗi 30 giây) và đủ lâu (muốn so tháng này với tháng khác thì phải giữ hơn một năm, Phần 4b); ② đo từng dịch vụ / tiến trình, không chỉ cả máy — mới chỉ mặt được thủ phạm (Phần 8); ③ có một máy khác canh chừng — máy treo thì không tự báo được (Phần 1); ④ ghi sổ sự cố và sổ thay đổi từ ngày đầu — mới có nhãn để chấm (Phần 4–6). Công cụ và cấu hình cụ thể cho từng mức: Phần 9
Thu thập thế nào? xuất bằng export_dietpi_health.py ngày 14/10/2026 — chỉ đọc, không sửa máy chủ
Có nhãn chưa? không có nhãn từng dòng. Có sổ sự cố thật (incidents.csv) để chấm bộ phát hiện — mỗi đợt có mức chắc chắn (CONFIRMED / LIKELY / HYPOTHESIS / UNEXPLAINED)
Có cần làm sạch? có chỗ trống khi máy chủ treo (Prometheus không hỏi được) — giữ nguyên là trống, không lấp (Phần 1)
In [ ]:
%matplotlib inline
import warnings
warnings.filterwarnings("ignore")
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
pd.set_option("display.width", 160); pd.set_option("display.max_columns", 30)

THU_MUC = "/opt/csai/data/dietpi_health"
d30 = pd.read_csv(f"{THU_MUC}/dietpi_health_30s.csv.gz", parse_dates=["ts"])
d5 = pd.read_csv(f"{THU_MUC}/dietpi_health_5min.csv", parse_dates=["ts"])
d1h = pd.read_csv(f"{THU_MUC}/dietpi_health_1h.csv", parse_dates=["ts"])
so_su_co = pd.read_csv(f"{THU_MUC}/incidents.csv", keep_default_na=False)   # keep_default_na: giữ chữ "n/a" là chữ
# 📦 d30 = mỗi 30 giây 1 dòng · d5 = mỗi 5 phút · d1h = mỗi giờ · so_su_co = sổ sự cố thật

print(f"30 giây: {len(d30):,} dòng, từ {d30.ts.min()} đến {d30.ts.max()}")
print(f"5 phút : {len(d5):,} dòng | 1 giờ: {len(d1h):,} dòng")
print("Các cột:", ", ".join(d30.columns[1:]))
so_o_trong = d30.drop(columns=["ts"]).isna().sum().max()        # số dòng trống của cột trống nhiều nhất
so_ngay_30 = (d30.ts.max() - d30.ts.min()).total_seconds() / 86400
print("Tỉ lệ ô trống lớn nhất của một cột:", f"{so_o_trong / len(d30):.3%}",
      f"= {so_o_trong} lần đo × 30 giây ≈ {so_o_trong * 30 / 60:.1f} phút trên {so_ngay_30:.1f} ngày")
print()
print("Sổ sự cố — số dòng theo loại và mức chắc chắn:")
print(pd.crosstab(so_su_co.kind, so_su_co.status))

Kết quả: 30 giây: 90,180 dòng, từ 2026-09-13 07:00:30+07:00 đến 2026-10-14 14:30:00+07:00 · 5 phút : 9,019 dòng | 1 giờ: 752 dòng · 24 chỉ số + scrape_ok · Tỉ lệ ô trống lớn nhất của một cột: 0.077% = 69 lần đo × 30 giây ≈ 34.5 phút trên 31.3 ngày

Sổ sự cố: 15 đợt bất thường (incident: 5 CONFIRMED, 8 LIKELY, 1 HYPOTHESIS, 1 UNEXPLAINED), 8 việc định kỳ (recurring_normal), 5 thay đổi lớn (step_change), 4 dòng "không có ghi chép" (no_record: khởi động lại, hết RAM… — không có bằng chứng trong 30 ngày).

👉 Hiểu:

  • Hơn 90 nghìn lần đo trong 31 ngày, gần như không thiếu: 0,077% nghĩa là 69 lần đo × 30 giây ≈ 34,5 phút trên 31 ngày — nhưng chỗ thiếu ít ỏi đó lại nằm đúng lúc quan trọng nhất (Phần 1).
  • Sổ sự cố có mức chắc chắn cho từng dòng. Một đợt chỉ "LIKELY" (có thể) thì dùng để chấm phải thận trọng — Phần 6 quay lại điều này.
In [ ]:
# Các đợt bất thường thật nằm TRONG 30 ngày này (dùng để chấm ở Phần 5)
su_co = so_su_co[(so_su_co.kind == "incident") & (so_su_co.in_window == "YES")].copy()
su_co["bat_dau"] = pd.to_datetime(su_co.start_ts); su_co["ket_thuc"] = pd.to_datetime(su_co.end_ts)
dinh_ky = so_su_co[so_su_co.kind == "recurring_normal"]
# 📦 su_co = các đợt E nằm trong 30 ngày · dinh_ky = các việc định kỳ R (cron, backup)
pd.set_option("display.max_colwidth", 70)
print(su_co[["id", "start_ts", "end_ts", "status", "title"]].to_string(index=False))
print()
print(dinh_ky[["id", "start_ts", "title"]].to_string(index=False))

Kết quả: 8 đợt nằm trong 30 ngày: E1 (22/09 16:43–18:15, máy treo vì một lần đo hiệu năng không giới hạn, CONFIRMED), E2 (15/09, swap thrashing), E3 (24/09 tối, backup + chuyển dịch vụ), E4 (25/09 10:00, HYPOTHESIS), E5 (28/09 13:00–20:30, CPU cao + nóng, UNEXPLAINED), E6a/E6b (06:00 ngày 15 và 17/09), E7 (17/09 18:25, lần backup Gitea đầu tiên). Việc định kỳ R1–R8: backup / logrotate 00:00, pipeline mỗi 6 giờ (đến 22/09), báo cáo sáng ~06:00, backup Gitea Chủ nhật ~03:32–03:45, việc ban đêm, giám sát nền…

👉 Hiểu: chỉ 1 trong 8 đợt được xác nhận chắc chắn. Đây là thực tế của mọi SOC: nhãn ít và không chắc. Danh sách R là "lịch sinh hoạt" của máy chủ — thứ trông giống bất thường nhưng là bình thường.

Phần 1 — Làm sạch: chỗ trống và cái bẫy "lấy trung bình"

📍 Bước 2 + 5 — Làm sạch và lưu ý: trước khi tìm bất thường, phải biết dữ liệu thiếu ở đâu và độ phân giải có đủ để thấy sự cố không.

Câu hỏi: ngày 22/09 có một đợt máy chủ quá tải (E1, đã xác nhận). Nếu Prometheus không hỏi được máy chủ trong lúc đó — ta có nên lấp chỗ trống bằng số trước và sau không?

In [ ]:
# 1.1 Chỗ trống: các lần Prometheus không hỏi được máy chủ (scrape_ok = 0)
trong = d30[d30.scrape_ok == 0]
nhom = (trong.ts.diff() > pd.Timedelta("1min")).cumsum()
khoang_trong = trong.groupby(nhom).ts.agg(["min", "max", "count"])
khoang_trong["phút"] = khoang_trong["count"] * 0.5
print(f"Tổng số dòng trống: {len(trong)} / {len(d30):,} ({len(trong)/len(d30):.2%})")
print("Khoảng trống dài hơn 2 phút:")
print(khoang_trong[khoang_trong["phút"] > 2].to_string(index=False))

# Vẽ 2 giờ quanh E1 ở độ phân giải 30 giây
cua_so = d30[(d30.ts >= "2026-09-22 16:00+07:00") & (d30.ts <= "2026-09-22 18:45+07:00")]
fig, truc = plt.subplots(2, 1, figsize=(14, 5.5), sharex=True)
truc[0].plot(cua_so.ts, cua_so.load15, color="#1E3A5F"); truc[0].set_ylabel("load15")
truc[1].plot(cua_so.ts, cua_so.cpu_iowait_pct, color="#DC2626"); truc[1].set_ylabel("iowait %")
for t in truc:
    t.axvspan(khoang_trong["min"].iloc[khoang_trong["phút"].argmax()], khoang_trong["max"].iloc[khoang_trong["phút"].argmax()],
              color="#9CA3AF", alpha=0.35, label="Prometheus không hỏi được máy chủ")
    t.grid(alpha=0.3); t.legend(loc="upper left")
truc[1].xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=cua_so.ts.dt.tz))
plt.suptitle("E1 — 22/09/2026: máy chủ quá tải, giám sát bị 'mù'"); plt.tight_layout(); plt.show()

Kết quả: Tổng số dòng trống: 67 / 90,180 (0.07%) — một khoảng trống duy nhất dài hơn 2 phút: 2026-09-22 16:43:30 → 17:16:30, 33.5 phút. Hình: ngay trước khoảng trống iowait đã lên ~40–54%; ngay sau khoảng trống load15 đứng ở ~102 rồi giảm dần trong gần 1 giờ.

👉 Hiểu:

  • 33,5 phút trống chính là lúc máy chủ quá tải đến mức không trả lời được Prometheus. Khoảng trống là triệu chứng, không phải lỗi dữ liệu.
  • Nếu lấp bằng đường nối thẳng giữa trước và sau, ta sẽ vẽ ra những con số chưa từng tồn tại, và che mất câu "giám sát đã bị mù". Vì vậy bài này giữ chỗ trống là trống.

Máy treo đến mức không trả lời được Prometheus — vậy cài Prometheus có vô ích không? Không, nhưng phải hiểu nó làm được gì và không làm được gì:

  • Trước khi mù, nó đã ghi được dấu hiệu báo trước (iowait lên ~40–54% ngay trước khoảng trống) — nếu có cảnh báo trên iowait, SOC đã biết trước khi máy đứng hẳn.
  • Sau khi máy hồi, nó ghi được hậu quả (load15 ~102, giảm dần gần 1 giờ) — đo được sự cố kéo dài và nặng đến đâu.
  • Chính chỗ trống là một số đo: cột scrape_ok = 0 ở đúng 67 lần đo đó — "giám sát bị mù" được ghi lại, không bị mất.
  • Giới hạn thật: Prometheus chạy trên chính máy bị treo, nên lúc treo nó không gửi được cảnh báo nào. Cách sửa: thêm một máy khác canh chừng (hỏi máy này mỗi phút, không trả lời quá vài phút thì báo) — đó là việc của Mức 4 ở Phần 9. Giám sát tốt = giám sát từ trong (chi tiết) + từ ngoài (biết khi máy chết).
In [ ]:
# 1.2 Cái bẫy "lấy trung bình": cùng đợt E1, nhìn ở 3 độ phân giải
a, b = "2026-09-22 16:30+07:00", "2026-09-22 18:30+07:00"
cat = lambda d: d[(d.ts >= a) & (d.ts <= b)]
print(f"iowait lớn nhất trong E1 — đo mỗi 30 giây          : {cat(d30).cpu_iowait_pct.max():.1f} %")
print(f"iowait lớn nhất trong E1 — trung bình mỗi 5 phút    : {cat(d5).cpu_iowait_pct.max():.1f} %")
print(f"iowait lớn nhất trong E1 — trung bình mỗi 1 giờ     : {cat(d1h).cpu_iowait_pct_mean.max():.1f} %")
print(f"iowait lớn nhất trong E1 — 'lớn nhất' của mỗi 1 giờ : {cat(d1h).cpu_iowait_pct_max.max():.1f} %")

fig, t = plt.subplots(figsize=(14, 3.6))
t.plot(cat(d30).ts, cat(d30).cpu_iowait_pct, label="mỗi 30 giây", alpha=0.6)
t.plot(cat(d5).ts, cat(d5).cpu_iowait_pct, label="trung bình 5 phút", lw=2)
t.step(cat(d1h).ts, cat(d1h).cpu_iowait_pct_mean, where="post", label="trung bình 1 giờ", lw=3)
t.set_ylabel("iowait %"); t.legend(); t.grid(alpha=0.3); t.set_title("Càng gộp thô, đỉnh sự cố càng 'biến mất'")
t.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=d30.ts.dt.tz)); plt.show()

Kết quả: iowait lớn nhất trong E1: mỗi 30 giây: 97.5 % · trung bình 5 phút: 94.4 % · trung bình 1 giờ: 27.7 % · 'lớn nhất' của mỗi 1 giờ: 97.5 %

👉 Hiểu:

  • Cùng một sự cố: nhìn trung bình theo giờ chỉ còn 27,7% — trông như "hơi bận". Nhìn chi tiết là 97,5%: máy gần như chỉ ngồi chờ đĩa.
  • Gộp dữ liệu (để vẽ nhanh, lưu ít) luôn đánh đổi: muốn giữ sự cố thì phải giữ cả cột "lớn nhất", không chỉ trung bình.

Khuyến cáo khi tự triển khai:

  1. Giữ số liệu gốc (mỗi 15–30 giây) đủ lâu để kịp điều tra một sự cố — ít nhất vài tuần.
  2. Khi gộp để lưu lâu (5 phút, 1 giờ), mỗi khung giữ cả trung bình và lớn nhất (nếu được thêm phân vị 95). Trong Prometheus: tạo chuỗi gộp bằng avg_over_time(...) và max_over_time(...).
  3. Cảnh báo trên số liệu chi tiết (30 giây / 5 phút), không bao giờ cảnh báo trên trung bình giờ — trung bình giờ của sự cố E1 chỉ 27,7%, dưới mọi ngưỡng hợp lý.
  4. Biểu đồ nhiều ngày: vẽ đường "lớn nhất" cạnh đường "trung bình" — nhìn trung bình một mình sẽ không thấy đỉnh.

Phần 2 — Góc nhìn 24 giờ: máy chủ có "nhịp sinh học"

📍 Bước 3 — Hiểu dữ liệu để chọn mô hình: nếu máy chủ làm cùng một việc vào cùng một giờ mỗi ngày (backup, cron), thì một đỉnh lúc 00:00 có thể là bình thường — nhưng cùng đỉnh đó lúc 14:00 thì lạ.

In [ ]:
# 2.1 Mỗi giờ trong ngày: iowait "thường ngày" (trung vị) và "ngày xấu" (bách phân vị 95)
d30["gio"] = d30.ts.dt.hour
d30["phut_trong_ngay"] = d30.ts.dt.hour * 60 + d30.ts.dt.minute
theo_gio = d30.groupby("gio").cpu_iowait_pct.agg(trung_vi="median", p95=lambda s: s.quantile(0.95))
# 📦 theo_gio = mỗi giờ 0-23 một dòng: trung vị và p95 của iowait
print(theo_gio.round(2).T.to_string())

fig, truc = plt.subplots(1, 2, figsize=(15, 3.8))
truc[0].bar(theo_gio.index, theo_gio.p95, color="#DC2626", label="p95 (5% lúc tệ nhất)")
truc[0].bar(theo_gio.index, theo_gio.trung_vi, color="#2563EB", label="trung vị (thường ngày)")
truc[0].set_xlabel("giờ trong ngày"); truc[0].set_ylabel("iowait %"); truc[0].legend(); truc[0].grid(alpha=0.3, axis="y")
truc[0].set_title("iowait theo giờ: 4 cột cao bất thường")
# Từng khung 5 phút: khung nào có trung vị cao nhất?
khung5 = d30.assign(k=(d30.phut_trong_ngay // 5) * 5).groupby("k").cpu_iowait_pct.median()
truc[1].plot(khung5.index / 60, khung5.values, color="#1E3A5F")
truc[1].set_xlabel("giờ trong ngày (mỗi điểm = 5 phút)"); truc[1].set_ylabel("iowait % (trung vị 30 ngày)"); truc[1].grid(alpha=0.3)
truc[1].set_title("Trung vị từng khung 5 phút")
plt.tight_layout(); plt.show()
print("5 khung 5 phút có trung vị iowait cao nhất (phút tính từ 00:00):", khung5.sort_values(ascending=False).head(5).round(2).to_dict())

Kết quả: trung vị iowait mọi giờ chỉ khoảng 0,5–0,6%; nhưng p95 vọt lên ~23–25% đúng 4 giờ: 00h (24,68), 06h (23,11), 12h (24,15), 18h (23,19) — các giờ khác 2,4–5,5. Khung 5 phút có trung vị cao nhất: 00:00 = 19,56% (khung kế tiếp chỉ 1,37%).

👉 Hiểu:

  • 00:00 cao gần như mỗi đêm (trung vị 19,6% nghĩa là hơn nửa số đêm): khớp R1 — logrotate / backup chạy lúc 00:00. Đây là bình thường.
  • 4 giờ 00 / 06 / 12 / 18 — cách nhau đúng 6 tiếng — khớp R2 (pipeline chạy mỗi 6 giờ, đến 22/09).
  • Bài học chọn mô hình: một vạch ngưỡng duy nhất cho cả ngày sẽ báo động backup mỗi đêm. Cần so mỗi điểm với "bình thường của chính khung giờ đó" → bộ B ở Phần 5.

Phần 3 — Góc nhìn 1 tuần: Chủ nhật có gì khác?

📍 Bước 3 — Hiểu dữ liệu: sổ việc định kỳ có R4: backup Gitea mỗi Chủ nhật ~03:32–03:45 (chép dữ liệu sang ổ NAS). Nếu đúng, Chủ nhật lúc đó mạng gửi đi và đĩa đọc phải cao hơn hẳn các ngày khác.

In [ ]:
# 3.1 Bản đồ nhiệt: thứ trong tuần × giờ — mạng gửi đi (p95)
d30["thu"] = d30.ts.dt.dayofweek                     # 0 = Thứ Hai ... 6 = Chủ nhật
ten_thu = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]
bang_nhiet = d30.pivot_table(index="thu", columns="gio", values="net_eth0_tx_mbps", aggfunc=lambda s: s.quantile(0.95))
fig, t = plt.subplots(figsize=(15, 3.6))
anh = t.imshow(bang_nhiet.values, aspect="auto", cmap="magma")
t.set_yticks(range(7)); t.set_yticklabels(ten_thu); t.set_xticks(range(24)); t.set_xlabel("giờ trong ngày")
plt.colorbar(anh, ax=t, label="mạng gửi đi, p95 (Mbps)"); t.set_title("Mạng gửi đi theo thứ × giờ (4 tuần)"); plt.show()

# 3.2 So con số: 03:30–03:50 Chủ nhật vs các ngày khác
khung = d30.phut_trong_ngay.between(3 * 60 + 30, 3 * 60 + 50)
cn, khac = d30[khung & (d30.thu == 6)], d30[khung & (d30.thu != 6)]
so = pd.DataFrame({c: [cn[c].quantile(0.95), khac[c].quantile(0.95)]
                   for c in ["net_eth0_tx_mbps", "disk_sda_read_mbps", "cpu_iowait_pct"]}, index=["Chủ nhật 03:30–03:50", "ngày khác 03:30–03:50"]).round(2)
# 📦 so = bảng p95 của 3 chỉ số, Chủ nhật so với các ngày khác cùng khung giờ
print(so.to_string())
print("Số tuần trong dữ liệu (tuần ISO):", sorted(d30.ts.dt.isocalendar().week.unique().tolist()))

Kết quả:

                       net_eth0_tx_mbps  disk_sda_read_mbps  cpu_iowait_pct
Chủ nhật 03:30–03:50              14.28                7.86           32.72
ngày khác 03:30–03:50              0.10                0.01            2.31

Số tuần trong dữ liệu (tuần ISO): [37, 38, 39, 40, 41, 42] · bản đồ nhiệt: ô Chủ nhật, 3 giờ sáng rõ hơn hẳn.

👉 Hiểu:

  • Chủ nhật 03:30–03:50: mạng gửi đi cao ~140 lần, đĩa đọc cao ~800 lần so với cùng giờ ngày thường → đúng R4: backup Gitea sang NAS mỗi Chủ nhật.
  • Nếu chỉ dùng nền theo giờ trong ngày, mỗi Chủ nhật 03:30 sẽ bị báo "lạ". Muốn bỏ qua đúng việc này cần nền theo thứ + giờ — nhưng ta chỉ có khoảng 4 tuần (tuần 37 và 42 không đủ ngày): mỗi ô thứ × giờ chỉ có 4–5 mẫu, quá ít để học chắc.

Phần 4 — Góc nhìn 1 tháng: ngày nào bất thường, và "bình thường" có đổi không?

📍 Bước 3 + 6 — Hiểu dữ liệu và đánh giá: bản đồ nhiệt ngày × giờ cho cả 30 ngày, đánh dấu các đợt trong sổ sự cố. Theo sổ, ngày 22–23/09 honeypot được chuyển sang máy khác (S3) — "bình thường" của máy chủ có thể đổi hẳn từ đó.

In [ ]:
# 4.1 Bản đồ nhiệt ngày × giờ: iowait lớn nhất mỗi giờ
d30["ngay"] = d30.ts.dt.date                          # ngày thật (sắp xếp đúng thứ tự thời gian)
nhiet_thang = d30.pivot_table(index="ngay", columns="gio", values="cpu_iowait_pct", aggfunc="max")
nhiet_thang = nhiet_thang.reindex(columns=range(24))     # cột luôn là giờ 0..23 theo đúng thứ tự
# 📦 nhiet_thang = bảng ngày × giờ (0-23), mỗi ô = iowait lớn nhất trong giờ đó
fig, t = plt.subplots(figsize=(15, 9))
anh = t.imshow(nhiet_thang.values, aspect="auto", cmap="inferno", vmin=0, vmax=100)
t.set_yticks(range(len(nhiet_thang))); t.set_yticklabels([n.strftime("%d/%m") for n in nhiet_thang.index], fontsize=8)
t.set_xticks(range(24)); t.set_xlabel("giờ trong ngày")
plt.colorbar(anh, ax=t, label="iowait lớn nhất trong giờ (%)")
for _, r in su_co.iterrows():                          # khung xanh = đợt trong sổ sự cố
    ngay = r.bat_dau.date()
    if ngay in nhiet_thang.index:
        y = list(nhiet_thang.index).index(ngay)
        t.add_patch(plt.Rectangle((r.bat_dau.hour - 0.5, y - 0.5), max(1, r.ket_thuc.hour - r.bat_dau.hour + 1), 1, fill=False, ec="cyan", lw=2))
        t.text(r.ket_thuc.hour + 0.7, y, r.id, color="cyan", fontsize=8, va="center")
t.set_title("iowait theo ngày × giờ (khung xanh = đợt trong sổ sự cố)"); plt.show()

Kết quả: bản đồ 31 ngày × 24 giờ. Cột 0 giờ sáng màu gần như mọi ngày (R1). Các ô 05–06 giờ sáng rõ từ 14/09 đến 22/09 rồi nhạt hẳn từ 23/09. Khung xanh E1 trùng đúng ô sáng nhất lúc 17 giờ ngày 22/09; E3, E4 cũng trùng ô sáng. E5 (28/09) lại là dải tối.

👉 Hiểu:

  • Đổi màu từ 23/09 khớp với việc chuyển honeypot sang máy khác và tắt pipeline R2 (S3) — "bình thường" của máy chủ đã đổi.
  • E5 tối trên iowait vì E5 là đợt CPU cao và nóng, không phải đĩa: nhìn một chỉ số sẽ bỏ sót. Bộ phát hiện cần nhìn nhiều chỉ số.
  • Vẫn còn vài ô sáng không có khung (ví dụ tối 03/10, chiều 09/10, trưa 14/10): sổ sự cố chưa ghi — có thể là việc vận hành chưa được ghi lại.
In [ ]:
# 4.2 Tóm tắt từng ngày + "bình thường" trước / sau khi chuyển honeypot (S3, 22–23/09)
theo_ngay = d30.set_index("ts").resample("1D").agg({"load15": "max", "cpu_iowait_pct": "max", "mem_used_pct": "mean", "temp_max_c": "max"})
fig, truc = plt.subplots(4, 1, figsize=(15, 9), sharex=True)
for t, c, nhan in zip(truc, theo_ngay.columns, ["load15 lớn nhất", "iowait lớn nhất %", "RAM dùng trung bình %", "nhiệt độ lớn nhất °C"]):
    t.plot(theo_ngay.index, theo_ngay[c], marker="o", color="#1E3A5F"); t.set_ylabel(nhan, fontsize=9); t.grid(alpha=0.3)
    t.axvspan(pd.Timestamp("2026-09-22", tz="Asia/Ho_Chi_Minh"), pd.Timestamp("2026-09-24", tz="Asia/Ho_Chi_Minh"), color="#D97706", alpha=0.25)
    for _, r in su_co.iterrows():
        t.axvline(r.bat_dau, color="#DC2626", alpha=0.4, lw=1)
truc[0].set_yscale("log"); truc[0].set_title("Từng ngày (vạch đỏ = đợt trong sổ; dải cam = chuyển honeypot S3)")
plt.tight_layout(); plt.show()

truoc = d30[d30.ts < "2026-09-22 18:15+07:00"]; sau = d30[d30.ts >= "2026-09-23 00:00+07:00"]
doi = pd.DataFrame({"trước 22/09": [truoc.cpu_iowait_pct.quantile(0.95), truoc.mem_used_pct.mean(), truoc.swap_used_gib.max()],
                    "sau 23/09": [sau.cpu_iowait_pct.quantile(0.95), sau.mem_used_pct.mean(), sau.swap_used_gib.max()]},
                   index=["iowait p95 (%)", "RAM dùng trung bình (%)", "swap lớn nhất (GiB)"]).round(2)
# 📦 doi = "bình thường" trước và sau khi chuyển honeypot
print(doi.to_string())

Kết quả:

                         trước 22/09  sau 23/09
iowait p95 (%)                 20.45       2.72
RAM dùng trung bình (%)        69.20      60.47
swap lớn nhất (GiB)             2.89       1.74

Hình từng ngày: load15 ngày 22/09 vọt lên ~102 (trục log); nhiệt độ lớn nhất 82 °C ngày 28/09 (E5); RAM trung bình tăng đều từ ~56% (01/10) lên ~70% (13/10).

👉 Hiểu:

  • Sau khi chuyển honeypot, iowait "ngày xấu" giảm từ 20,45% xuống 2,72%, RAM giảm ~9 điểm. Một mô hình học "bình thường" từ trước 22/09 sẽ coi mọi thứ sau đó là quá êm — phải học lại nền sau mỗi thay đổi lớn.
  • RAM tăng đều suốt 2 tuần đầu tháng 10 không có trong sổ sự cố: đây là kiểu bất thường chậm (có thể là rò bộ nhớ) mà bộ phát hiện "điểm lạ" thường bỏ qua — vì không có điểm nào đột ngột. Cần nhìn xu hướng, không chỉ từng điểm.

Phần 4b — "Giữa các tháng trong năm": điểm mù dữ liệu

📍 Bước 1 + 5 — Thu thập và lưu ý: học viên muốn so tháng này với tháng khác trong năm. Câu hỏi đầu tiên luôn là: dữ liệu có đủ không?

  • Prometheus chỉ giữ 30 ngày — mọi thứ cũ hơn đã bị xoá. (Tối 14/10/2026 đã nâng lên 180 ngày: từ nay mới tích luỹ được, quá khứ đã mất thì mất hẳn.)
  • Hình dưới (theo khảo sát ngày 14/10/2026) cho thấy số liệu năm 2026 còn lại ở đâu: đứt đoạn, mỗi đoạn một cách đo khác nhau, và trống hẳn từ 01/04 đến 08/07.
  • Vì vậy bài này không vẽ "so sánh các tháng trong năm" — vẽ ra sẽ là bịa. Cách sửa thật: lưu số liệu liên tục (mỗi ngày gộp 5 phút / 1 giờ vào kho riêng) — một năm sau mới có góc nhìn cả năm.

Bài học nếu tự triển khai:

  • Quyết định thời gian lưu theo câu hỏi, ngay ngày đầu. Muốn so "tháng này với cùng kỳ năm trước" thì phải giữ hơn 12 tháng. Prometheus mặc định chỉ giữ 15 ngày (cờ --storage.tsdb.retention.time), máy chủ này để 30 ngày và không có ghi chép nào cho thấy đó là lựa chọn có chủ đích — nên phần cũ hơn đã mất.
  • Giữ một cách đo duy nhất. Mỗi đoạn trên hình đo bằng một công cụ khác, bước thời gian khác → ghép lại không so được. Đổi công cụ thì chạy song song một thời gian để nối hai đoạn.
  • Kho dài hạn tách khỏi máy được giám sát (gộp 5 phút / 1 giờ, giữ cả trung bình và lớn nhất — xem khuyến cáo ở 1.2). Máy chết thì số liệu của nó vẫn còn.
  • Ghi lại mọi thay đổi (chuyển dịch vụ, đổi cấu hình) — không có sổ thay đổi thì một "bước nhảy" như S3 sẽ bị đọc thành sự cố.

data_coverage

Phần 5 — Chọn mô hình và coding: 3 bộ phát hiện, chấm bằng sự cố thật

📍 Bước 3 + 4 + 6 — Chọn, code, đánh giá. Dùng số liệu 5 phút (đủ chi tiết, ít ồn hơn 30 giây). Mỗi bộ phát hiện đánh dấu các khung 5 phút "lạ"; các khung liền nhau (cách ≤ 15 phút) gộp thành một đợt báo động.

Cách chấm:

  • bắt được một sự cố = có ít nhất một đợt báo động chạm vào khoảng thời gian của sự cố đó;
  • đợt chưa giải thích = đợt báo động không trùng sự cố nào trong sổ. Chưa chắc là báo nhầm: sổ sự cố chỉ có những gì có người nhận ra và ghi lại — mà không phải lúc nào người dùng cũng biết máy đang treo (máy treo lúc 3 giờ sáng, hay treo vài phút rồi tự hồi, thì không ai kêu). Một số đợt "chưa giải thích" có thể là sự cố thật chưa được ghi. Vì vậy số đợt chưa giải thích là mức trần của báo nhầm, không phải số báo nhầm thật; muốn biết thì phải đi tìm thêm bằng chứng (log, khoảng trống số liệu, lịch chạy) rồi bổ sung vào sổ. Dù sao, mỗi đợt vẫn là một lần SOC phải đi xem;
  • đếm riêng các đợt bắt đầu 00:00–00:30 — giờ backup hằng ngày (R1).
Bộ phát hiện Ý tưởng
A. Ngưỡng cố định iowait > trung bình + 4 × độ lệch chuẩn của cả 30 ngày
B. Nền theo giờ trong ngày với mỗi khung 5 phút trong ngày, "bình thường" = trung vị của khung đó; điểm lạ khi lệch hơn k lần MAD ở bất kỳ chỉ số nào, kéo dài ít nhất vài khung
C. Isolation Forest nhìn 11 chỉ số cùng lúc, cô lập điểm khác biệt
In [ ]:
# 5.1 Chuẩn bị: 11 chỉ số, ô trống tạm lấp bằng số liền trước (chỉ để mô hình chạy được — đã ghi nhớ ở Phần 1)
CHI_SO = ["cpu_iowait_pct", "load15", "mem_used_pct", "cpu_user_pct", "cpu_system_pct", "swap_used_gib",
          "psi_io_some_pct", "disk_sda_io_time_pct", "net_eth0_rx_mbps", "temp_max_c", "procs_blocked"]
X = d5[CHI_SO].ffill().bfill()
so_ngay = (d5.ts.max() - d5.ts.min()).total_seconds() / 86400

def gop_dot(co, toi_thieu=1):
    # gộp các khung 5 phút bị đánh dấu (co = True) thành đợt; bỏ đợt ngắn hơn toi_thieu khung
    t = d5.ts[co]
    if t.empty: return []
    nhom = (t.diff() > pd.Timedelta("15min")).cumsum()
    return [(g.min(), g.max()) for _, g in t.groupby(nhom) if len(g) >= toi_thieu]

def cham(ten, dot):
    cham_su_co = lambda s, e: [r.id for _, r in su_co.iterrows() if s <= r.ket_thuc and e >= r.bat_dau]
    bat = sorted({i for s, e in dot for i in cham_su_co(s, e)})
    co_giai_thich = sum(1 for s, e in dot if cham_su_co(s, e))
    return {"bộ phát hiện": ten, "số đợt báo": len(dot), "đợt / ngày": round(len(dot) / so_ngay, 1),
            "bắt được": f"{len(bat)}/{len(su_co)}", "sự cố bắt được": " ".join(bat),
            "đợt chưa giải thích": len(dot) - co_giai_thich,
            "đợt lúc 00:00–00:30": sum(1 for s, e in dot if s.hour == 0 and s.minute <= 30)}
print(f"{len(X):,} khung 5 phút × {len(CHI_SO)} chỉ số, {so_ngay:.1f} ngày; {len(su_co)} sự cố để chấm")
In [ ]:
# 5.2 Bộ A — ngưỡng cố định trên iowait
nguong_A = X.cpu_iowait_pct.mean() + 4 * X.cpu_iowait_pct.std()
dot_A = gop_dot(X.cpu_iowait_pct > nguong_A)
print(f"Ngưỡng A = {nguong_A:.2f} % iowait")

# 5.3 Bộ B — nền theo giờ trong ngày
# ✏️ SỬA: K_MAD (bao nhiêu lần MAD thì coi là lạ) và TOI_THIEU (đợt phải dài ít nhất bao nhiêu khung 5 phút)
K_MAD = 20
TOI_THIEU = 3
NEN_TU = "2026-09-23 00:00+07:00"        # chỉ học "bình thường" từ sau khi chuyển honeypot (Phần 4.2)

khung = d5.ts.dt.hour * 12 + d5.ts.dt.minute // 5        # 288 khung 5 phút trong một ngày
lech = pd.DataFrame(index=d5.index)
for c in ["cpu_iowait_pct", "load15", "mem_used_pct", "cpu_user_pct", "temp_max_c"]:
    goc = X[c].where(d5.ts >= NEN_TU)                        # chỉ dùng dữ liệu "bình thường mới" để tính nền
    trung_vi = goc.groupby(khung).transform("median")
    mad = (goc - trung_vi).abs().groupby(khung).transform("median")
    lech[c] = (X[c] - trung_vi) / (1.4826 * mad + 1e-6 + 0.05 * X[c].abs().median())
diem_B = lech.max(axis=1)                                     # lệch nhiều nhất trong 5 chỉ số
dot_B = gop_dot(diem_B > K_MAD, TOI_THIEU)
# 📦 lech = mỗi khung 5 phút × 5 chỉ số: lệch khỏi "bình thường của khung giờ đó" bao nhiêu lần MAD

# 5.4 Bộ C — Isolation Forest trên 11 chỉ số
from sklearn.ensemble import IsolationForest
rung = IsolationForest(n_estimators=200, contamination=0.01, random_state=0).fit(X)
dot_C = gop_dot(pd.Series(rung.predict(X) == -1, index=d5.index))

bang_cham = pd.DataFrame([cham("A. ngưỡng cố định", dot_A), cham(f"B. nền theo giờ (k={K_MAD}, ≥{TOI_THIEU} khung)", dot_B),
                          cham("C. Isolation Forest (1%)", dot_C)])
print(bang_cham.to_string(index=False))

Kết quả: Ngưỡng A = 19.35 % iowait

                    bộ phát hiện  số đợt báo  đợt / ngày bắt được        sự cố bắt được  đợt chưa giải thích  đợt lúc 00:00–00:30
               A. ngưỡng cố định          90         2.9      5/8       E1 E2 E3 E4 E6b                   79                   13
B. nền theo giờ (k=20, ≥3 khung)          34         1.1      7/8 E1 E2 E3 E4 E5 E6b E7                   22                    0
        C. Isolation Forest (1%)          51         1.6      6/8    E1 E2 E3 E4 E6b E7                   38                    4

👉 Hiểu:

  • A (một vạch cho cả 30 ngày): 90 đợt, bắt 5/8, và 13 đợt rơi đúng giờ backup 00:00 — ca trực sẽ "chữa cháy" backup.
  • B (nền theo giờ, nhìn 5 chỉ số, đợt phải kéo dài ≥ 15 phút): ít báo nhất (34 đợt, ~1/ngày) mà bắt nhiều nhất (7/8), 0 đợt lúc backup. Bắt được E5 vì nhìn cả CPU và nhiệt độ. Bỏ sót E6a — đợt chỉ dài 5 phút, ngắn hơn điều kiện "≥ 3 khung".
  • C (Isolation Forest, nhìn 11 chỉ số): ở giữa; không bắt được E5 và vẫn báo 4 lần lúc backup — vì nó không biết giờ trong ngày.
  • Cả 3 bộ đều còn nhiều đợt chưa giải thích (22–79). Không phải tất cả là báo nhầm: Phần 4.1 đã thấy sổ sự cố thiếu, và không phải lúc nào người dùng cũng biết máy đang treo — một số đợt có thể là sự cố thật chưa ai ghi. Nên đọc 22–79 là mức trần của báo nhầm. Dù vậy, mỗi đợt vẫn là một lần SOC phải đi xem.
In [ ]:
# 5.5 Nhìn cả 30 ngày: mỗi bộ báo ở đâu, sự cố thật nằm ở đâu
fig, t = plt.subplots(figsize=(15, 3.8))
for y, (ten, dot, mau) in enumerate([("A ngưỡng cố định", dot_A, "#6B7280"), ("B nền theo giờ", dot_B, "#2563EB"), ("C Isolation Forest", dot_C, "#7C3AED")]):
    for s, e in dot:
        t.plot([s, e + pd.Timedelta("5min")], [y, y], lw=8, color=mau, solid_capstyle="butt")
    t.text(d5.ts.min(), y + 0.3, ten, fontsize=9, color=mau)
for _, r in su_co.iterrows():
    t.axvspan(r.bat_dau, r.ket_thuc + pd.Timedelta("5min"), color="#DC2626", alpha=0.25)
    t.text(r.bat_dau, 2.75, r.id, fontsize=8, color="#DC2626")
t.set_yticks([]); t.set_ylim(-0.6, 3.1); t.grid(alpha=0.3, axis="x")
t.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m", tz=d5.ts.dt.tz))
t.set_title("Đợt báo động của 3 bộ (thanh màu) so với sự cố trong sổ (dải đỏ)"); plt.show()

Kết quả: hình dòng thời gian 30 ngày, 3 hàng thanh màu (mỗi thanh = một đợt báo động) trên nền các dải đỏ (sự cố trong sổ):

  • Hàng xám (A): thanh dày đặc ở bên trái (trước 23/09), thưa hẳn bên phải.
  • Hàng xanh (B): ít thanh, phần lớn đứng ngay trên các dải đỏ E2, E1, E3, E4, E5.
  • Hàng tím (C): ở giữa — nhiều hơn B, có những thanh không nằm trên dải đỏ nào.

👉 Cách đọc: một bộ tốt là bộ có ít thanh và thanh nằm trên dải đỏ. Thanh nằm xa mọi dải đỏ = một lần SOC đi xem mà không tìm thấy sự cố đã biết. 5.5b đếm đúng những điều mắt thấy.

5.5b Đếm những gì hình trên cho thấy

Hình chỉ cho cảm giác; ô dưới đếm đúng ba điều cần đọc: (1) mỗi bộ báo bao nhiêu đợt trước và từ ngày 23/09 (ngày chuyển honeypot sang máy khác, thay đổi lớn S3), quy ra mỗi ngày để so công bằng (hai giai đoạn dài khác nhau); (2) bao nhiêu đợt chạm một dải đỏ (sự cố trong sổ); (3) bao nhiêu đợt xa mọi dải đỏ.

In [ ]:
MOC_S3 = pd.Timestamp("2026-09-23 00:00+07:00")            # ✏️ SỬA: thử mốc khác để xem đoạn nào ồn
ngay_truoc = (MOC_S3 - d5.ts.min()).total_seconds() / 86400
ngay_sau = (d5.ts.max() - MOC_S3).total_seconds() / 86400
cham_dai_do = lambda s, e: any(s <= r.ket_thuc and e >= r.bat_dau for _, r in su_co.iterrows())   # đợt có chạm sự cố nào không
dong = []
for ten, dot in [("A ngưỡng cố định", dot_A), ("B nền theo giờ", dot_B), ("C Isolation Forest", dot_C)]:
    truoc = sum(1 for s, e in dot if s < MOC_S3)
    trung = sum(1 for s, e in dot if cham_dai_do(s, e))
    dong.append({"bộ": ten, "đợt trước 23/09": truoc, "mỗi ngày (trước)": round(truoc / ngay_truoc, 1),
                 "đợt từ 23/09": len(dot) - truoc, "mỗi ngày (sau)": round((len(dot) - truoc) / ngay_sau, 1),
                 "đợt chạm dải đỏ": trung, "đợt xa mọi dải đỏ": len(dot) - trung})
bang_hinh = pd.DataFrame(dong)
# 📦 bang_hinh = mỗi bộ một hàng: số đợt trước / sau mốc S3 (và quy ra mỗi ngày), số đợt trùng / không trùng sự cố
print(f"Trước 23/09: {ngay_truoc:.1f} ngày | từ 23/09: {ngay_sau:.1f} ngày")
print(bang_hinh.to_string(index=False))

Kết quả: giai đoạn trước 23/09 dài 9,7 ngày, từ 23/09 dài 21,6 ngày.

bộ trước 23/09 (đợt / ngày) từ 23/09 (đợt / ngày) chạm dải đỏ xa mọi dải đỏ
A ngưỡng cố định 65 (6,7) 25 (1,2) 11 79
B nền theo giờ 17 (1,8) 17 (0,8) 12 22
C Isolation Forest 27 (2,8) 24 (1,1) 13 38

📦 bang_hinh = mỗi bộ một hàng: số đợt trước / từ mốc S3, quy ra mỗi ngày, số đợt chạm / không chạm sự cố.

👉 Hiểu:

  • Vì sao nửa đầu tháng "ồn"? Trước 23/09, honeypot còn chạy ngay trên máy chủ này: kẻ tấn công kết nối vào nó suốt ngày đêm, làm CPU, đĩa, mạng nhảy liên tục. Ngày 23/09 honeypot được chuyển sang máy khác (thay đổi lớn S3) → máy chủ êm hẳn. Bộ A dùng một vạch cho cả 30 ngày, nên báo 6,7 đợt/ngày lúc ồn nhưng chỉ 1,2 đợt/ngày lúc êm — gấp hơn 5 lần, cùng một máy, cùng một ngưỡng.
  • Bộ B ít bị ảnh hưởng nhất (1,8 → 0,8) vì nó so mỗi khung với nền của đúng khung giờ đó, học từ sau 23/09.
  • "Nói trúng": B có 12 / 34 đợt chạm sự cố (35%), C 13 / 51 (25%), A chỉ 11 / 90 (12%). Bộ A nói nhiều nhất mà trúng ít nhất.
  • Bài học: khi máy đổi vai trò (chuyển dịch vụ đi / đến), "bình thường" đổi theo → mọi ngưỡng phải học lại. Không học lại thì hoặc báo động liên tục, hoặc im lặng bỏ sót.
In [ ]:
# 5.6 Cái bập bênh: đổi K_MAD và TOI_THIEU của bộ B — bắt nhiều hơn thì báo nhiều hơn
hang = []
for k in [10, 20, 30]:
    for toi_thieu in [1, 2, 3]:
        hang.append(cham(f"k={k}, ≥{toi_thieu} khung", gop_dot(diem_B > k, toi_thieu)))
bap_benh = pd.DataFrame(hang)
print(bap_benh[["bộ phát hiện", "số đợt báo", "đợt / ngày", "bắt được", "đợt chưa giải thích"]].to_string(index=False))
fig, t = plt.subplots(figsize=(7, 4.5))
t.scatter(bap_benh["đợt / ngày"], bap_benh["bắt được"].str.split("/").str[0].astype(int), s=80, color="#2563EB")
for _, r in bap_benh.iterrows():
    t.annotate(r["bộ phát hiện"], (r["đợt / ngày"], int(r["bắt được"].split("/")[0])), fontsize=8, xytext=(4, 4), textcoords="offset points")
t.set_xlabel("số đợt báo động mỗi ngày (SOC phải đi xem)"); t.set_ylabel(f"số sự cố bắt được / {len(su_co)}")
t.grid(alpha=0.3); t.set_title("Bắt thêm sự cố = phải đọc thêm báo động"); plt.show()

Kết quả:

  bộ phát hiện  số đợt báo  đợt / ngày bắt được  đợt chưa giải thích
k=10, ≥1 khung         295         9.4      8/8                  279
k=10, ≥2 khung         170         5.4      8/8                  156
k=10, ≥3 khung          94         3.0      8/8                   80
k=20, ≥1 khung         205         6.5      8/8                  192
k=20, ≥2 khung         113         3.6      8/8                  100
k=20, ≥3 khung          34         1.1      7/8                   22
k=30, ≥1 khung         157         5.0      6/8                  146
k=30, ≥2 khung          74         2.4      6/8                   63
k=30, ≥3 khung          26         0.8      6/8                   15

👉 Hiểu — cái bập bênh (như Lab 10):

  • Muốn bắt đủ 8/8 thì phải chịu 3–9 đợt báo mỗi ngày. Siết lại còn < 1 đợt/ngày thì bỏ sót 2 sự cố.
  • Điều kiện "kéo dài ≥ 3 khung" cắt báo động nhiều nhất (295 → 94 ở k=10) mà gần như không mất sự cố: phần lớn "điểm lạ" chỉ là gai 5 phút.
  • Không có cấu hình "đúng": chọn theo sức người của ca trực và giá của một sự cố bị bỏ sót.

Phần 6 — Đánh giá trung thực: vì sao điểm đẹp có thể là điểm ảo

📍 Bước 5 + 6 — Lưu ý khi coding và đánh giá: giả sử ta muốn một mô hình có giám sát (Random Forest) học "khung 5 phút này có nằm trong sự cố không". Cùng dữ liệu, cùng mô hình — chỉ khác cách chia phần học / phần thi.

Ví dụ đời thường: thầy giáo ra đề thi bằng cách rút ngẫu nhiên câu hỏi từ chính tập bài tập đã phát cho học sinh làm ở nhà. Học sinh học thuộc đáp án là được điểm cao — nhưng gặp câu hỏi mới thì không làm được. Điểm thi đó không đo được học sinh có hiểu bài hay không.

Với số liệu máy chủ cũng y như vậy:

  • Số liệu đo liên tục: tình trạng máy lúc 10:00 và lúc 10:05 gần như giống nhau (máy đang bận thì 5 phút sau vẫn bận). Hai khung liền nhau như hai câu hỏi gần trùng đề.
  • Chia ngẫu nhiên (xáo trộn rồi chia 80% học / 20% thi): khung 10:05 có thể vào phần thi trong khi khung 10:00 và 10:10 nằm trong phần học. Mô hình chỉ cần nhớ "quanh 10:00 hôm đó là sự cố" là trả lời đúng — nó không phải hiểu sự cố trông thế nào.
  • Chia theo thời gian (học các ngày trước, thi các ngày sau): giống đúng tình huống thật — hôm nay mô hình phải đoán về ngày mai mà nó chưa từng thấy.

Hình dưới vẽ hai cách chia; 6.1 đo xem hai khung liền nhau giống nhau đến mức nào, rồi chấm cùng một mô hình theo cả hai cách.

split_diagram

In [ ]:
# 6.1 Hàng xóm giống nhau cỡ nào? (tự tương quan: 1 = giống hệt, 0 = không liên quan)
print(f"Tự tương quan giữa 2 khung 5 phút liền nhau — iowait: {X.cpu_iowait_pct.autocorr(1):.2f} | load15: {X.load15.autocorr(1):.2f}")

# 6.2 Nhãn: khung 5 phút nằm trong một đợt của sổ sự cố
y = pd.Series(0, index=d5.index); ten_su_co = pd.Series("", index=d5.index)
for _, r in su_co.iterrows():
    m = (d5.ts >= r.bat_dau) & (d5.ts <= r.ket_thuc); y[m] = 1; ten_su_co[m] = r.id
print(f"Khung 5 phút có nhãn 'sự cố': {int(y.sum())} / {len(y):,} ({y.mean():.1%})")

from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import precision_score, recall_score, f1_score
def do(y_that, du_doan):
    return {"precision": round(precision_score(y_that, du_doan, zero_division=0), 3),
            "recall": round(recall_score(y_that, du_doan, zero_division=0), 3), "F1": round(f1_score(y_that, du_doan, zero_division=0), 3)}

# Cách 1: chia NGẪU NHIÊN 70/30
X_hoc, X_thi, y_hoc, y_thi = train_test_split(X, y, test_size=0.3, random_state=0, shuffle=True, stratify=y)
rung_1 = RandomForestClassifier(n_estimators=200, random_state=0, n_jobs=2).fit(X_hoc, y_hoc)
kq_ngau_nhien = do(y_thi, rung_1.predict(X_thi))

# Cách 2: chia THEO THỜI GIAN — học trước ngày cắt, thi sau ngày cắt
# ✏️ SỬA: ngày cắt
NGAY_CAT = "2026-09-24 00:00+07:00"
hoc = d5.ts < NGAY_CAT
rung_2 = RandomForestClassifier(n_estimators=200, random_state=0, n_jobs=2).fit(X[hoc], y[hoc])
kq_thoi_gian = do(y[~hoc], rung_2.predict(X[~hoc]))
print(f"Sự cố trong phần học: {sorted(ten_su_co[hoc & (y == 1)].unique())} | trong phần thi: {sorted(ten_su_co[~hoc & (y == 1)].unique())}")

ket_qua = pd.DataFrame([kq_ngau_nhien, kq_thoi_gian], index=["chia NGẪU NHIÊN", f"chia THEO THỜI GIAN (cắt {NGAY_CAT[:10]})"])
print(ket_qua.to_string())
ket_qua.plot.barh(figsize=(10, 3), xlim=(0, 1), title="Cùng dữ liệu, cùng mô hình — chỉ khác cách chia", color=["#2563EB", "#059669", "#D97706"])
plt.grid(alpha=0.3, axis="x"); plt.show()

Kết quả: Tự tương quan giữa 2 khung 5 phút liền nhau — iowait: 0.58 | load15: 0.79 · Khung 5 phút có nhãn 'sự cố': 213 / 9,019 (2.4%) · Sự cố trong phần học: ['E1', 'E2', 'E6a', 'E6b', 'E7'] | trong phần thi: ['E3', 'E4', 'E5']

                                      precision  recall     F1
chia NGẪU NHIÊN                           0.941   0.750  0.835
chia THEO THỜI GIAN (cắt 2026-09-24)      0.056   0.009  0.016

👉 Hiểu:

  • Cùng dữ liệu, cùng mô hình: chia ngẫu nhiên được F1 = 0,835 — trông rất đẹp. Chia theo thời gian (học trước 24/09, thi sau) chỉ còn F1 = 0,016.
  • Lý do 1 — lộ đề: hai khung 5 phút liền nhau rất giống nhau (tự tương quan 0,58–0,79). Chia ngẫu nhiên thì mỗi khung thi đều có "anh em sinh đôi" trong phần học: mô hình nhớ, không hiểu.
  • Lý do 2 — sự cố tương lai khác sự cố quá khứ: học từ E1, E2… (treo máy, hết RAM) rồi thi trên E3, E4, E5 (backup, CPU nóng). Với 8 sự cố, mô hình có giám sát không thể học khái niệm "sự cố" chung.
  • Con số dùng để quyết định phải là con số chia theo thời gian — giống ngày mai mô hình sẽ gặp.
In [ ]:
# 6.3 Thêm 3 cái bẫy trung thực trong chính bài này
# (a) Sổ sự cố: bao nhiêu đợt thật sự ĐÃ XÁC NHẬN?
print("Mức chắc chắn của các sự cố dùng để chấm:", su_co.status.value_counts().to_dict())
# (b) "Bình thường" đổi sau khi chuyển honeypot: Isolation Forest học TRƯỚC 22/09 rồi dùng SAU 23/09
truoc_doi = d5.ts < "2026-09-22 00:00+07:00"; sau_doi = d5.ts >= "2026-09-23 00:00+07:00"
rung_cu = IsolationForest(n_estimators=200, contamination=0.01, random_state=0).fit(X[truoc_doi])
co_cu = pd.Series(rung_cu.predict(X) == -1, index=d5.index)
print(f"Isolation Forest học trước 22/09 — tỉ lệ báo động: trước {co_cu[truoc_doi].mean():.1%} | sau 23/09 {co_cu[sau_doi].mean():.1%}")
# (c) Nền của bộ B được tính trên CHÍNH 30 ngày đang chấm (không có phần thi riêng)
print("Nền bộ B tính từ:", NEN_TU[:10], "đến hết dữ liệu — cùng khoảng thời gian dùng để chấm")

Kết quả: Mức chắc chắn của các sự cố dùng để chấm: {'LIKELY': 5, 'CONFIRMED': 1, 'HYPOTHESIS': 1, 'UNEXPLAINED': 1} · Isolation Forest học trước 22/09 — tỉ lệ báo động: trước 1.0% | sau 23/09 0.8% · Nền bộ B tính từ: 2026-09-23 đến hết dữ liệu — cùng khoảng thời gian dùng để chấm

👉 Hiểu — 3 điều phải nói thật khi báo cáo kết quả Phần 5:

  • (a) Nhãn yếu: chỉ 1/8 sự cố là CONFIRMED. "Bắt được 7/8" nghĩa là "trùng với 7 đợt mà ta nghĩ là sự cố".
  • (b) Thay đổi lớn có thể đi theo cả hai hướng: ở đây máy chủ êm hơn sau 23/09, nên mô hình học từ trước báo ít hơn (1,0% → 0,8%) — nó có thể bỏ sót sự cố mới vì "ngưỡng" của nó quen với thời ồn ào. (Ở một mô hình khác của chính máy chủ này, khảo sát 14/10 thấy điều ngược lại: tỉ lệ báo động vọt lên 61,6–93,2% sau khi hệ thống đổi.) Cả hai đều là lý do phải học lại nền.
  • (c) Chấm trên chính dữ liệu đã học: nền của bộ B được tính trên cùng 3 tuần dùng để chấm — trung vị và MAD ít bị vài sự cố kéo lệch, nhưng điểm số vẫn lạc quan hơn khi gặp tháng sau.

Phần 8 — Ai gây ra sự cố? Truy tìm service "thủ phạm"

📍 Bước 1 → 6 lặp lại ở mức service: Phần 5 trả lời lúc nào máy chủ bất thường. Câu SOC hỏi tiếp luôn là: service nào gây ra?

5 câu hỏi về dữ liệu service Trả lời
Dữ liệu gì? CPU, RAM, đọc / ghi đĩa, mạng của từng container trên DietPi (cowrie, prometheus, grafana, postgres, redis…), cộng một cột _host_outside_containers = mọi thứ chạy thẳng trên máy chủ (cron, script, cơ sở dữ liệu cài thẳng…); host = cả máy
Lấy ở đâu? Prometheus của DietPi (bộ đo container), mỗi ~1 phút; lịch cron / systemd timer của máy chủ (schedule.csv); ảnh chụp tiến trình mà máy chủ tự ghi khi iowait ≥ 15% (process_snapshots.csv — chỉ tên tiến trình)
Thu thập thế nào? xuất bằng export_dietpi_services.py ngày 14/10/2026 — chỉ đọc
Có nhãn chưa? không — ta so mỗi service với chính nó lúc bình thường
Có cần làm sạch? đã làm khi xuất: CPU container là mẫu 1 giây (nhiễu), số đĩa được tính lại từ bộ đếm, container đã dừng (cowrie từ 22/09, open-webui từ 24/09) để trống chứ không giữ số cũ

Cách truy (giống bác sĩ so chỉ số với "bình thường của chính bệnh nhân"): trong khoảng thời gian sự cố, mỗi service dùng bao nhiêu — trừ đi mức nó vẫn dùng cùng khung giờ đó ở các ngày khác. Service nào vượt nhiều nhất là nghi phạm số 1.

In [ ]:
# 8.1 Nạp dữ liệu service
sv = pd.read_csv(f"{THU_MUC}/services/services_1min.csv.gz", parse_dates=["ts"])
lich = pd.read_csv(f"{THU_MUC}/services/schedule.csv", keep_default_na=False)
anh_chup = pd.read_csv(f"{THU_MUC}/services/process_snapshots.csv", parse_dates=["ts"])
COT = [c for c in sv.columns if "__" in c]                      # tên cột dạng "<service>__<chỉ số>"
DICH_VU = sorted({c.split("__")[0] for c in COT} - {"host"})
# 📦 sv = mỗi phút 1 dòng, mỗi service vài cột · lich = lịch cron/timer · anh_chup = tiến trình ghi đĩa nhiều khi iowait ≥ 15%
print(f"{len(sv):,} phút, {len(DICH_VU)} service (gồm '_host_outside_containers'): {', '.join(DICH_VU)}")

def vuot_muc(ma_su_co):
    # mỗi service: trung bình trong sự cố − trung vị của cùng khung giờ ở các ngày khác
    r = su_co[su_co.id == ma_su_co].iloc[0]
    a, b = r.bat_dau, max(r.ket_thuc, r.bat_dau + pd.Timedelta("10min"))
    trong = sv[(sv.ts >= a) & (sv.ts <= b)]
    phut = sv.ts.dt.hour * 60 + sv.ts.dt.minute
    cung_gio = (phut >= a.hour * 60 + a.minute) & (phut <= b.hour * 60 + b.minute)
    ngay_khac = sv[cung_gio & (sv.ts.dt.date != a.date())]
    binh_thuong = ngay_khac.groupby(ngay_khac.ts.dt.date)[COT].mean().median()
    hang = []
    for dv in DICH_VU:
        h = {"service": dv}
        for chi_so, ten in [("cpu_pct", "CPU vượt (% 1 lõi)"), ("mem_mib", "RAM vượt (MiB)")]:
            c = f"{dv}__{chi_so}"
            if c in sv.columns: h[ten] = trong[c].mean() - binh_thuong[c]
        h["đĩa vượt (MB/s)"] = sum(trong[f"{dv}__{x}"].mean() - binh_thuong[f"{dv}__{x}"]
                                  for x in ["io_read_mbs", "io_write_mbs"] if f"{dv}__{x}" in sv.columns)
        hang.append(h)
    return pd.DataFrame(hang).set_index("service").round(2), a, b

# ✏️ SỬA: chọn sự cố để truy (E1, E2, E3, E4, E5, E6b, E7)
SU_CO_TRUY = "E2"
bang_vuot, tu, den = vuot_muc(SU_CO_TRUY)
print(f"{SU_CO_TRUY}: {tu:%d/%m %H:%M} → {den:%H:%M} | sổ ghi: {su_co.set_index('id').loc[SU_CO_TRUY, 'title']}")
print(bang_vuot.sort_values("CPU vượt (% 1 lõi)", ascending=False).head(5).to_string())
fig, truc = plt.subplots(1, 3, figsize=(16, 4))
for t, cot, mau in zip(truc, bang_vuot.columns, ["#2563EB", "#7C3AED", "#D97706"]):
    top = bang_vuot[cot].sort_values(ascending=False).head(6)[::-1]
    t.barh(top.index, top.values, color=[("#DC2626" if v > 0 else "#9CA3AF") for v in top.values]); t.set_title(cot); t.grid(alpha=0.3, axis="x")
plt.suptitle(f"{SU_CO_TRUY}: service nào VƯỢT mức bình thường của chính nó?"); plt.tight_layout(); plt.show()

Kết quả (E2): 45,140 phút, 20 service (gồm '_host_outside_containers') · E2: 15/09 08:15 → 15:00 | sổ ghi: Swap thrashing in 5 bursts (honeypot container memory pressure)

                     CPU vượt (% 1 lõi)  RAM vượt (MiB)  đĩa vượt (MB/s)
service
cowrie                             0.52           97.83             1.79
mikrotik-prometheus                0.14           15.03             0.02
liveview                           0.09            0.31             0.00

👉 Hiểu:

  • Ở E2, cowrie (container honeypot) vượt mức bình thường của chính nó nhiều nhất ở cả 3 chỉ số: RAM thêm ~98 MiB, đĩa thêm ~1,8 MB/s. Trong lúc đó RAM của cowrie chạm 512 MiB — đúng mức giới hạn đặt cho container (trung bình 444 MiB): container bị ép, phải đẩy sang swap.
  • Dữ liệu ủng hộ lời ghi trong sổ ("honeypot container memory pressure").
  • ✏️ Thử sửa: SU_CO_TRUY = "E1" hoặc "E5" — bạn sẽ thấy dòng đầu là _host_outside_containers: thủ phạm nằm ngoài mọi container.
In [ ]:
# 8.2 Nhìn tận mắt: CPU từng service quanh sự cố (cộng dồn)
khung_xem = sv[(sv.ts >= tu - pd.Timedelta("2h")) & (sv.ts <= den + pd.Timedelta("2h"))].set_index("ts")
cpu = khung_xem[[f"{dv}__cpu_pct" for dv in DICH_VU if f"{dv}__cpu_pct" in sv.columns]].clip(lower=0).fillna(0)
cpu.columns = [c.split("__")[0].replace("_host_outside_containers", "ngoài container (chạy thẳng trên máy)") for c in cpu.columns]
# (tên bắt đầu bằng "_" bị matplotlib giấu khỏi chú thích, nên đổi tên cho dễ đọc)
top6 = cpu.mean().sort_values(ascending=False).head(6).index
ve = cpu[top6].copy(); ve["(các service khác)"] = cpu.drop(columns=top6).sum(axis=1)
ve = ve.rolling(5, min_periods=1).mean()                         # làm mượt 5 phút cho dễ nhìn
fig, t = plt.subplots(figsize=(15, 4.5))
t.stackplot(ve.index, ve.T.values, labels=ve.columns, alpha=0.85)
t.axvspan(tu, den, color="#DC2626", alpha=0.12, label=f"{SU_CO_TRUY} trong sổ")
t.set_ylabel("CPU (% của 1 lõi, cộng dồn)"); t.legend(loc="upper left", fontsize=8); t.grid(alpha=0.3)
t.xaxis.set_major_formatter(mdates.DateFormatter("%d/%m %H:%M", tz=sv.ts.dt.tz)); t.set_title("Ai đang 'ăn' CPU trước, trong và sau sự cố?")
plt.show()

Kết quả: biểu đồ CPU cộng dồn 15/09 06:00 → 17:00. Dải lớn nhất ở đáy (~12–15% một lõi, gần như phẳng) là "ngoài container"; trong khung E2 có nhiều gai đỏ của cowrie, cao nhất quanh 12:45.

👉 Hiểu: "bình thường" của máy chủ đã có sẵn một lớp nền từ các chương trình chạy thẳng trên máy. Thủ phạm hiện ra khi một màu phình lên đúng trong khung sự cố và xẹp xuống sau đó — ở đây là cowrie.

In [ ]:
# 8.3 Lúc đó có lịch chạy gì? + tiến trình nào ghi đĩa nhiều (ảnh chụp khi iowait ≥ 15%)
import re
MAU_CRON = re.compile(r"^(\*|\d+(-\d+)?)(/\d+)?(,(\*|\d+(-\d+)?)(/\d+)?)*$")    # một ô cron hợp lệ
THU = {"Mon": 0, "Tue": 1, "Wed": 2, "Thu": 3, "Fri": 4, "Sat": 5, "Sun": 6}

def khop(o, v, lo, hi):
    # o là một ô lịch (vd "*/6", "0,12", "1-5"); v có nằm trong đó không?
    if o == "*": return True
    for phan in o.split(","):
        buoc = 1
        if "/" in phan: phan, buoc = phan.split("/"); buoc = int(buoc)
        day = range(lo, hi + 1) if phan == "*" else (range(int(phan.split("-")[0]), int(phan.split("-")[1]) + 1) if "-" in phan else [int(phan)])
        if v in list(day)[::buoc]: return True
    return False

def doc_lich(bieu_thuc):
    # trả về danh sách hàm t -> True/False; None nếu không đọc được (lịch "sau khi khởi động", hoặc viết sai)
    luat = []
    for e in bieu_thuc.split(";"):
        f = e.split()
        if len(f) == 5 and all(MAU_CRON.match(x) for x in f):                      # cron: phút giờ ngày tháng thứ
            luat.append(lambda t, f=f: khop(f[0], t.minute, 0, 59) and khop(f[1], t.hour, 0, 23) and khop(f[2], t.day, 1, 31)
                        and khop(f[3], t.month, 1, 12) and khop(f[4], (t.dayofweek + 1) % 7, 0, 6))
        elif len(f) >= 2 and f[-2].endswith("*-*-*") and f[-1].count(":") >= 1:   # systemd: [Thứ] *-*-* giờ:phút
            gio, phut = f[-1].split(":")[:2]; thu = THU.get(f[0])
            if not (MAU_CRON.match(gio) and MAU_CRON.match(phut)): return None
            luat.append(lambda t, gio=gio, phut=phut, thu=thu: (thu is None or t.dayofweek == thu) and khop(gio, t.hour, 0, 23) and khop(phut, t.minute, 0, 59))
        else:
            return None
    return luat

def viec_trong(a, b):
    ds = []
    for _, j in lich[lich.active == "YES"].iterrows():
        L = doc_lich(j.schedule_expr)
        if L is None: continue
        luc = next((t for t in pd.date_range(a.floor("min"), b, freq="1min") if any(f(t) for f in L)), None)
        if luc is not None: ds.append({"giờ chạy": luc.strftime("%H:%M"), "việc": j.job, "làm gì": j.what_it_does})
    return pd.DataFrame(ds).sort_values("giờ chạy") if ds else pd.DataFrame()

khong_doc_duoc = [(j.job, j.schedule_expr) for _, j in lich.iterrows() if doc_lich(j.schedule_expr) is None]
print(f"Lịch không đọc được ({len(khong_doc_duoc)}):", khong_doc_duoc)
viec = viec_trong(tu - pd.Timedelta("5min"), den)
print(f"{len(viec)} việc có lịch chạy trong {SU_CO_TRUY} (từ 5 phút trước):")
print(viec.head(12).to_string(index=False))
chup = anh_chup[(anh_chup.ts >= tu) & (anh_chup.ts <= den)]
if len(chup):
    tien_trinh = (chup.assign(dia_kb_s=chup.kb_read_s.fillna(0) + chup.kb_write_s.fillna(0)).groupby("process")
                  .dia_kb_s.agg(["mean", "count"]).sort_values("mean", ascending=False).head(6).round(0))
    print(f"\n{len(chup)} dòng ảnh chụp tiến trình trong {SU_CO_TRUY}; tiến trình ghi/đọc đĩa nhiều nhất (KB/s trung bình, số lần thấy):")
    print(tien_trinh.to_string())
else:
    print(f"\nKhông có ảnh chụp tiến trình nào trong {SU_CO_TRUY} (iowait chưa lên 15%, hoặc giám sát bị 'mù').")

Kết quả: Lịch không đọc được (2): ghidra-cleanup.timer, systemd-tmpfiles-clean.timer (lịch kiểu "sau khi khởi động / sau lần chạy trước", không gắn giờ cố định) · 30 việc có lịch chạy trong E2 (ví dụ monitor-pc-v3, nextcloud_cron, web-attack-alert mỗi phút…) · 237 dòng ảnh chụp tiến trình: cowrie xuất hiện 13 lần (~330 KB/s); claude (~10.900 KB/s) và incident-analyzer (~5.900 KB/s) mỗi cái chỉ 1 lần.

👉 Hiểu:

  • 30 việc định kỳ chạy trong một khung sự cố → nhìn lịch một mình không thể chỉ ra ai. Lịch chỉ giúp loại trừ hoặc gợi ý.
  • Ảnh chụp tiến trình: đọc cả cột số lần thấy. Một tiến trình ghi đĩa rất mạnh nhưng chỉ bị chụp 1 lần là một cú gai ngắn; cowrie bị chụp 13 lần là áp lực kéo dài — khớp với phần 8.1.
In [ ]:
# 8.4 Truy cả 7 sự cố một lượt: nghi phạm số 1 theo CPU, RAM, đĩa — đặt cạnh lời ghi trong sổ
tong = []
for ma in ["E1", "E2", "E3", "E4", "E5", "E6b", "E7"]:
    b_, a_, z_ = vuot_muc(ma)
    top = lambda c: f"{b_[c].idxmax()} (+{b_[c].max():.1f})" if b_[c].max() > 0.05 else "không service nào vượt"
    tong.append({"sự cố": ma, "mức chắc": su_co.set_index("id").loc[ma, "status"], "CPU vượt nhất": top("CPU vượt (% 1 lõi)"),
                 "RAM vượt nhất": top("RAM vượt (MiB)"), "đĩa vượt nhất": top("đĩa vượt (MB/s)"),
                 "sổ ghi": su_co.set_index("id").loc[ma, "title"][:55]})
bang_tong_dv = pd.DataFrame(tong)
# 📦 bang_tong_dv = mỗi sự cố 1 dòng: service vượt mức nhất theo 3 chỉ số + lời ghi trong sổ
print(bang_tong_dv.to_string(index=False))

Kết quả:

sự cố    mức chắc                    CPU vượt nhất                     RAM vượt nhất                   đĩa vượt nhất
   E1   CONFIRMED  _host_outside_containers (+6.5) _host_outside_containers (+278.4) _host_outside_containers (+0.9)
   E2      LIKELY                    cowrie (+0.5)                    cowrie (+97.8)                   cowrie (+1.8)
   E3      LIKELY _host_outside_containers (+10.2) _host_outside_containers (+335.9) _host_outside_containers (+3.6)
   E4  HYPOTHESIS _host_outside_containers (+19.4) _host_outside_containers (+531.5) _host_outside_containers (+2.2)
   E5 UNEXPLAINED _host_outside_containers (+76.9)           fr02-prometheus (+10.8)          không service nào vượt
  E6b      LIKELY  _host_outside_containers (+7.1)  _host_outside_containers (+86.7) _host_outside_containers (+1.2)
   E7      LIKELY  _host_outside_containers (+7.6) _host_outside_containers (+279.1) _host_outside_containers (+0.7)

👉 Hiểu:

  • Chỉ E2 chỉ thẳng được vào một container (cowrie). 6/7 sự cố còn lại: thủ phạm nằm ngoài container — chương trình chạy thẳng trên máy chủ.
  • E1 (máy treo): khớp với lời sổ ghi "một lần đo hiệu năng chạy trên máy" — nhưng dữ liệu không gọi được tên chương trình đó.
  • E4: sổ đoán "tạo lại container"; dữ liệu cho thấy các container không thay đổi, phần vượt nằm ngoài container → giả thuyết không được ủng hộ.
  • E5 (CPU ngoài container +77% một lõi suốt nhiều giờ, đĩa không ai vượt): một chương trình ăn CPU chạy thẳng trên máy — ta biết ở đâu nhưng không biết là ai. Phần 8.5 thử cải thiện cách truy, Phần 8.6 giải thích vì sao vẫn chưa gọi được tên.

8.5 Cải thiện cách truy: từ "ai vượt nhiều nhất" đến "ai đủ bằng chứng"

Cách truy ở 8.1–8.4 có 4 điểm yếu, đều đã thấy bằng số ở trên:

Điểm yếu Thấy ở đâu Cách sửa trong 8.5
So bằng số thô: CPU tính bằng %, RAM bằng MiB, đĩa bằng MB/s → không so được với nhau; service vốn hay dao động luôn trông như "vượt nhiều" bảng 8.1 8.5a điểm z bền: chia phần vượt cho độ dao động thường ngày của chính service đó
Không có thứ tự thời gian → không tách được thủ phạm (lên trước) với nạn nhân (bị kéo theo, lên sau) 8.2 chỉ nhìn bằng mắt 8.5b ai lên trước — từng phút
Lịch một mình chỉ ra hàng chục việc; ảnh chụp tiến trình chỉ thấy vài tên 8.3 8.5c bảng nghi phạm 3 bằng chứng, cần ít nhất 2/3 mới gọi tên
Chỉ E2 có nguyên nhân ghi trong sổ → cách truy chưa được kiểm tra 8.4 8.5d chạy mù trên E2 + thử "giả dược" ở lúc bình thường

Điểm z bền là gì? Giống bộ B ở Phần 5, nhưng cho từng service:

z = (trung bình trong sự cố − trung vị của cùng khung giờ ở các ngày khác) / (1,4826 × MAD của các ngày khác)

  • trung vị và MAD (độ lệch trung vị) không bị vài ngày cực lớn kéo lệch — vài sự cố khác lọt vào "ngày khác" cũng không làm hỏng nền;
  • 1,4826 × MAD ≈ "độ lệch chuẩn" nếu dữ liệu bình thường → z = 3 nghĩa là vượt 3 lần độ dao động thường ngày;
  • có một sàn (SAN): service gần như đứng yên có MAD ≈ 0, chia cho số ≈ 0 sẽ ra z khổng lồ vô nghĩa.

Cách đọc hình 8.5a:

Thành phần Nghĩa
Bên trái xếp hạng theo số thô của 8.1 (CPU vượt, % một lõi)
Bên phải xếp hạng theo z lớn nhất trong 3 chỉ số (CPU, RAM, đĩa)
Vạch đứt đỏ NGUONG_Z — vượt vạch này mới tính là "bất thường so với chính nó"
In [ ]:
# 8.5a Điểm z "bền": mỗi service vượt bao nhiêu lần "độ dao động thường ngày" CỦA CHÍNH NÓ
CHI_SO_DV = {"cpu_pct": "CPU", "mem_mib": "RAM", "io_mbs": "đĩa"}
# ✏️ SỬA: SAN = độ dao động tối thiểu của từng chỉ số (tránh chia cho số gần 0 khi service gần như đứng yên)
SAN = {"cpu_pct": 0.5, "mem_mib": 10, "io_mbs": 0.05}
# ✏️ SỬA: NGUONG_Z = vượt bao nhiêu lần độ dao động thường ngày thì coi là "bất thường"
NGUONG_Z = 3

# gộp đọc + ghi đĩa thành 1 cột "<service>__io_mbs" (min_count=1: cả 2 cột trống thì kết quả vẫn là trống)
dia = {f"{dv}__io_mbs": sv[[f"{dv}__{x}" for x in ["io_read_mbs", "io_write_mbs"] if f"{dv}__{x}" in sv.columns]].sum(axis=1, min_count=1)
       for dv in DICH_VU if f"{dv}__io_read_mbs" in sv.columns or f"{dv}__io_write_mbs" in sv.columns}
sv = pd.concat([sv.drop(columns=[c for c in dia if c in sv.columns]), pd.DataFrame(dia)], axis=1)   # chạy lại ô này cũng không bị trùng cột
COT_Z = [f"{dv}__{cs}" for dv in DICH_VU for cs in CHI_SO_DV if f"{dv}__{cs}" in sv.columns]
SAN_COT = pd.Series({c: SAN[c.split("__")[1]] for c in COT_Z})        # sàn của từng cột, theo loại chỉ số
PHUT = sv.ts.dt.hour * 60 + sv.ts.dt.minute                            # phút trong ngày của từng dòng (0..1439)
NGAY = sv.ts.dt.date                                                   # ngày của từng dòng
# 📦 COT_Z = các cột "<service>__cpu_pct / mem_mib / io_mbs" · SAN_COT = sàn cho từng cột đó
print(f"{len(COT_Z)} cột để chấm z, ví dụ: {COT_Z[:3]}")

def diem_z(a, b):
    # z = (trung bình trong [a, b] − trung vị các ngày khác cùng giờ) / max(1,4826 × MAD, sàn)
    trong = sv.loc[(sv.ts >= a) & (sv.ts <= b), COT_Z].mean()
    cung_gio = (PHUT >= a.hour * 60 + a.minute) & (PHUT <= b.hour * 60 + b.minute) & (NGAY != a.date())
    moi_ngay = sv.loc[cung_gio, COT_Z].groupby(NGAY[cung_gio]).mean()   # mỗi ngày khác: 1 dòng trung bình cùng khung giờ
    trung_vi = moi_ngay.median()
    mad = (moi_ngay - trung_vi).abs().median()
    z = (trong - trung_vi) / np.maximum(1.4826 * mad, SAN_COT)
    z[moi_ngay.notna().sum() < 5] = np.nan                              # ít hơn 5 ngày để so (vd container đã dừng) -> không chấm
    bang = pd.DataFrame({ten: [z.get(f"{dv}__{cs}", np.nan) for dv in DICH_VU] for cs, ten in CHI_SO_DV.items()}, index=DICH_VU)
    bang.index.name = "service"
    bang["z lớn nhất"] = bang.max(axis=1)                              # chỉ số nào lệch nhiều nhất của service đó
    return bang.round(1)

bang_z = diem_z(tu, den)
# hạng 1 = vượt nhiều nhất; so hạng theo số thô (8.1) với hạng theo z
bang_z["hạng theo số thô (CPU, 8.1)"] = bang_vuot["CPU vượt (% 1 lõi)"].rank(ascending=False, method="min").astype("Int64")
bang_z["hạng theo z"] = bang_z["z lớn nhất"].rank(ascending=False, method="min").astype("Int64")
# 📦 bang_z = mỗi service 1 dòng: z của CPU, RAM, đĩa, z lớn nhất, và 2 cách xếp hạng
print(f"{SU_CO_TRUY}: 8 service có z lớn nhất (z ≥ {NGUONG_Z} = bất thường so với chính nó)")
print(bang_z.sort_values("z lớn nhất", ascending=False).head(8).to_string())
print(f"Số service có z ≥ {NGUONG_Z}: {int((bang_z['z lớn nhất'] >= NGUONG_Z).sum())} / {len(bang_z)}")

ten_de_doc = lambda s: s.replace("_host_outside_containers", "ngoài container (chạy thẳng trên máy)")
fig, truc = plt.subplots(1, 2, figsize=(16, 4.2))
tho = bang_vuot["CPU vượt (% 1 lõi)"].sort_values(ascending=False).head(8)[::-1]
truc[0].barh([ten_de_doc(s) for s in tho.index], tho.values, color="#9CA3AF"); truc[0].set_title("Theo số thô: CPU vượt (% một lõi)")
zz = bang_z["z lớn nhất"].sort_values(ascending=False).head(8)[::-1]
truc[1].barh([ten_de_doc(s) for s in zz.index], zz.values, color=[("#DC2626" if v >= NGUONG_Z else "#2563EB") for v in zz.values])
truc[1].axvline(NGUONG_Z, color="#DC2626", ls="--"); truc[1].set_title("Theo z lớn nhất (đỏ = vượt NGUONG_Z)")
for t in truc: t.grid(alpha=0.3, axis="x")
plt.suptitle(f"{SU_CO_TRUY}: số thô và điểm z xếp hạng service có giống nhau không?"); plt.tight_layout(); plt.show()

Kết quả (E2): 60 cột để chấm z · Số service có z ≥ 3: 1 / 20

                          CPU  RAM   đĩa  z lớn nhất  hạng theo số thô (CPU, 8.1)  hạng theo z
cowrie                    1.0  2.3  35.5        35.5                            1            1
open-webui                0.1  1.5  -0.0         1.5                            5            2
_host_outside_containers -0.7 -0.4   1.3         1.3                           20            3
mikrotik-prometheus       0.3  0.8   0.3         0.8                            2            4

👉 Hiểu:

  • cowrie đứng đầu theo cả hai cách, và là service duy nhất (1/20) vượt z ≥ 3 — nhưng chỉ số lệch mạnh nhất là đĩa (z = 35,5), không phải RAM. Ở 8.1, RAM của cowrie thêm ~98 MiB trông rất lớn, nhưng RAM của nó vốn dao động nhiều giữa các ngày nên chỉ là z = 2,3.
  • Các hạng sau đổi chỗ: mikrotik-prometheus hạng 2 theo CPU thô nhưng chỉ hạng 4 theo z (z = 0,8 — nằm trong dao động thường ngày). "Ngoài container" hạng 20 theo CPU thô (CPU còn thấp hơn thường ngày) nhưng hạng 3 theo z (nhờ đĩa, z = 1,3).
  • z trả lời câu "vượt bao nhiêu lần bình thường của chính nó" — cùng một thước đo cho CPU, RAM và đĩa. Bảng 8.5c tính z cho cả 7 sự cố.

8.5b Ai "lên" trước?

Một service gây sự cố thường lên trước; service bị kéo theo (thiếu RAM, chờ đĩa) lên sau. Ta xét từng phút, từ TRUOC_PHUT phút trước lúc sổ ghi bắt đầu đến hết sự cố:

  • nền riêng cho từng phút trong ngày (trung vị + MAD của đúng phút đó ở các ngày khác);
  • số liệu làm mượt 5 phút (CPU container chỉ đo 1 giây mỗi vòng nên rất nhiễu);
  • một service "bắt đầu vượt" khi z ≥ NGUONG_Z liền KEO_DAI phút — giống điều kiện "kéo dài" của bộ B, để bỏ gai lẻ.

Cách đọc hình 8.5b:

Thành phần Nghĩa
Mỗi hàng một service có lúc vượt; xếp từ lên sớm nhất (trên cùng)
Vạch nhỏ một phút service đó có z ≥ NGUONG_Z
Chấm to phút bắt đầu vượt (đầu chuỗi KEO_DAI phút liền)
Dải đỏ / vạch đen khoảng sự cố trong sổ / lúc sổ ghi bắt đầu
In [ ]:
# 8.5b Ai lên trước? Xét từng phút quanh sự cố
# ✏️ SỬA: TRUOC_PHUT = nhìn sớm bao nhiêu phút trước lúc sổ ghi · KEO_DAI = phải vượt liền bao nhiêu phút mới tính
TRUOC_PHUT = 30
KEO_DAI = 3
MUOT = sv[COT_Z].rolling(5, min_periods=3).mean()            # trung bình trượt 5 phút cho từng cột
# 📦 MUOT = cùng hình dạng với sv[COT_Z] (mỗi phút 1 dòng), nhưng đã làm mượt

def ai_len_truoc(a, b):
    t0 = a - pd.Timedelta(minutes=TRUOC_PHUT)
    cung_gio = (PHUT >= t0.hour * 60 + t0.minute) & (PHUT <= b.hour * 60 + b.minute)
    khac = MUOT[cung_gio & (NGAY != a.date())]               # cùng khung giờ, các ngày khác
    k = PHUT[khac.index]
    trung_vi = khac.groupby(k).median()                        # nền của TỪNG phút trong ngày
    mad = (khac - trung_vi.loc[k].values).abs().groupby(k).median()
    m = (sv.ts >= t0) & (sv.ts <= b)
    kt = PHUT[m]
    z = (MUOT[m] - trung_vi.reindex(kt).values) / np.maximum(1.4826 * mad.reindex(kt).values, SAN_COT.values)
    hang, vuot_dv = [], {}
    for dv in DICH_VU:
        zd = z[[c for c in COT_Z if c.startswith(dv + "__")]].max(axis=1)   # z lớn nhất của service này, từng phút
        vuot = zd >= NGUONG_Z
        du_dai = vuot.rolling(KEO_DAI).sum() >= KEO_DAI                     # True khi đã vượt liền KEO_DAI phút
        vuot_dv[dv] = vuot
        if du_dai.any():
            dau = vuot.index[vuot.index.get_loc(du_dai.idxmax()) - KEO_DAI + 1]   # phút ĐẦU của chuỗi vượt đầu tiên
            hang.append({"service": dv, "lúc": sv.ts[dau], "so với sổ (phút)": round((sv.ts[dau] - a).total_seconds() / 60),
                         "số phút vượt": int(vuot.sum())})
    bang = pd.DataFrame(hang, columns=["service", "lúc", "so với sổ (phút)", "số phút vượt"]).sort_values("lúc")
    return bang, pd.DataFrame(vuot_dv).set_index(pd.DatetimeIndex(sv.ts[m]))

len_truoc, vuot_tung_phut = ai_len_truoc(tu, den)
# 📦 len_truoc = mỗi service có vượt 1 dòng: lúc bắt đầu vượt, sớm/muộn bao nhiêu phút so với sổ, tổng số phút vượt
print(f"{SU_CO_TRUY}: sổ ghi bắt đầu {tu:%d/%m %H:%M}. Thứ tự service bắt đầu vượt (âm = sớm hơn sổ):")
print(len_truoc.assign(lúc=len_truoc["lúc"].dt.strftime("%d/%m %H:%M")).to_string(index=False))

fig, t = plt.subplots(figsize=(15, 0.6 * max(len(len_truoc), 2) + 1.5))
for y, (_, r) in enumerate(len_truoc.iloc[::-1].iterrows()):
    phut_vuot = vuot_tung_phut.index[vuot_tung_phut[r.service].values]
    t.scatter(phut_vuot, [y] * len(phut_vuot), marker="|", s=120, color="#2563EB")
    t.scatter([r["lúc"]], [y], s=90, color="#DC2626", zorder=3)
t.set_yticks(range(len(len_truoc))); t.set_yticklabels([ten_de_doc(s) for s in len_truoc.service[::-1]])
t.axvspan(tu, den, color="#DC2626", alpha=0.12); t.axvline(tu, color="black", lw=1)
t.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M", tz=sv.ts.dt.tz)); t.grid(alpha=0.3)
t.set_title(f"{SU_CO_TRUY}: ai bắt đầu vượt trước? (chấm đỏ = lúc bắt đầu vượt)"); plt.tight_layout(); plt.show()

Kết quả (E2, sổ ghi bắt đầu 15/09 08:15):

                 service         lúc  so với sổ (phút)  số phút vượt
_host_outside_containers 15/09 08:01               -14            88
     mikrotik-prometheus 15/09 08:01               -14            15
                  cowrie 15/09 08:07                -8           309
        fr02-postgres-v2 15/09 11:26               191            20
           aios-agentui2 15/09 12:40               265             5

Hình: vạch của cowrie gần như phủ kín cả khoảng sự cố; vạch của "ngoài container" rải rác từng cụm ngắn.

👉 Hiểu:

  • Cả 3 service đầu đều vượt trước giờ sổ ghi (8–14 phút) — giờ bắt đầu trong sổ có thể ghi muộn; khi truy, luôn nhìn sớm hơn giờ trong sổ.
  • "Ngoài container" lên trước cowrie 6 phút — nhưng số liệu đã làm mượt 5 phút, nên chênh vài phút chưa đủ để nói ai trước. Nhìn thêm cột số phút vượt: cowrie 309 phút (áp lực kéo dài suốt sự cố), "ngoài container" 88 phút, rải rác — và z của cả khoảng chỉ 1,3 (8.5a).
  • fr02-postgres-v2, aios-agentui2 vượt hàng giờ sau và rất ngắn → nhiều khả năng là bị kéo theo hoặc không liên quan — đây vẫn là giả thuyết, không phải kết luận.
  • Bài học: đọc "ai lên trước" cùng với "kéo dài bao lâu"; một cột riêng lẻ dễ đánh lừa.

8.5c Bảng nghi phạm: 3 bằng chứng, cần ít nhất 2/3

Một con số đơn lẻ dễ đánh lừa. Ta chỉ gọi tên một service khi có ít nhất 2 trong 3 bằng chứng độc lập:

Bằng chứng Lấy từ Có khi
(i) vượt mức 8.5a z lớn nhất ≥ NGUONG_Z
(ii) có việc định kỳ schedule.csv một việc của service đó có lịch chạy trong sự cố
(iii) bị chụp process_snapshots.csv tiến trình của service đó có trong ảnh chụp tiến trình

Gán việc / tiến trình cho service — chỉ khi dữ liệu cho phép, không đoán:

  • Mọi dòng của schedule.csv là cron / systemd timer của máy chủ (cột kind) → tiến trình của chúng chạy ngoài container. Dữ liệu không cho biết một việc có "chọc" vào container nào không → với các container, bằng chứng (ii) là "không xác định" (không tính).
  • Tên tiến trình trùng tên một việc trong lịch → ngoài container; trùng đúng tên một container → container đó; còn lại (ví dụ python3, prometheus — có 2 container prometheus, chưa kể máy chủ) → "không xác định".
In [ ]:
# 8.5c Bảng nghi phạm 3 bằng chứng
TEN_VIEC = set(lich.job)

def tien_trinh_thuoc(p):
    # tên tiến trình -> service, CHỈ khi suy ra được từ chính dữ liệu
    if p in TEN_VIEC: return "_host_outside_containers"    # trùng tên một việc cron/timer của máy chủ -> chạy thẳng trên máy
    if p in DICH_VU: return p                                # trùng đúng tên một container
    return "không xác định"

ban_do = pd.Series({p: tien_trinh_thuoc(p) for p in anh_chup.process.unique()})
# 📦 ban_do = mỗi tên tiến trình (trong ảnh chụp) -> service, hoặc "không xác định"
print(f"{len(ban_do)} tên tiến trình -> service:", ban_do.value_counts().to_dict())
print("Ví dụ 'không xác định':", ", ".join(ban_do[ban_do == "không xác định"].index[:10]))

def bang_nghi_pham(a, b):
    z = diem_z(a, b)["z lớn nhất"]
    co_viec = len(viec_trong(a - pd.Timedelta("5min"), b)) > 0           # có việc cron/timer nào chạy không (8.3)
    chup = anh_chup[(anh_chup.ts >= a) & (anh_chup.ts <= b)]
    thay = chup.process.map(tien_trinh_thuoc).value_counts()              # mỗi service bị chụp bao nhiêu lần
    hang = []
    for dv in DICH_VU:
        b1 = bool(z.get(dv) >= NGUONG_Z)
        b2 = co_viec if dv == "_host_outside_containers" else None       # None = không xác định
        b3 = int(thay.get(dv, 0))
        so = int(b1) + int(bool(b2)) + int(b3 > 0)
        if so:
            hang.append({"service": dv, "(i) z lớn nhất": z.get(dv), "(ii) việc định kỳ": {True: "có", False: "không", None: "không xác định"}[b2],
                         "(iii) số lần bị chụp": b3, "số bằng chứng": so, "kết luận": "NGHI PHẠM" if so >= 2 else "chưa đủ"})
    return pd.DataFrame(hang, columns=["service", "(i) z lớn nhất", "(ii) việc định kỳ", "(iii) số lần bị chụp", "số bằng chứng", "kết luận"]
                        ).sort_values(["số bằng chứng", "(i) z lớn nhất"], ascending=False)

print()
print(f"{SU_CO_TRUY}: các service có ít nhất 1 bằng chứng")
print(bang_nghi_pham(tu, den).to_string(index=False))

# Cả 7 sự cố một lượt
tong_nghi = []
for ma in ["E1", "E2", "E3", "E4", "E5", "E6b", "E7"]:
    r = su_co.set_index("id").loc[ma]
    a_, b_ = r.bat_dau, max(r.ket_thuc, r.bat_dau + pd.Timedelta("10min"))
    bn = bang_nghi_pham(a_, b_)
    nghi = bn[bn["kết luận"] == "NGHI PHẠM"]
    tong_nghi.append({"sự cố": ma, "z lớn nhất": diem_z(a_, b_)["z lớn nhất"].max(),
                      "nghi phạm (≥ 2/3)": ", ".join(f"{s} ({n}/3)" for s, n in zip(nghi.service, nghi["số bằng chứng"])) or "chưa đủ bằng chứng",
                      "sổ ghi": r.title[:50]})
bang_7 = pd.DataFrame(tong_nghi)
# 📦 bang_7 = mỗi sự cố 1 dòng: z lớn nhất trong mọi service, các nghi phạm đủ 2/3, lời ghi trong sổ
print()
print(bang_7.to_string(index=False))

Kết quả: 107 tên tiến trình -> service: {'không xác định': 92, '_host_outside_containers': 13, 'cowrie': 1, 'portainer': 1} — ví dụ "không xác định": mariadbd, prometheus, cron, influxd, dockerd, python3 (unmapped), redis-server…

E2: cowrie — (i) z 35,5 · (ii) không xác định · (iii) 13 lần → 2/3 NGHI PHẠM · "ngoài container" — (i) z 1,3 · (ii) có · (iii) 6 lần → 2/3 NGHI PHẠM

sự cố  z lớn nhất                            nghi phạm (≥ 2/3)
   E1         6.5               _host_outside_containers (3/3)
   E2        35.5 cowrie (2/3), _host_outside_containers (2/3)
   E3        22.7               _host_outside_containers (2/3)
   E4        16.5               _host_outside_containers (3/3)
   E5        38.3               _host_outside_containers (2/3)
  E6b         5.0               _host_outside_containers (3/3)
   E7         8.1               _host_outside_containers (2/3)

👉 Hiểu:

  • Chỉ 15/107 tên tiến trình gán được cho service từ chính dữ liệu; 92 tên còn lại không xác định (ví dụ prometheus có thể là 2 container khác nhau hoặc máy chủ). Không đoán — đó là giới hạn thật của dữ liệu.
  • Mọi sự cố đều gọi tên "ngoài container", vì bằng chứng (ii) của nó luôn "có": lịch có việc chạy mỗi phút (ví dụ web-attack-alert), nên khoảng nào cũng có việc định kỳ. (ii) ở đây gần như là bằng chứng cho không — 8.5d đo hậu quả.
  • Container thì không bao giờ có (ii), nên phải có cả z cao lẫn bị chụp mới đủ 2/3 — chỉ cowrie ở E2 đạt.
  • Bảng không gọi tên được chương trình cụ thể nào ngoài container (E1, E5) — đúng giới hạn của số liệu hiện có (Phần 9).

8.5d Kiểm tra trung thực: cách truy này có đáng tin không?

Hai phép thử — giống cách Phần 6 kiểm tra mô hình:

  • (a) Chạy mù trên E2: chỉ đưa giờ bắt đầu / kết thúc, không đọc lời ghi trong sổ. Kết quả có chỉ ra cowrie (container honeypot) như sổ ghi không?
  • (b) "Giả dược": bốc thăm các khoảng cùng độ dài với từng sự cố, ở lúc bình thường (cách mọi sự cố trong sổ ≥ 2 giờ). Ở đó không có sự cố nào được ghi → mọi "nghi phạm" tìm được là nghi oan. Tỉ lệ nghi oan cho biết một kết quả "có nghi phạm" đáng tin đến đâu.

Cách đọc hình 8.5d: mỗi chấm là z lớn nhất (của mọi service) trong một khoảng. Hàng trên = 7 sự cố thật, hàng dưới = các khoảng giả dược. Trục ngang thang log. Nếu hai hàng chồng lên nhau ở một ngưỡng, ngưỡng đó không phân biệt được sự cố với lúc bình thường.

In [ ]:
# 8.5d (a) Chạy mù trên E2: chỉ dùng giờ, không đọc lời ghi
r = su_co.set_index("id").loc["E2"]
mu = bang_nghi_pham(r.bat_dau, r.ket_thuc)
print("E2 chạy mù — nghi phạm (≥ 2/3):", mu[mu["kết luận"] == "NGHI PHẠM"][["service", "(i) z lớn nhất", "số bằng chứng"]].to_dict("records"))
print("Sổ ghi (chỉ mở ra SAU khi chạy):", r.title)

# 8.5d (b) Giả dược: khoảng cùng độ dài, ở lúc bình thường
# ✏️ SỬA: SO_CUA_SO = số khoảng giả cho mỗi sự cố · HAT_GIONG = đổi số này để bốc thăm bộ khoảng khác
SO_CUA_SO = 5
HAT_GIONG = 0
ngau_nhien = np.random.default_rng(HAT_GIONG)
ban = [(x.bat_dau - pd.Timedelta("2h"), x.ket_thuc + pd.Timedelta("2h")) for _, x in su_co.iterrows()]   # vùng cấm quanh sự cố
gia = []
for ma in bang_7["sự cố"]:
    x = su_co.set_index("id").loc[ma]
    dai = max(x.ket_thuc - x.bat_dau, pd.Timedelta("10min"))
    so_phut = int((sv.ts.max() - sv.ts.min() - dai).total_seconds() // 60)
    duoc = 0
    while duoc < SO_CUA_SO:
        s = (sv.ts.min() + pd.Timedelta(minutes=int(ngau_nhien.integers(0, so_phut)))).floor("min"); e = s + dai
        if s.date() != e.date() or any(s <= y2 and e >= y1 for y1, y2 in ban): continue   # không qua nửa đêm, không chạm sự cố
        bn = bang_nghi_pham(s, e); z = diem_z(s, e)["z lớn nhất"]
        nghi = bn[bn["kết luận"] == "NGHI PHẠM"]
        gia.append({"dài như": ma, "bắt đầu": s.strftime("%d/%m %H:%M"), "z lớn nhất": z.max(), "service z lớn nhất": z.idxmax(),
                    "có z ≥ NGUONG_Z": bool(z.max() >= NGUONG_Z), "nghi phạm (≥ 2/3)": ", ".join(nghi.service) or "-"})
        duoc += 1
bang_gia = pd.DataFrame(gia)
# 📦 bang_gia = mỗi khoảng giả dược 1 dòng: z lớn nhất, service đó, có bị gọi tên nghi phạm không
print()
print(bang_gia.to_string(index=False))
print()
print(f"Giả dược: {bang_gia['có z ≥ NGUONG_Z'].mean():.0%} số khoảng bình thường có service z ≥ {NGUONG_Z}; "
      f"{(bang_gia['nghi phạm (≥ 2/3)'] != '-').mean():.0%} số khoảng bị gọi tên nghi phạm (≥ 2/3)")
print("Service bị nghi oan nhiều nhất:", bang_gia.loc[bang_gia["nghi phạm (≥ 2/3)"] != "-", "nghi phạm (≥ 2/3)"].value_counts().to_dict())

# 8.5d (c) Ngưỡng z: sự cố thật vs giả dược
nguong = pd.DataFrame([{"ngưỡng z": k, "sự cố thật có z ≥ ngưỡng": f"{(bang_7['z lớn nhất'] >= k).sum()}/{len(bang_7)}",
                        "giả dược có z ≥ ngưỡng": f"{(bang_gia['z lớn nhất'] >= k).sum()}/{len(bang_gia)}"} for k in [3, 5, 10]])
print()
print(nguong.to_string(index=False))

fig, t = plt.subplots(figsize=(12, 3.2))
t.scatter(bang_7["z lớn nhất"].clip(lower=0.1), [1] * len(bang_7), s=80, color="#DC2626", label="sự cố thật")
for _, x in bang_7.iterrows(): t.annotate(x["sự cố"], (max(x["z lớn nhất"], 0.1), 1), xytext=(0, 8), textcoords="offset points", fontsize=8, ha="center")
t.scatter(bang_gia["z lớn nhất"].clip(lower=0.1), [0] * len(bang_gia), s=50, color="#6B7280", alpha=0.7, label="giả dược (lúc bình thường)")
t.axvline(NGUONG_Z, color="#DC2626", ls="--", label=f"NGUONG_Z = {NGUONG_Z}")
t.set_xscale("log"); t.set_yticks([0, 1]); t.set_yticklabels(["giả dược", "sự cố thật"]); t.set_ylim(-0.6, 1.6)
t.set_xlabel("z lớn nhất trong khoảng (thang log)"); t.grid(alpha=0.3, axis="x"); t.legend(loc="lower right", fontsize=8)
t.set_title("Sự cố thật và lúc bình thường: ngưỡng nào tách được hai hàng?"); plt.tight_layout(); plt.show()

Kết quả:

  • (a) E2 chạy mù: nghi phạm = cowrie (z 35,5, 2/3) và "ngoài container" (z 1,3, 2/3). Mở sổ sau khi chạy: "honeypot container memory pressure" → cowrie khớp.
  • (b) Giả dược — 35 khoảng bình thường (5 khoảng cho mỗi độ dài của 7 sự cố): 14/35 = 40% có service z ≥ 3; 14/35 = 40% bị gọi tên nghi phạm — cả 14 lần đều là "ngoài container"; 4 trong số đó có z dưới 3 (bị gọi tên chỉ nhờ (ii) + (iii)).
  • (c) Ngưỡng z:
 ngưỡng z sự cố thật có z ≥ ngưỡng giả dược có z ≥ ngưỡng
        3                      7/7                  14/35
        5                      7/7                   4/35
       10                      4/7                   1/35

Hình: 7 chấm đỏ đều ở z ≥ 5,0 (thấp nhất E6b); phần lớn chấm xám dưới 5, nhưng có một chấm xám 14,9 (mikrotik-prometheus, 26/09 12:56) — lúc không có sự cố nào trong sổ.

👉 Hiểu — nói thật về cách truy này:

  • E2 chạy mù trúng cowrie, nhưng quy tắc 2/3 cũng gọi tên "ngoài container" chỉ với z 1,3. Đọc cả cột z, đừng chỉ đếm bằng chứng.
  • Chỉ 1 sự cố có nhãn nguyên nhân → trúng 1/1 chưa chứng minh cách truy đúng cho sự cố khác.
  • Ở NGUONG_Z = 3, 40% lúc bình thường cũng "có nghi phạm": một kết quả "có nghi phạm" chưa phải bằng chứng có sự cố.
  • Nâng ngưỡng lên 5 giữ 7/7 mà giả dược còn 4/35 — nhưng ngưỡng 5 được chọn sau khi nhìn chính 7 sự cố này, nên con số này lạc quan (giống cái bẫy (c) ở Phần 6). Phải thử lại trên sự cố tháng sau.

8.6 Điểm mù khi truy "thủ phạm" — và phải sửa gì từ trước

Điểm mù trong dữ liệu của ta Hệ quả Sửa thế nào (phải làm trước khi có sự cố)
Chương trình chạy thẳng trên máy chủ (cron, script, cơ sở dữ liệu cài thẳng) chỉ có một cột gộp _host_outside_containers biết "ngoài container" nhưng không gọi được tên (E1, E5) đo từng tiến trình / từng service systemd liên tục (ví dụ process-exporter, số liệu cgroup của systemd)
Ảnh chụp tiến trình chỉ có khi iowait ≥ 15% và xếp theo đĩa thủ phạm ăn CPU mà ít dùng đĩa không bao giờ bị chụp chụp theo cả CPU và theo lịch (ví dụ mỗi phút khi load cao)
Lúc máy quá tải, bộ đo container bỏ sót số container trong lúc treo là thấp hơn thật đặt bộ đo ở mức ưu tiên cao; cảnh báo khi bộ đo bị "mù"
Nhật ký hệ thống (journal) chỉ giữ vài ngày không đối chiếu được lỗi dịch vụ của tháng trước giữ journal / log dịch vụ lâu hơn, hoặc chuyển về kho tập trung
Hàng chục việc định kỳ chạy trong mỗi khung sự cố lịch chạy một mình không chỉ ra được ai kết hợp lịch + ai vượt mức + sổ thay đổi

👉 Bài học: câu "service nào gây ra?" chỉ trả lời được nếu từ trước đã thu thập số liệu theo từng service. Thiết kế giám sát là quyết định trước khi có sự cố.

Phần 9 — Về nhà tự triển khai: làm thế nào, được tới đâu, cần gì

📍 Bước 7 — Quy tắc bàn tay phải, mang về nhà. Phần 0–8 dùng số liệu của một máy chủ thật. Phần này gom lại thành phương pháp để bạn dựng cho máy chủ của mình — và nói rõ: không phải cách nào cũng được. Mỗi mức giám sát trả lời được một số câu hỏi, và không bao giờ trả lời được câu hỏi của mức trên nó, dù mô hình có giỏi đến đâu.

9.1 Thang 4 mức giám sát

Mức Cần dựng (công cụ · nhịp đo · giữ bao lâu) Trả lời được Vẫn KHÔNG trả lời được Bằng chứng trong bài này
1. Cả máy node_exporter → Prometheus; đo ≤ 30 giây; giữ ≥ 30 ngày (muốn so tháng / năm: thêm kho lưu dài hạn bản gộp 5 phút / 1 giờ, có cột lớn nhất) LÚC NÀO máy chủ bất thường; việc định kỳ nào là bình thường AI gây ra Phần 1–6: bắt được các đợt E, tách backup 00:00 khỏi sự cố; Phần 4b: không có số liệu cả năm
2. + từng container bộ đo container (ví dụ cAdvisor) vào cùng Prometheus; nhãn = tên container container nào vượt mức chương trình chạy thẳng trên máy (cron, script, cơ sở dữ liệu cài thẳng) — chỉ thấy một cột gộp E2 → cowrie; 6/7 sự cố còn lại chỉ ra "ngoài container" (8.4)
3. + từng tiến trình / service systemd số liệu liên tục theo tiến trình hoặc service systemd (ví dụ process-exporter, hoặc bật đo CPU / RAM / đĩa theo service của systemd); ảnh chụp tiến trình theo cả CPU lẫn đĩa gọi tên chương trình chạy thẳng trên máy vì sao nó chạy (ai bật, có phải việc bảo trì không) đúng thứ E1, E5 còn thiếu (8.4, 8.6)
4. + sổ thay đổi, log dài hạn, cảnh báo "giám sát bị mù" lịch việc định kỳ + sổ thay đổi (ai làm gì, lúc nào); log tập trung giữ ≥ 30 ngày; cảnh báo khi Prometheus không hỏi được máy xác nhận nguyên nhân, tách thủ phạm / nạn nhân, không báo nhầm "sự cố an ninh" cho việc của chính mình (đủ để điều tra — còn lại là kỷ luật ghi chép) E1 là benchmark của chính đội vận hành, chỉ biết nhờ sổ; 33,5 phút "mù" ở E1 không ai được báo (Phần 1)

Máy chủ DietPi trong bài đang có gì (đọc cấu hình thật ngày 14/10/2026, chỉ đọc):

Mức Đã có Còn thiếu
1 Prometheus hỏi node_exporter mỗi 30 giây, giữ 30 ngày lúc làm bài (tối 14/10 nâng lên 180 ngày) kho lưu dài hạn (nên Phần 4b không so được các tháng)
2 một bộ đo container tự viết, đọc docker stats; CPU container chỉ là mẫu 1 giây mỗi vòng ~50 giây, và bỏ sót container khi máy quá tải bộ đo container ổn định, ưu tiên cao
3 chỉ có ảnh chụp tiến trình (pidstat -d — chỉ đĩa), chạy mỗi 5 phút khi iowait ≥ 15%, sau mỗi lần chụp nghỉ 15 phút số liệu liên tục theo tiến trình / service; chụp theo CPU
4 lịch cron / timer (schedule.csv); sổ sự cố ghi tay nhật ký hệ thống chỉ giữ trong RAM (giới hạn 100 MB — lúc kiểm tra, dòng cũ nhất là tối 12/10, tức khoảng 2 ngày); không có cảnh báo khi Prometheus không hỏi được máy

→ Máy chủ trong bài ở Mức 2 — và đó chính xác là lý do Phần 8 gọi tên được cowrie (E2) nhưng không gọi tên được thủ phạm của E1, E5.

9.2 Phương pháp = một pipeline cố định

Làm đúng thứ tự, và ở mỗi bước phải tự kiểm tra trước khi đi tiếp. Bỏ một bước, bước sau không cứu được.

Bước Làm gì Kiểm tra trước khi đi tiếp Vì sao không được bỏ (bằng chứng trong bài)
1. Thu thập liên tục đo ≤ 30 giây, giữ ≥ 30 ngày, lưu cả trung bình lẫn lớn nhất thiếu ở đâu? chỗ trống có trùng sự cố không? trung bình 1 giờ cho E1 chỉ 27,7% iowait, thật là 97,5% (1.2)
2. Nền theo khung giờ trung vị + MAD cho từng khung giờ trong ngày; học lại sau thay đổi lớn nền có "nhịp ngày" không? vừa có thay đổi lớn không? 00:00 có trung vị iowait 19,56% mỗi đêm (2.1); sau khi chuyển honeypot, p95 iowait 20,45% → 2,72% (4.2)
3. Phát hiện so mỗi điểm với nền của khung giờ đó, nhiều chỉ số cùng lúc bộ phát hiện có nhìn đủ loại chỉ số (CPU, nhiệt, RAM, đĩa) không? E5 tối trên bản đồ iowait vì là đợt CPU + nóng (4.1)
4. Gộp thành đợt điểm lạ liền nhau = một lần báo; phải kéo dài mỗi ngày bao nhiêu đợt — ca trực có đọc hết không? điều kiện "≥ 3 khung" cắt 295 → 94 đợt ở k=10 (5.3)
5. Đánh giá chấm bằng sổ sự cố, chia theo thời gian nhãn chắc đến đâu? có lộ đề không? chia ngẫu nhiên F1 0,835, theo thời gian 0,016 (6.1)
6. Truy service z bền + ai lên trước + 3 bằng chứng đo theo service đến mức nào? tỉ lệ nghi oan bao nhiêu? 8.4, 8.5
7. Ghi lại vào sổ nguyên nhân + mức chắc chắn + ai xác nhận sổ có thêm một dòng CONFIRMED không? chỉ 1/8 sự cố là CONFIRMED (0.2) — sổ yếu thì bước 5 cũng yếu

9.3 "Làm sai thì thấy ngay"

Mỗi dòng là một cách làm có vẻ hợp lý — và hậu quả đã đo được ngay trong notebook này:

Cách làm sai Thấy ngay ở đâu Hậu quả đo được
Chỉ lưu trung bình 1 giờ 1.2 đỉnh iowait E1 97,5% → 27,7% — sự cố trông như "hơi bận"
Lấp chỗ trống cho đẹp 1.1 xoá mất 33,5 phút giám sát bị "mù" — triệu chứng rõ nhất của E1
Một ngưỡng cho cả ngày 5.1 bộ A: 90 đợt báo, 13 đợt đúng giờ backup 00:00–00:30
Chia dữ liệu ngẫu nhiên 6.1 F1 0,835 ảo; chia theo thời gian chỉ 0,016
Truy thủ phạm bằng lịch một mình 8.3 30 việc có lịch chạy trong E2 — không chỉ ra được ai
Không đo theo service 8.4 6/7 sự cố chỉ dừng ở "ngoài container"
Xếp hạng service bằng số thô (%, MiB, MB/s trộn lẫn) 8.5a ở E2 hạng 1 vẫn là cowrie, nhưng các hạng sau đổi chỗ: mikrotik-prometheus hạng 2 theo CPU thô, chỉ hạng 4 theo z (z = 0,8 — dao động bình thường của nó)
Gọi tên thủ phạm mà không thử giả dược 8.5d 40% khoảng bình thường cũng có service z ≥ 3 — không thử thì tưởng mọi "nghi phạm" đều thật

9.4 Tự chấm: hệ thống của bạn đang ở mức nào?

Ô dưới đã điền sẵn đúng tình trạng máy chủ DietPi trong bài. Hãy sửa từng dòng True / False theo hệ thống ở cơ quan bạn rồi chạy lại.

In [ ]:
# 9.4 Tự chấm mức giám sát
# ✏️ SỬA: True = hệ thống của bạn CÓ điều này, False = chưa có (giá trị sẵn = máy chủ DietPi trong bài)
CO = {
    "1a. Số liệu cả máy (CPU, RAM, đĩa, mạng), đo ≤ 30 giây": True,
    "1b. Giữ số liệu ≥ 30 ngày": True,
    "2.  Số liệu theo từng container": True,
    "3a. Số liệu LIÊN TỤC theo từng tiến trình / service systemd": False,
    "3b. Ảnh chụp tiến trình theo cả CPU lẫn đĩa": False,
    "4a. Lịch việc định kỳ + sổ thay đổi (ai làm gì, lúc nào)": True,
    "4b. Log tập trung giữ ≥ 30 ngày": False,
    "4c. Cảnh báo khi chính giám sát bị 'mù' (không hỏi được máy)": False,
}
# mức = bậc thang: phải đủ MỌI điều của các mức dưới
CAN = {1: ["1a", "1b"], 2: ["2."], 3: ["3a", "3b"], 4: ["4a", "4b", "4c"]}
DUOC = {0: "chưa nói được gì chắc chắn về sức khoẻ máy chủ",
        1: "LÚC NÀO máy chủ bất thường (Phần 1–6)",
        2: "lúc nào + CONTAINER nào vượt mức (như E2 → cowrie)",
        3: "lúc nào + gọi tên cả chương trình chạy thẳng trên máy",
        4: "lúc nào + ai + VÌ SAO — đủ để xác nhận nguyên nhân"}
muc = 0
for m in [1, 2, 3, 4]:
    if all(gt for ten, gt in CO.items() if any(ten.startswith(ma) for ma in CAN[m])):
        muc = m
    else:
        break
for ten, gt in CO.items():
    print("✅" if gt else "❌", ten)
print()
print(f"→ Mức {muc}: bạn nói được: {DUOC[muc]}")
if muc < 4:
    thieu = [ten for ten, gt in CO.items() if not gt and any(ten.startswith(ma) for ma in CAN[muc + 1])]
    print(f"→ Muốn lên Mức {muc + 1}, cần thêm:", "; ".join(thieu))

Kết quả (giá trị sẵn = máy chủ DietPi trong bài): ✅ 1a, 1b, 2, 4a · ❌ 3a, 3b, 4b, 4c → Mức 2: bạn nói được: lúc nào + CONTAINER nào vượt mức (như E2 → cowrie) · Muốn lên Mức 3, cần thêm: 3a … ; 3b …

👉 Hiểu:

  • Mức là bậc thang: có lịch việc định kỳ (4a) nhưng thiếu 3a, 3b thì vẫn chỉ là Mức 2 — không "nhảy cóc" được.
  • Đây đúng là điều Phần 8 đã đo: gọi tên được cowrie (container), nhưng E1, E5 chỉ dừng ở "ngoài container".
  • ✏️ Sửa CO theo hệ thống của bạn và chạy lại: dòng cuối cho biết chính xác cần thêm gì để lên mức kế tiếp.

9.5 Cài tối thiểu cho từng mức

Mức Công cụ (ví dụ, mã nguồn mở) Vài thiết lập quyết định kết quả
1 node_exporter + Prometheus (+ Grafana để xem) nhịp hỏi scrape_interval ≤ 30 giây; thời gian giữ --storage.tsdb.retention.time ≥ 30 ngày (máy chủ trong bài: 30d lúc xuất dữ liệu, nay 180d); muốn so tháng / năm: chép bản gộp 5 phút / 1 giờ có cột lớn nhất sang kho dài hạn
2 bộ đo container, ví dụ cAdvisor nhãn tên container ổn định; bộ đo phải chạy được cả lúc máy quá tải (không thì số container lúc treo thấp hơn thật — đúng như dữ liệu của ta)
3 process-exporter (gom tiến trình theo tên), hoặc bật đo theo service của systemd (CPUAccounting, MemoryAccounting, IOAccounting) gom theo tên chương trình, không theo số PID; ảnh chụp tiến trình kích hoạt theo CPU / load chứ không chỉ iowait, và không nghỉ quá lâu giữa hai lần chụp
4 lịch + sổ thay đổi (một file / ticket cũng được); log tập trung (ví dụ Loki, hoặc journald lưu trên đĩa); cảnh báo của Prometheus journald: lưu trên đĩa, không chỉ trong RAM; cảnh báo khi up == 0 (Prometheus không hỏi được máy) — "mất số liệu cũng là tín hiệu"

👉 Mang về nhà:

  • Chọn mức theo câu hỏi bạn cần trả lời. Muốn "bắt tận tay" service gây lỗi chạy thẳng trên máy → phải có Mức 3 từ trước khi sự cố xảy ra; không mô hình nào bù được số liệu chưa từng thu.
  • Đi đúng pipeline 9.2, và ở mỗi bước so với bảng 9.3: nếu bạn đang làm một dòng trong cột "cách làm sai", con số của bạn đang nói dối.

Phần 7 — Dùng gì trong SOC, quy tắc bàn tay ✋ và câu hỏi thảo luận

📍 Bước 7 — nối với công việc thật.

Trong bài Trong SOC
Giám sát "mù" đúng lúc máy quá tải (Phần 1) mất số liệu cũng là một tín hiệu: cảnh báo khi Prometheus không hỏi được máy chủ, đừng chỉ cảnh báo khi số cao
Trung bình 1 giờ giấu mất đỉnh (Phần 1) dashboard cần cả trung bình lẫn lớn nhất; cảnh báo nên chạy trên số chi tiết
Đỉnh 00:00, Chủ nhật 03:30 (Phần 2, 3) ghi lịch việc định kỳ vào hệ thống cảnh báo, nếu không SOC sẽ "chữa cháy" backup mỗi đêm
Chuyển dịch vụ làm đổi "bình thường" (Phần 4) sau mỗi thay đổi lớn, phải học lại nền — mô hình không tự biết
Thiếu số liệu cả năm (Phần 4b) lưu trữ dài hạn là quyết định từ trước, không thể làm bù
Điểm đẹp nhờ chia ngẫu nhiên (Phần 6) chỉ tin số đo chia theo thời gian — giống ngày mai mô hình sẽ gặp
Bất thường không có nghĩa là tấn công máy treo vì benchmark của chính đội vận hành (E1), không phải hacker — luôn đối chiếu sổ thay đổi trước khi báo sự cố an ninh
Truy service "thủ phạm" (Phần 8) mỗi cảnh báo nên kèm "ai đang vượt mức" theo điểm z của chính service đó và ai lên trước; chỉ gọi tên khi đủ 2/3 bằng chứng, và phải biết tỉ lệ nghi oan của cách truy (8.5d)
Thang 4 mức giám sát (Phần 9) trước khi hứa "tìm ra thủ phạm", xem hệ thống đang ở mức nào: Mức 2 chỉ gọi tên được container; chương trình chạy thẳng trên máy cần Mức 3 từ trước

Quy tắc bàn tay ✋ áp vào bài này

Ngón Điều Câu hỏi phải tự trả lời Trong bài này Quên điều này thì…
👍 1. Ngón cái DỮ LIỆU sạch Thiếu ở đâu? Độ phân giải đủ chưa? giữ khoảng trống E1 là trống; 30 giây vs trung bình 1 giờ (Phần 1) lấp mất chính sự cố; đỉnh biến mất trong trung bình
☝️ 2. Ngón trỏ ĐẶC TRƯNG đúng So với cái gì là "bình thường"? nền theo khung giờ thay vì một vạch cho cả ngày (Phần 2, 5) báo động backup 00:00 mỗi đêm
✋ 3. Ngón giữa CHỌN CÔNG CỤ Có nhãn không? Có nhịp ngày không? không nhãn từng dòng → phát hiện bất thường; sổ sự cố chỉ để chấm (Phần 5) dùng mô hình có giám sát với 8 sự cố
💍 4. Ngón áp út ĐO trung thực Chia dữ liệu thế nào? Nhãn chắc đến đâu? chia ngẫu nhiên vs theo thời gian; mức chắc chắn của từng sự cố (Phần 6) khoe một con số ảo
🤙 5. Ngón út GIẢI THÍCH & CẢNH GIÁC "Bình thường" có vừa đổi không? Đợt chưa giải thích là gì? chuyển honeypot đổi hẳn nền (Phần 4); đối chiếu sổ thay đổi (Phần 7) báo sự cố an ninh cho một việc của chính mình

💬 Câu hỏi thảo luận

  1. Ở Phần 1, nếu ta lấp 33 phút trống của E1 bằng đường nối thẳng, bộ phát hiện sẽ thấy gì? Vì sao "không có số liệu" đôi khi là dấu hiệu rõ nhất?
  2. Phần 2: đỉnh iowait lúc 00:00 xảy ra gần như mỗi đêm. Bạn sẽ dạy hệ thống cảnh báo "bỏ qua" nó thế nào mà không bỏ sót một sự cố thật cũng xảy ra lúc 00:00?
  3. Phần 5: chọn một cấu hình cho bộ B để giao cho ca trực SOC. Bạn chấp nhận bao nhiêu đợt báo mỗi ngày, đổi lại bỏ sót sự cố nào?
  4. Phần 6: một nhà cung cấp khoe "mô hình phát hiện sự cố máy chủ F1 = 0,8". Bạn sẽ hỏi họ câu gì đầu tiên?
  5. E5 (28/09, CPU cao và nóng nhiều giờ) đến nay chưa giải thích được. Phần 8 chỉ ra "ngoài container" nhưng không gọi được tên. Bạn cần thêm số liệu gì để biết nó là gì?
  6. Phần 8: ở E2, container cowrie vượt mức nhiều nhất — nhưng ảnh chụp tiến trình cùng lúc lại thấy tiến trình khác ghi đĩa nhiều hơn. Bạn kết luận "thủ phạm" thế nào, và cần bằng chứng gì thêm?
  7. Phần 8.5b: ở E2, "ngoài container" bắt đầu vượt lúc 08:01, cowrie lúc 08:07 — nhưng cowrie vượt 309 phút, "ngoài container" chỉ 88 phút. Ai là thủ phạm, ai là nạn nhân? Chỉ nhìn "ai lên trước" có đủ không?
  8. Phần 8.5d: 40% khoảng bình thường có service z ≥ 3, và mọi lần bị gọi tên oan đều là "ngoài container". Con số này nói gì về bằng chứng (ii) "có việc định kỳ"? Bạn chọn ngưỡng z nào, và đổi lại mất gì?
  9. Phần 9: hệ thống ở cơ quan bạn đang ở mức nào? Muốn lên một mức, cần thêm đúng thứ gì — và câu hỏi nào bạn sẽ trả lời được sau đó?