Trước đây, mỗi dự án cá nhân của mình được deploy theo một cách riêng: có cái clone code về server rồi build ngay trên đó, có cái chạy bằng một file docker-compose chỉ tồn tại trên máy, biến môi trường thì mỗi nơi một kiểu. Chạy thì vẫn chạy, nhưng mỗi lần cần sửa gì đó là một lần phải nhớ lại xem mình đã làm thế nào.
Bài này ghi lại cách mình gom tất cả về một mối, cùng những lỗi thật đã gặp trên đường. Nếu bạn đang tự vận hành server cho dự án cá nhân, hy vọng vài điều dưới đây giúp bạn tránh được vài buổi tối gỡ lỗi.
Đưa mọi thứ vào git
Nguyên tắc nền tảng: không sửa tay trên server. File compose, config, rule cảnh báo, dashboard đều nằm trong một repo. Muốn đổi gì thì sửa trong repo, push, và để CI đưa thay đổi lên. Image được build trong CI của từng app, không build trên server nữa.
Cách này đem lại ba thứ:
- Lịch sử: mỗi thay đổi có commit, có lý do, và revert được.
- Dựng lại được: mất server thì dựng lại từ repo và bản backup, không phải từ trí nhớ.
- Phát hiện lệch: một script dựng lại hệ thống, chạy ở chế độ thử (
--dry-run) trên server đang chạy, phải báo "không có gì thay đổi". Có gì khác tức là git và thực tế đã lệch nhau.
Có một lỗi nhỏ minh hoạ vì sao phiên bản phải nằm ở chỗ ai cũng đọc được: từng có lúc phiên bản của các database nằm trong file secret đã mã hoá. Diff không đọc được, công cụ cập nhật phiên bản không thấy, và kết quả là vài container chạy tag kiểu latest khác với giá trị ghi trong file mà không ai biết. Giờ mọi image được ghim phiên bản cụ thể ngay trong file compose.
Để càng ít thứ lộ ra internet càng tốt
Ít công sức nhưng lợi ích lớn: mỗi dịch vụ lộ ra ngoài là một thứ phải vá, phải theo dõi, phải lo. Vài cách giảm bớt:
- Dùng tunnel thay vì mở cổng. Các dịch vụ như Cloudflare Tunnel cho phép server chủ động kết nối ra ngoài, nên không cần mở cổng web trên máy. Lọc DDoS và giới hạn tần suất request được xử lý trước khi traffic tới server.
- Tách đường quản trị khỏi đường người dùng. Truy cập quản trị nên đi qua một mạng riêng (VPN) thay vì internet công cộng.
- Không route những thứ chỉ dùng nội bộ. Database, cache, endpoint
/metricschỉ cần nói chuyện với nhau trong mạng nội bộ của Docker. - Mỗi app một user database riêng, chỉ có quyền trên đúng database của nó, thay vì dùng chung tài khoản quản trị.
Không cách nào ở trên làm hệ thống "an toàn tuyệt đối". Chúng chỉ giảm số chỗ phải lo, để bạn lo kỹ hơn cho những chỗ còn lại.
Deploy có đường lùi
Quy trình hiện tại:
- Repo của app chạy test và build image.
- Phiên bản đang chạy của mỗi app được ghi trong một file của repo hạ tầng, nên lịch sử deploy chính là
git logcủa file đó. - Bản mới chạy thử dưới dạng canary: một container riêng, chưa nhận traffic, phải qua được healthcheck. Ổn thì mới thay bản thật, không ổn thì tự quay về bản cũ.
Lợi ích lớn nhất không phải là tự động hoá, mà là rollback trở nên nhàm chán: revert một commit là xong.
Hai lỗi thật mình gặp khi viết phần này, cả hai đều không báo lỗi gì:
cmd | grep -qtrong script cóset -o pipefail.grep -qthoát ngay khi tìm thấy, lệnh phía trước nhận SIGPIPE, và cả pipeline bị coi là thất bại. Script deploy vì thế báo "đã là bản mới nhất" thay vì deploy. Cách sửa: lưu output vào biến trước, rồi mớigreptrên biến đó.- Dùng chung
concurrencygroup giữa các workflow GitHub Actions. Như tài liệu về concurrency của GitHub Actions mô tả, mặc định GitHub chỉ giữ một run đang chờ cho mỗi group, run mới hơn sẽ huỷ run đang chờ trước nó. Kết quả là có những lần deploy biến mất không dấu vết. Mỗi workflow giờ có group riêng.
Secret: không để bản rõ trong git
Có vài cách, tuỳ quy mô:
- Mã hoá secret trước khi commit, bằng công cụ như sops, và chỉ giải mã tại nơi dùng.
- Hoặc dùng secret store của nền tảng CI/hosting bạn đang dùng.
Dù chọn cách nào, có ba thói quen đáng giữ: mỗi thành phần chỉ có đúng quyền nó cần; ghi lại cách xoay vòng từng secret trước khi cần dùng tới; và xoay vòng ngay khi một secret xuất hiện ở nơi không nên có, kể cả khi bạn nghĩ "chắc không sao".
Cảnh báo: ít nhưng đúng
- Cảnh báo theo triệu chứng người dùng cảm nhận được (lỗi, chậm, không vào được), không theo nguyên nhân. CPU cao mà không ai bị ảnh hưởng thì không đáng làm phiền ai.
- Kiểm tra từ góc nhìn của người dùng. Một probe gọi URL public theo đúng đường người dùng đi (kể cả kiểm tra hạn chứng chỉ TLS) bắt được những lỗi mà metric bên trong server không thấy.
- Coi rule cảnh báo là code, có unit test (Prometheus có
promtool test rules). - Kiểm tra tên metric trên exporter thật trước khi viết rule. Đây là lỗi mình thấy nguy hiểm nhất: một rule dùng metric không tồn tại sẽ không bao giờ kích hoạt, và cũng không báo lỗi gì. Mình từng có một cảnh báo "Elasticsearch chết" như vậy. Nó nằm đó, trông rất yên tâm, và không bao giờ có thể kêu.
- Giám sát cả hệ thống giám sát. Nếu monitoring chạy trên chính server cần giám sát, thì khi server chết nó cũng im lặng theo. Một tín hiệu "còn sống" gửi đều đặn ra dịch vụ bên ngoài giải quyết chuyện này.
Backup chỉ có giá trị khi khôi phục được
Quy tắc mình theo: một bản backup chỉ được tính khi đã khôi phục thử thành công.
- Tự động khôi phục thử định kỳ vào một môi trường tạm, dùng đúng phiên bản database đang chạy thật, rồi kiểm tra dữ liệu.
- Cảnh báo khi backup lỗi, khi bản mới nhất đã quá cũ, và khi khôi phục thử thất bại.
- Giữ ít nhất một bản ở ngoài server, và mã hoá nó.
- Với những bản nâng cấp thay đổi schema một chiều (như nâng cấp Keycloak), thử trước trên một bản sao nạp từ backup, và chụp backup ngay trước khi nâng.
Những lỗi im lặng khác đáng biết
Những lỗi tốn thời gian nhất của mình đều có chung một đặc điểm: không có thông báo lỗi nào.
- Bind mount một file chưa tồn tại, Docker sẽ tạo một thư mục trùng tên và container vẫn chạy bình thường (tài liệu bind mount của Docker ghi rõ với
-v, đường dẫn nguồn chưa có luôn được tạo thành thư mục, còn--mountthì từ chối chạy). Một file cấu hình MySQL tuỳ chỉnh của mình đã "được áp dụng" theo cách đó suốt 8 tháng, tức là chưa bao giờ được áp dụng. rsync --filter=':- .gitignore'chỉ áp dụng rule ở phía nhận khi file.gitignoređã có sẵn ở đó. Một lần đồng bộ suýt xoá dữ liệu và các file.envvì chuyện này. Giờ mọi thao tác đồng bộ có xoá đều đi qua một script luôn chạy thử trước, và từ chối khi sắp xoá thứ gì trông giống dữ liệu hoặc secret.- Trước khi chuyển một app sang cách vận hành mới, hãy so sánh cấu hình cũ và mới. Một biến môi trường bị sót có thể tắt một tính năng mà không có lỗi nào báo ra.
Tóm tắt
- Giữ file compose, config, rule cảnh báo và dashboard trong một repo git, không bao giờ sửa tay trên server; một lần dựng lại ở chế độ
--dry-runbáo "không có gì thay đổi" cho thấy git và server vẫn khớp nhau. - Ghim mọi image vào phiên bản cụ thể, đặt ở file mà người và công cụ đều đọc được, không nhét trong file secret đã mã hoá.
- Để càng ít thứ lộ ra internet càng tốt: dùng tunnel thay vì mở cổng, quản trị qua VPN, dịch vụ nội bộ chỉ nằm trong mạng nội bộ của Docker, mỗi app một user database.
- Deploy qua canary phải vượt healthcheck, và ghi phiên bản vào git để rollback chỉ là revert một commit.
- Cảnh báo theo triệu chứng người dùng cảm nhận được, viết test cho rule cảnh báo, kiểm tra tên metric trên exporter thật, và giám sát hệ thống giám sát từ bên ngoài server.
- Một bản backup chỉ được tính khi đã khôi phục thử thành công.
- Không điều nào ở trên tốn nhiều tiền: Cloudflare Tunnel và bộ Prometheus, Grafana đều có bản miễn phí đủ dùng cho dự án cá nhân. Thứ khó nhất là kỷ luật: không sửa tay "lần này thôi", và luôn hỏi "nếu cái này hỏng thì mình có biết không?".
Nếu bạn cũng từng gặp những lỗi im lặng kiểu này trên server của mình, mình rất muốn được trao đổi. Email của mình ở trang chủ.