Zero-downtime deploy với nginx và systemd

Tại sao cần zero-downtime deploy?
Triển khai ứng dụng web lên production, dù chỉ mất vài giây restart service cũng gây mất kết nối cho người dùng đang truy cập. Với hệ thống có traffic thực, mỗi lần downtime nhỏ có thể làm hỏng phiên làm việc, mất request API, hoặc tệ hơn là làm giảm trải nghiệm người dùng. Zero-downtime deploy cho phép bạn chuyển đổi giữa các phiên bản ứng dụng một cách mượt mà, không có request nào bị drop.
Có nhiều cách để đạt được điều này: dùng Docker Swarm, Kubernetes, hoặc cơ chế blue-green deploy với load balancer. Nhưng nếu bạn đang chạy một VPS riêng biệt (dạng VPS Linux giá rẻ), không muốn phức tạp hóa hạ tầng, thì giải pháp nginx reverse proxy + systemd socket service là đơn giản, nhẹ và hiệu quả.
Yêu cầu trước khi bắt đầu
- Một VPS chạy Ubuntu 24.04 LTS (bài này dùng bản này, nhưng Debian 12 hoặc các distro dùng systemd khác cũng làm tương tự).
- User sudo (không dùng root cho tác vụ hàng ngày).
- Nginx đã được cài đặt và chạy (bạn có thể tham khảo bài cài đặt LEMP stack trên Ubuntu).
- Một ứng dụng web mẫu (Node.js, Python Flask, Go HTTP server) chạy trên một port nội bộ, ví dụ port 3000. Bài này dùng ứng dụng Node.js đơn giản.
Cách nginx + systemd hỗ trợ zero-downtime
Nguyên lý hoạt động dựa trên việc bạn có hai instance của ứng dụng chạy đồng thời trên hai port khác nhau (ví dụ 3001 và 3002). Nginx reverse proxy ban đầu trỏ vào port 3001. Khi deploy phiên bản mới, bạn khởi động instance mới trên port 3002, chờ nó ready, rồi dùng lệnh reload nginx để chuyển upstream sang port 3002. Cuối cùng tắt instance cũ trên port 3001. Do nginx reload là hot reload (không drop kết nối đang xử lý), nên không có request nào bị mất.
Để tự động hóa việc quản lý hai instance, chúng ta dùng systemd socket activation. Mỗi instance được quản lý bởi một service file, nhưng thay vì bind trực tiếp vào port cố định, nó nhận socket từ systemd. Systemd sẽ mở socket (port 3001 và 3002), và chuyển file descriptor cho ứng dụng. Khi deploy, bạn chỉ cần start socket mới, dừng socket cũ, systemd sẽ điều phối việc chấp nhận kết nối.
Bước 1 - Chuẩn bị hai systemd service cho ứng dụng
Giả sử ứng dụng Node.js của bạn ở /opt/myapp, chạy bằng node server.js và lắng nghe trên PORT được truyền qua biến môi trường.
Tạo hai file service. File thứ nhất cho instance chạy port 3001:
sudo nano /etc/systemd/system/myapp-1.service
Nội dung:
[Unit]
Description=MyApp instance 1
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node server.js
Environment=PORT=3001
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
File thứ hai cho instance port 3002:
sudo nano /etc/systemd/system/myapp-2.service
Nội dung tương tự, chỉ khác tên service và PORT:
[Unit]
Description=MyApp instance 2
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node server.js
Environment=PORT=3002
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Giải thích: Type=simple vì ứng dụng không fork. Restart=always tự khởi động lại khi crash. RestartSec=5 chờ 5 giây trước khi restart để tránh restart loop. Cả hai instance dùng chung code, khác port.
Verify:
sudo systemctl daemon-reload
sudo systemctl start myapp-1
sudo systemctl start myapp-2
sudo ss -tlnp | grep -E '300[12]'
Output mong đợi: thấy port 3001 và 3002 đang LISTEN, mỗi port do một process node chiếm.
Bước 2 - Cấu hình nginx upstream và reverse proxy
Mở file cấu hình nginx cho site của bạn:
sudo nano /etc/nginx/sites-available/myapp
Nội dung mẫu:
upstream myapp_backend {
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
server_name myapp.example.com;
location / {
proxy_pass http://myapp_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
Giải thích: upstream block định nghĩa nhóm backend server. Ở đây chỉ một server (port 3001). max_fails=3 fail_timeout=10s giúp nginx đánh dấu backend bị lỗi sau 3 lần thất bại trong 10 giây và chuyển sang server khác (sau khi ta thêm server thứ hai). proxy_pass trỏ vào tên upstream. Các header cần thiết cho WebSocket và proxy chuẩn.
Enable site:
sudo ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
Verify: nếu nginx -t trả về syntax is ok, reload nginx:
sudo systemctl reload nginx
Kiểm tra bằng curl:
curl -I http://myapp.example.com
Output: HTTP/1.1 200 OK (hoặc 301/302 tùy app). Nếu lỗi 502, kiểm tra xem myapp-1 có đang chạy không.
Bước 3 - Deploy phiên bản mới
Đây là bước cốt lõi: bạn sẽ khởi động instance mới, chờ nó ready, cập nhật nginx upstream để bao gồm cả instance mới, chờ vài giây để các request bắt đầu được cân bằng, rồi loại bỏ instance cũ.
Giả sử instance 1 (port 3001) đang chạy, bạn deploy code mới. Script deploy chi tiết:
#!/bin/bash
# deploy.sh - zero-downtime with nginx + systemd
DEPLOY_DIR="/opt/myapp"
INSTANCE_OLD="myapp-2"
INSTANCE_NEW="myapp-1"
PORT_OLD=3002
PORT_NEW=3001
NGINX_UPSTREAM_FILE="/etc/nginx/sites-available/myapp"
# 1. Pull code mới (git pull, copy file, build...)
cd $DEPLOY_DIR
git pull origin main
# 2. Khởi động instance mới (hiện tại đang dừng hoặc chưa có)
sudo systemctl start $INSTANCE_NEW
# 3. Chờ instance mới ready (kiểm tra port)
sleep 3
if ! ss -tlnp | grep -q ":$PORT_NEW "; then
echo "ERROR: new instance not ready"
sudo systemctl stop $INSTANCE_NEW
exit 1
fi
# 4. Cập nhật nginx upstream: thêm server mới, giữ server cũ
sudo sed -i "s/} # end upstream/ server 127.0.0.1:$PORT_NEW;\n} # end upstream/" $NGINX_UPSTREAM_FILE
# 5. Reload nginx (không restart, không drop kết nối)
sudo nginx -t && sudo systemctl reload nginx
# 6. Chờ request chuyển dần sang instance mới (khoảng 10s)
sleep 10
# 7. Loại bỏ instance cũ khỏi upstream
sudo sed -i "/127.0.0.1:$PORT_OLD/d" $NGINX_UPSTREAM_FILE
sudo nginx -t && sudo systemctl reload nginx
# 8. Dừng instance cũ
sudo systemctl stop $INSTANCE_OLD
echo "Deploy thành công. Instance $INSTANCE_OLD stopped."
Giải thích từng bước: Bước 2 start instance mới mà không ảnh hưởng đến instance cũ. Bước 3 verify port đã mở. Bước 4 dùng sed để thêm dòng server mới vào upstream. Cần đánh dấu comment # end upstream trong file nginx để sed hoạt động chính xác (bạn phải sửa file nginx mẫu ở Bước 2, thêm dòng comment đó vào cuối upstream block). Bước 7 xóa dòng server cũ. Bước 8 dừng service cũ.
Lưu ý: script trên dùng sed đơn giản; với production, bạn nên dùng nginx config template hoặc công cụ quản lý cấu hình (Ansible, Salt).
Verify sau deploy:
sudo systemctl status myapp-1 # phải active (running)
sudo systemctl status myapp-2 # inactive (dead)
curl -I http://myapp.example.com
Bước 4 - Xử lý lỗi thường gặp
Lỗi 1: Nginx reload thất bại vì syntax error
Nguyên nhân: thường do sed làm hỏng file cấu hình (thiếu dấu chấm phẩy, sai thụt lề).
Cách xử lý:
sudo nginx -t 2>&1 | grep -i error
Nếu có lỗi, sửa tay file /etc/nginx/sites-available/myapp và chạy lại sudo nginx -t && sudo systemctl reload nginx. Quan trọng: không reload khi syntax sai, nginx sẽ không load config mới, nhưng config cũ vẫn chạy, do đó người dùng vẫn không bị ảnh hưởng. Bạn có thể rollback an toàn.
Lỗi 2: Instance mới không start được
Kiểm tra log journal:
sudo journalctl -u myapp-1 --no-pager -n 20
Thường do lỗi code, thiếu dependency, hoặc port đã bị chiếm. Nếu port 3001 đã bị myapp-1 chiếm từ trước (do chưa dừng hẳn), bạn cần kill process cũ:
sudo fuser -k 3001/tcp
sudo systemctl restart myapp-1
Lỗi 3: Request bị 502 Bad Gateway khi chuyển đổi
Nguyên nhân: có khoảng trống giữa lúc ngừng gửi request đến instance cũ và lúc instance mới chưa sẵn sàng. Để khắc phục, tăng sleep ở bước 6 lên 15-20 giây, hoặc cấu hình nginx proxy_next_upstream:
location / {
proxy_pass http://myapp_backend;
proxy_next_upstream error timeout invalid_header http_500 http_502;
}
Nginx sẽ tự động chuyển request sang server khác nếu server hiện tại trả lỗi 502.
Câu hỏi thường gặp
Tôi có thể dùng phương pháp này với ứng dụng Python (Gunicorn, uWSGI) không?
Có. Nguyên lý giống hệt: bạn chạy hai instance Gunicorn trên hai port khác nhau, dùng systemd quản lý, nginx reverse proxy và swap upstream. Chỉ khác lệnh ExecStart trong service file (ví dụ gunicorn -b 127.0.0.1:3001 myapp:app).
Phương pháp này có dùng được với Docker không?
Không trực tiếp. Docker container thường dùng port mapping động, khó quản lý upstream tĩnh. Nếu bạn muốn zero-downtime với Docker, nên dùng Docker Compose với nhiều service replica và load balancer (Traefik, nginx với Docker service discovery).
Tại sao không dùng blue-green deploy với HAProxy?
HAProxy cũng làm được, nhưng thêm một lớp phức tạp. Với VPS đơn lẻ, nginx + systemd là nhẹ nhất, không cần cài thêm phần mềm. Nếu bạn cần tính năng nâng cao như A/B testing, sticky session, thì HAProxy là lựa chọn tốt hơn.
Có cách nào tự động hóa việc phát hiện instance mới mà không cần sed?
Có thể dùng nginx với upstream dynamic module (nginx plus), hoặc dùng nginx config trong thư mục conf.d và quản lý bằng symlink. Nhưng với VPS nhỏ, dùng sed trong shell script là đủ dùng.
Nếu application crash trong lúc deploy thì sao?
Script deploy cần có cơ chế rollback. Đơn giản nhất: nếu sau khi start instance mới mà curl về lỗi, bạn dừng instance mới và giữ nguyên config cũ. Nâng cao hơn: dùng systemd Restart=always và cấu hình health check script trước khi swap upstream.
Tôi đang thuê VPS NVMe IPv4 Việt Nam, cấu hình có cần thay đổi gì không?
Không, phương pháp này hoàn toàn độc lập với hạ tầng. Bạn chỉ cần đảm bảo VPS của bạn có đủ RAM để chạy hai instance cùng lúc (tổng RAM gấp đôi một instance). Với các dòng VPS Linux có 2GB RAM trở lên, phương pháp này chạy rất mượt.
Bài viết liên quan
- Cấu hình nginx làm reverse proxy trên Ubuntu 24.04
- Tối ưu nginx cho traffic cao
- Reverse proxy với HTTPS tự động bằng Caddy
- Chạy Node.js production trên VPS với PM2


