Có một loại vấn đề vận hành rất khó thấy: chạy âm thầm, không làm sập website nào, nhưng đốt tài nguyên mỗi ngày và chôn mất tín hiệu thật. Tài liệu này nói về hai trường hợp điển hình cùng họ. Thứ nhất, job cron trên Jenkins vẫn SSH vào những máy đã gỡ từ lâu, đỏ liên tục mà không ai để ý. Thứ hai, error.log MySQL trên máy database của cụm Kubernetes phình tới hàng GB, gần như toàn bộ là một dòng cảnh báo reverse DNS lặp lại hàng triệu lần. Bài trình bày cách phát hiện, nguyên nhân gốc, cấu hình sửa (skip_name_resolve, logrotate) và checklist khi gỡ một máy.
Dấu hiệu
Không khách hàng nào báo lỗi. Dấu hiệu chỉ hiện ra khi chủ động nhìn vào:
- Một job cron trên Jenkins (ví dụ xoá cache file của vài site Laravel, chạy vài khung giờ mỗi ngày) có phần lớn build gần đây là FAILURE, và một thời gian dài không có build nào thành công.
- Một job có tham số bắt buộc nhưng gắn trigger định kỳ, fail đều đặn mỗi tối.
- Job gia hạn SSL cho một balancer đã gỡ vẫn chạy mỗi tuần và đỏ đều đặn.
- Trên máy MySQL ngoài cluster (mô hình ở bài MySQL trong Kubernetes hay ngoài cluster), error.log MySQL chiếm nhiều GB trong
/var/log.
Điều tra job cron xác sống
Bước 1: đếm build theo tháng thay vì nhìn build gần nhất
Màn hình Jenkins chỉ cho thấy vài build gần nhất. Muốn biết job hỏng từ bao giờ, đọc thẳng build.xml của từng build trong JENKINS_HOME (chỉ đọc) và gom theo tháng:
cd $JENKINS_HOME/jobs/clear-cache-job/builds
for b in [0-9]*/build.xml; do
m=$(grep -m1 -o '<timestamp>[0-9]*' $b | cut -c12-21)
r=$(grep -m1 -o '<result>[A-Z]*' $b | cut -c9-)
echo "$(date -d @$m +%Y-%m) $r"
done | sort | uniq -cMẫu hình hay gặp: job đỏ lẻ tẻ, rồi đỏ nhiều hơn xanh, rồi đỏ hoàn toàn. Đã đỏ quen mắt nên lúc hỏng hẳn không ai thấy khác biệt.
Bước 2: đọc console log, và một hướng nghi sai
Console thường có hai kiểu lỗi xen kẽ:
rm: cannot remove 'storage/framework/cache/data/': Directory not empty
ssh: Could not resolve hostname minion-old: Name or service not knownDễ nghĩ lỗi chính là dòng rm: lệnh rm -rf chạy đúng lúc ứng dụng đang ghi cache mới, nên thỉnh thoảng thư mục không xoá được. Lỗi này có thật, nhưng chỉ giải thích những lần fail lẻ tẻ. Khi mọi build đều dừng ở dòng thứ hai, nguyên nhân là script viết cứng tên máy:
ssh root@minion-old "cd /var/www/site-a.example && rm -rf storage/framework/cache/data/ && chown -R www-data:www-data *"
ssh root@minion-old "cd /var/www/site-b.example && rm -rf storage/framework/cache/data/ && chown -R www-data:www-data *"Máy đó đã ốm dần (kiểu chết chậm trong bài no live upstreams: đọc error.log Nginx khi upstream bị đánh dấu chết), các site được chuyển đi, máy bị gỡ và tên bị xoá khỏi DNS nội bộ. Job thì không ai sửa. Dò cổng 22 và 80 từ một máy khác để xác nhận máy không còn.
Lục tiếp các job có lịch chạy thường thấy thêm:
- Job gia hạn SSL theo tên balancer đã gỡ, lỗi
Could not resolve hostname. - Job có tham số bắt buộc (
minion,domain) nhưng gắn trigger định kỳ. Cron của Jenkins chạy với giá trị mặc định là rỗng, dừng ngay ở bước kiểm tra[ERROR] domain parameter is required. Danh sách chọnminioncòn cả máy đã gỡ. - "Rule xác sống" trên Grafana: rule CPU, RAM ghim cứng
instancecủa máy đã gỡ, đặtNo data → OK, nghĩa là từ khi máy biến mất, rule báo "bình thường" mãi mãi.
Điều tra error.log MySQL phình to
Bước 3: error.log MySQL chứa gì
Nếu không có cấu hình logrotate cho MySQL (/etc/logrotate.d không có file nào của MySQL), error.log MySQL chỉ có một file và lớn dần từ lần boot. Đếm theo mã cảnh báo:
ls -la /var/log/mysql/
grep -oE '\[MY-[0-9]+\]' /var/log/mysql/error.log | sort | uniq -c | sort -rn | head
grep MY-010055 /var/log/mysql/error.log | head -1
[Warning] [MY-010055] [Server] IP address '10.0.1.13' could not be resolved: Name or service not knownKhi một mã chiếm gần hết file, gom theo IP trong một ngày mẫu:
grep MY-010055 error.log | grep "$(date -d yesterday +%F)" \
| grep -oE "IP address .[0-9.]+" | sort | uniq -c | sort -rnTrong tình huống điển hình, các IP đứng đầu chính là worker node của cluster LKE. Pod website kết nối từ node, IP private của node không có bản ghi PTR trên DNS resolver của máy database, nên mỗi lần MySQL đi hỏi tên đều nhận về "Name or service not known" và ghi một dòng cảnh báo. Với hàng trăm pod kết nối liên tục, mỗi giờ có thêm hàng chục nghìn dòng.
Phần còn lại thường là IP lạ từ Internet: các dịch vụ quét cổng công khai. Thấy chúng trong error.log MySQL nghĩa là cổng 3306 đang trả lời từ Internet, phải đóng ngay.
Bước 4: tín hiệu thật bị chôn trong error.log MySQL
Lọc bỏ MY-010055, error.log MySQL thường còn vài thứ đáng đọc mà trước đó không ai thấy, ví dụ Background histogram update on <db>.<table>: Lock wait timeout exceeded lặp lại hàng trăm lần. Kiểm tra thêm table_open_cache:
SHOW GLOBAL STATUS LIKE 'Table_open_cache_overflows';
SELECT @@table_open_cache, COUNT(*) AS so_bang FROM information_schema.tables;Overflow tăng nhanh trong khi tổng số bảng của mọi database lớn hơn table_open_cache là dấu hiệu nên nâng giá trị này.
Nguyên nhân gốc: cron xác sống và error.log MySQL
Job cron xác sống
- Gỡ máy không có bước "tìm mọi chỗ đang trỏ tới máy này". DNS được dọn, Prometheus target được dọn, nhưng job Jenkins và rule Grafana thì không. Job viết cứng hostname thay vì đọc từ một danh sách máy chung.
- Cấu hình job không nằm trong Git thì không thể
grepmột lần trên toàn bộ job như grep một repo (xem bài Jenkins làm "control plane" cho hàng trăm website). - Job có tham số bắt buộc lại được gắn cron.
- Không ai nhận cảnh báo khi job fail liên tiếp. Màu đỏ thành nền, và nền thì không ai nhìn.
error.log MySQL phình to
- Mặc định
skip_name_resolve = OFF. Với mỗi host client mới, MySQL phân giải IP ra tên rồi tên ra IP để so với cộthostcủa tài khoản, kết quả lưu trong host cache. IP private không có PTR, lần hỏi nào cũng thất bại và sinh một dòng cảnh báo. Nếu mọi tài khoản đều khai báo theo IP hoặc%, việc phân giải tên này không mang lại lợi ích gì. - Không có logrotate cho error.log MySQL, nên một cảnh báo vô hại tích tụ thành file hàng GB và che lỗi thật.
- Cổng 3306 mở ra Internet khiến log bị bơm thêm bởi các lượt quét và mở rộng bề mặt tấn công không cần thiết.
Khắc phục
Dọn job cron xác sống
- Disable các job trỏ vào máy đã gỡ. Tắt chứ chưa xoá, để còn lịch sử build đối chiếu.
- Bỏ trigger định kỳ của job có tham số bắt buộc; xoá các máy đã gỡ khỏi danh sách chọn.
- Xoá rule Grafana ghim vào máy đã gỡ.
Sửa error.log MySQL
Lưu trữ error.log MySQL hiện tại ra chỗ khác, rồi cho MySQL mở file mới: đổi tên file cũ, chạy FLUSH ERROR LOGS, nén file cũ.
Bật skip_name_resolve, nhưng chỉ sau khi chắc chắn không còn tài khoản nào khai báo theo tên máy, vì khi bật, các tài khoản đó sẽ không đăng nhập được:
-- chạy trước khi bật: phải trả về rỗng (ngoài localhost)
SELECT user, host FROM mysql.user
WHERE host NOT IN ('%', 'localhost')
AND host NOT REGEXP '^[0-9.%/]+$';
# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
skip_name_resolve = ONThêm logrotate cho error.log MySQL, dùng mysqladmin flush-logs error trong postrotate để MySQL ghi sang file mới:
# /etc/logrotate.d/mysql-error
/var/log/mysql/error.log {
daily
rotate 14
maxsize 200M
compress
delaycompress
missingok
notifempty
create 640 mysql adm
postrotate
mysqladmin --defaults-file=/root/.my.cnf flush-logs error
endscript
}Đóng cổng 3306 với Internet để error.log MySQL sạch hơn
Giới hạn cổng 3306 chỉ cho mạng private của cluster, bằng firewall của nhà cung cấp (với LKE là Linode Cloud Firewall) hoặc nftables trên host, và đặt bind-address về IP private nếu được. Kiểm tra từ một máy ngoài mạng: nc -zv <ip-public> 3306 phải không kết nối được. Việc này và việc nâng table_open_cache cần lịch bảo trì vì đụng tới kết nối đang chạy.
Checklist khi gỡ một máy
# tìm mọi job Jenkins còn nhắc tới máy sắp gỡ
grep -l "minion-old" $JENKINS_HOME/jobs/*/config.xml
# target Prometheus, /etc/hosts trên các máy khác
grep -rn "minion-old" /etc/prometheus/ /etc/hosts- Gỡ máy là một thay đổi có checklist: DNS, Prometheus target, rule cảnh báo, job Jenkins (cả danh sách chọn trong tham số), backup, kênh thông báo.
- Job định kỳ không viết cứng tên máy; đọc từ một danh sách máy chung, máy bị gỡ khỏi danh sách thì job tự bỏ qua.
- Job có tham số bắt buộc không được có cron. Nếu cần chạy định kỳ, tách thành job riêng với tham số cố định. Cú pháp lịch chạy xem tài liệu cron syntax của Jenkins.
- Báo khi một job cron fail ba lần liên tiếp, không phải mỗi lần fail.
- Rule cảnh báo đặt
No datalà trạng thái cảnh báo, không phải OK.
Giám sát error.log MySQL
- Mỗi máy database có logrotate cho error.log MySQL và cảnh báo khi
/var/logvượt ngưỡng. - Mỗi tháng, đếm 10 mã cảnh báo nhiều nhất trong error.log MySQL. Mã nào chiếm hơn một nửa file là một việc cần xử lý, không phải một dòng cần lờ đi.
- Ghi lại mọi lần sửa
mysqld.cnf(tốt nhất là quản lý bằng Git), để khi hành vi của MySQL đổi sau một lần restart, còn bản cũ mà so.
Bài học
- Đỏ quen mắt nguy hiểm hơn đỏ đột ngột. Thống kê theo tháng cho thấy điều màn hình build gần nhất giấu đi.
- Gỡ máy là thay đổi cấu hình ở nhiều nơi, không chỉ là bấm xoá VPS.
- Cảnh báo vô hại vẫn có hại khi nó chiếm gần hết log. error.log MySQL phình to không làm sập database, nhưng nó khiến không ai đọc log, và lỗi thật nằm im trong đó.
- Không có thay đổi cấu hình nào là "nhỏ" nếu không được ghi lại, kể cả một dòng trong
mysqld.cnflàm error.log MySQL đổi hành vi.
Bài tiếp theo
Database chết mà không ai biết: cảnh báo Prometheus còn thiếu gì. Khi một máy MySQL ngừng trả lời mà hệ thống giám sát im lặng, và bộ rule tối thiểu để điều đó không lặp lại.
