Hạ tầng tự vận hành cho chính trang này
Trang bạn đang đọc chạy trên một board ARM đặt tại nhà: CI theo tag, rolling deploy không downtime, auto-heal và autoscale.
- Docker
- GitHub Actions
- Traefik
- nginx
- Cloudflare Tunnel
Trang portfolio này không nằm trên Vercel hay một VPS nào. Nó chạy trên một board Armbian bốn nhân Cortex-A53 với khoảng 1,8 GB RAM đặt ngay tại nhà tôi — cùng máy đang chạy vài dịch vụ khác. Ràng buộc phần cứng đó quyết định gần như mọi lựa chọn kỹ thuật bên dưới.
Không mở port nào ra Internet#
Máy ở nhà không có IP tĩnh và tôi không muốn mở port trên router. Đường vào đi qua
Cloudflare Tunnel: một tiến trình cloudflared trên máy tự tạo kết nối ra ngoài,
Cloudflare đẩy request ngược vào theo đường đó. Router không mở port nào, IP nhà
không lộ ra, và TLS kết thúc ở Cloudflare.
Sau tunnel là Traefik route theo tên miền, rồi tới một nginx làm lớp cache, rồi mới tới các container ứng dụng.
Vì sao phải có nginx ở giữa#
Mỗi lượt xem trang mà đánh thức Node để render lại là quá đắt với con CPU này. nginx giữ một micro-cache trong RAM: HTML và payload RSC cache 60 giây, file build có hash trong tên cache 7 ngày. Nhiều người cùng mở một trang thì chỉ một request đi vào ứng dụng, phần còn lại chờ và dùng chung kết quả:
location / {
proxy_pass http://portfolio_app;
proxy_cache portfolio_pages;
# Next.js trả HTML và payload RSC ở cùng URL, khác nhau theo header RSC —
# khoá cache phải tách chúng ra, nếu không trình duyệt nhận nhầm loại.
proxy_cache_key "$scheme$host$request_uri|$enc|$http_rsc|$http_next_router_prefetch";
proxy_cache_lock on;
proxy_cache_background_update on;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
}Dòng proxy_cache_use_stale là phần tôi thích nhất: ứng dụng có chết hẳn thì khách
vẫn nhận được bản đã cache thay vì trang lỗi.
Release chỉ bằng một cái tag#
Tôi code trên máy Mac, nhưng máy build là một Fedora khác trong nhà, chạy GitHub
self-hosted runner trong Docker. Đẩy một tag v* lên là cả pipeline CI tự chạy: lint và
type-check, build image arm64, nén bằng zstd rồi stream thẳng qua SSH sang
board và docker load — không đi qua registry nào. Một lần release mất khoảng hai
phút từ lúc push tag tới lúc bản mới phục vụ thật.
Thay bản mới mà không rơi một request nào#
Trước đây site chạy một container duy nhất, nên mỗi lần lên version có khoảng 30 giây không ai vào được. Bây giờ nginx chỉ gửi request tới đúng những container ghi trong file upstream mà script deploy quản lý:
# Bản mới đã healthy: chuyển nginx sang, rồi CHỜ worker cũ xử lý xong
# request đang dở trước khi tắt bản cũ.
write_upstream "${new_ids[@]}"
reload_edge || { write_upstream "${old_ids[@]}"; reload_edge; exit 1; }
retire "${old_ids[@]}"Bản mới khởi động cạnh bản cũ, phải qua health check đã; nginx mới đổi upstream và reload êm; script chờ các worker cũ thoát — tức mọi request đang dở đã trả xong — rồi mới tắt container cũ. Bản mới lỗi thì bị gỡ đi, và nó chưa bao giờ nhận được request nào.
Tôi test toàn bộ trên máy Mac bằng Traefik thật và ba vòng lặp request liên tục (trang, API không cache, và một file tải chậm ba giây đang dở lúc chuyển): chuyển kiến trúc, release thường, bản không qua health check, cấu hình nginx sai, thay nginx, tăng giảm replica — không request nào lỗi.
Auto-heal và autoscale#
Docker chỉ khởi động lại container bị crash; container treo mà vẫn "chạy" thì nằm đó mãi. Một timer systemd chạy 30 giây một lần sẽ: rút container unhealthy khỏi upstream trước rồi mới restart nó, và thêm replica khi CPU cao kéo dài, giảm lại khi rảnh — có chặn ngưỡng RAM trống để máy không bao giờ phải swap.
Kết quả#
- Release bằng một lệnh
git tag, không thao tác tay trên server - Không downtime khi lên version, đã kiểm chứng bằng hàng nghìn request liên tục
- 300 request ở mức concurrency 20: tất cả đều 200, CPU gần như không nhúc nhích
- Bản hỏng không bao giờ tới tay người dùng; rollback về bản trước cũng là một lệnh
- Toàn bộ chạy trên phần cứng giá bằng một bữa ăn, tiền điện vài chục nghìn một tháng