Triển khai ứng dụng Python với Gunicorn và Nginx trên CentOS Stream 9: Cấu hình production bảo mật với SELinux, systemd và virtualenv

CentOS tutorial - IT technology blog
CentOS tutorial - IT technology blog

Bối cảnh — tại sao lại chọn stack này trên CentOS Stream 9?

Công ty mình vẫn còn vài con server chạy CentOS 7, và bài toán migrate sang AlmaLinux thì đã giải quyết xong. Với những dự án mới, mình chọn thẳng CentOS Stream 9 — đây là upstream của RHEL 9, package mới về sớm hơn Rocky/Alma khoảng 4–6 tuần, và Python 3.11 đã có sẵn trong default repos mà không cần thêm third-party source.

Cái đau đầu nhất khi deploy Python app lên RHEL-based distro không phải Nginx hay Gunicorn — mà là SELinux. Chạy setenforce 0 thì giải quyết được ngay, nhưng làm vậy trên server production là tự bắn vào chân. Bài này mình đi qua toàn bộ luồng deploy đúng cách: virtualenv → Gunicorn → systemd service → Nginx reverse proxy → SELinux policy — không tắt, không workaround bẩn.

Stack: CentOS Stream 9, Python 3.11, Flask (Django dùng y chang), Gunicorn 21.x, Nginx 1.24.

Cài đặt môi trường

Chuẩn bị hệ thống

Update hệ thống và cài các package cần thiết — bước này mất vài phút trên server mới:

sudo dnf update -y
sudo dnf install -y python3.11 python3.11-pip python3.11-devel nginx gcc

Tạo system user riêng để chạy app. Không bao giờ dùng root — và cũng đừng dùng account cá nhân. System user không có home directory, không có login shell, nếu app bị compromise thì attacker cũng không leo thang lên được nhiều:

sudo useradd --system --no-create-home --shell /sbin/nologin appuser

Tạo virtualenv và cài dependencies

Mình đặt app trong /opt/myapp thay vì /home — SELinux có sẵn context rules cho /opt, việc gán label sau này đơn giản hơn nhiều:

sudo mkdir -p /opt/myapp
sudo chown appuser:appuser /opt/myapp

# Tạo virtualenv
sudo -u appuser python3.11 -m venv /opt/myapp/venv

# Cài dependencies
sudo -u appuser /opt/myapp/venv/bin/pip install gunicorn flask

Tạo file app mẫu để test:

# /opt/myapp/app.py
from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello from CentOS Stream 9!', 200

@app.route('/health')
def health():
    return {'status': 'ok'}, 200

if __name__ == '__main__':
    app.run()

Cấu hình chi tiết

systemd service cho Gunicorn

Unix socket thay vì TCP port — lựa chọn này vừa nhanh hơn (khoảng 15–20% với I/O-bound app) vừa giảm bề mặt tấn công, và dễ cấu hình SELinux label hơn:

sudo nano /etc/systemd/system/myapp.service
[Unit]
Description=Gunicorn daemon for myapp
After=network.target

[Service]
Type=notify
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
Environment="PATH=/opt/myapp/venv/bin"
EnvironmentFile=-/opt/myapp/.env
ExecStart=/opt/myapp/venv/bin/gunicorn \
    --workers 3 \
    --worker-class gthread \
    --threads 2 \
    --bind unix:/run/myapp/gunicorn.sock \
    --access-logfile /var/log/myapp/access.log \
    --error-logfile /var/log/myapp/error.log \
    app:app
ExecReload=/bin/kill -s HUP $MAINPID
RuntimeDirectory=myapp
RuntimeDirectoryMode=0750
PrivateTmp=true
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target

Cấu hình trên dùng gthread worker với 3 workers × 2 threads — tổng 6 luồng xử lý đồng thời. gthread phù hợp cho app có nhiều I/O (database query, external API call), khác với sync worker mặc định vốn chỉ xử lý 1 request mỗi worker.

Tạo thư mục log và phân quyền:

sudo mkdir -p /var/log/myapp
sudo chown appuser:appuser /var/log/myapp

Enable và start service:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myapp

Nginx reverse proxy

Cấu hình Nginx forward request tới Gunicorn qua Unix socket:

sudo nano /etc/nginx/conf.d/myapp.conf
upstream myapp_gunicorn {
    server unix:/run/myapp/gunicorn.sock fail_timeout=0;
}

server {
    listen 80;
    server_name example.com;

    access_log /var/log/nginx/myapp_access.log;
    error_log  /var/log/nginx/myapp_error.log;

    location / {
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_pass http://myapp_gunicorn;
        proxy_read_timeout 90s;
    }

    location /static/ {
        alias /opt/myapp/static/;
        expires 30d;
    }
}
sudo nginx -t && sudo systemctl enable --now nginx

Xử lý SELinux — phần không ai muốn làm nhưng buộc phải làm

Đây là chỗ mình mất nhiều thời gian nhất khi mới bắt đầu. Triệu chứng kinh điển: Nginx trả về 502 Bad Gateway, journalctl -u myapp thấy Gunicorn đang chạy bình thường, Nginx error log chỉ thấy connect() to unix:/run/myapp/gunicorn.sock failed (13: Permission denied). Đó là SELinux đang block ngầm — không phải lỗi app.

Cho phép Nginx kết nối tới socket:

# Cho phép Nginx connect đến network socket của app
sudo setsebool -P httpd_can_network_connect 1

# Label đúng context cho socket directory
sudo semanage fcontext -a -t httpd_var_run_t "/run/myapp(/.*)?" 
sudo restorecon -Rv /run/myapp

Nếu app cần đọc file trong /opt/myapp:

sudo semanage fcontext -a -t httpd_exec_t "/opt/myapp/venv/bin/gunicorn"
sudo semanage fcontext -a -t httpd_sys_content_t "/opt/myapp(/.*)?" 
sudo restorecon -Rv /opt/myapp

Mỗi khi gặp 502 không rõ nguyên nhân, mình chạy cặp lệnh này trước tiên:

# Xem log SELinux gần nhất
sudo ausearch -m avc -ts recent | audit2why

# Tạo policy tạm từ log để test
sudo ausearch -m avc -ts recent | audit2allow -M myapp_policy
sudo semodule -i myapp_policy.pp

Firewall

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

Kiểm tra và Monitoring

Verify stack đang chạy đúng

# Kiểm tra socket đã được tạo
ls -la /run/myapp/gunicorn.sock

# Test trực tiếp qua socket (bypass Nginx)
curl --unix-socket /run/myapp/gunicorn.sock http://localhost/health

# Test qua Nginx
curl -I http://localhost/

# Xem log realtime
journalctl -u myapp -f

Theo dõi workers và performance

Gunicorn docs khuyên tính số workers theo công thức (2 × CPU cores) + 1. Server 2 core → dùng 5 workers. Với cấu hình 3 workers × 2 threads ở trên, app xử lý được 6 request đồng thời — đủ cho hầu hết workload trừ khi site có traffic đột biến lớn.

# Kiểm tra PID và trạng thái workers
ps aux | grep gunicorn

# Reload config không downtime
sudo systemctl reload myapp

# Xem log access theo format
tail -f /var/log/myapp/access.log

Log rotation

Log app không tự xoay — nếu không cấu hình logrotate, vài tháng sau disk đầy lúc nào không hay:

sudo nano /etc/logrotate.d/myapp
/var/log/myapp/*.log {
    daily
    missingok
    rotate 14
    compress
    delaycompress
    notifempty
    sharedscripts
    postrotate
        systemctl kill -s USR1 myapp
    endscript
}

Checklist trước khi đưa lên production

  • SELinux đang ở mode enforcing (kiểm tra bằng getenforce)
  • App không chạy bằng root (ps aux | grep gunicorn phải thấy appuser)
  • Endpoint /health trả về 200 qua cả socket lẫn Nginx
  • Log rotation đã cấu hình
  • Service tự khởi động lại khi server reboot (systemctl is-enabled myapp)
  • File .env không được commit lên git, chỉ có trên server

Stack Gunicorn + Nginx + systemd + SELinux này mình đang chạy ổn định trên mấy con server production, uptime liên tục hơn 6 tháng không cần can thiệp thủ công. Sau khi setup xong, toàn bộ lifecycle của app do systemd quản lý: restart tự động khi crash, log tập trung qua journalctl, không cần supervisor hay pm2. Đơn giản mà vững.

Share: