前言
開發調試階段,運行 Flask 的方式多直接使用 app.run(),但 Flask 內置的 WSGI Server 的性能並不高。對於生產環境,一般使用 gunicorn。如果老項目並不需要多高的性能,而且用了很多單進程內的共享變量,使用 gunicorn 會影響不同會話間的通信,那麼也可以試試直接用 gevent。
在 Docker 流行之前,生產環境部署 Flask 項目多使用 virtualenv + gunicorn + supervisor。Docker 流行之後,部署方式就換成了 gunicorn + Docker。如果沒有容器編排服務,後端服務前面一般還會有個 nginx 做代理。如果使用 Kubernetes,一般會使用 service + ingress(或 istio 等)。
運行方式
Flask 內置 WSGI Server
開發階段一般使用這種運行方式。
# main.py
from flask import Flask
from time import sleep
app = Flask(__name__)
@app.get("/test")
def get_test():
sleep(0.1)
return "ok"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=10000)
運行:
python main.py
gevent
使用 gevent 運行 Flask,需要先安裝 gevent
python -m pip install -U gevent
代碼需要稍作修改。
需要注意 monkey.patch_all() 一定要寫在入口代碼文件的最開頭部分,這樣 monkey patch 才能生效。
# main.py
from gevent import monkey
monkey.patch_all()
import time
from flask import Flask
from gevent.pywsgi import WSGIServer
app = Flask(__name__)
@app.get("/test")
def get_test():
time.sleep(0.1)
return "ok"
if __name__ == "__main__":
server = WSGIServer(("0.0.0.0", 10000), app)
server.serve_forever()
運行
python main.py
gunicorn + gevent
如果現有項目大量使用單進程內的內存級共享變量,貿然使用 gunicorn 多 worker 模式可能會導致數據訪問不一致的問題。
同樣需要先安裝依賴。
python -m pip install -U gunicorn gevent
不同於單獨使用 gevent,這種方式不需要修改代碼,gunicorn 會自動注入 gevent 的 monkey patch。
gunicorn 可以在命令行配置啓動參數,但個人一般習慣在 gunicorn 的配置文件內配置啓動參數,這樣可以動態設置一些配置,而且可以修改日誌格式。
gunicorn.conf.py 的配置示例如下:
# Gunicorn 配置文件
from pathlib import Path
from multiprocessing import cpu_count
import gunicorn.glogging
from datetime import datetime
class CustomLogger(gunicorn.glogging.Logger):
def atoms(self, resp, req, environ, request_time):
"""
重寫 atoms 方法來自定義日誌佔位符
"""
# 獲取默認的所有佔位符數據
atoms = super().atoms(resp, req, environ, request_time)
# 自定義 't' (時間戳) 的格式
now = datetime.now().astimezone()
atoms['t'] = now.isoformat(timespec="seconds")
return atoms
# 預加載應用代碼
preload_app = True
# 工作進程數量:通常是 CPU 核心數的 2 倍加 1
# workers = int(cpu_count() * 2 + 1)
workers = 4
# 使用 gevent 異步 worker 類型,適合 I/O 密集型應用
# 注意:gevent worker 不使用 threads 參數,而是使用協程進行併發處理
worker_class = "gevent"
# 每個 gevent worker 可處理的最大併發連接數
worker_connections = 2000
# 綁定地址和端口
bind = "127.0.0.1:10001"
# 進程名稱
proc_name = "flask-dev"
# PID 文件路徑
pidfile = str(Path(__file__).parent / "tmp" / "gunicorn.pid")
logger_class = CustomLogger
access_log_format = (
'{"@timestamp": "%(t)s", '
'"remote_addr": "%(h)s", '
'"protocol": "%(H)s", '
'"host": "%({host}i)s", '
'"request_method": "%(m)s", '
'"request_path": "%(U)s", '
'"status_code": %(s)s, '
'"response_length": %(b)s, '
'"referer": "%(f)s", '
'"user_agent": "%(a)s", '
'"x_tracking_id": "%({x-tracking-id}i)s", '
'"request_time": %(L)s}'
)
# 訪問日誌路徑
accesslog = str(Path(__file__).parent / "logs" / "access.log")
# 錯誤日誌路徑
errorlog = str(Path(__file__).parent / "logs" / "error.log")
# 日誌級別
loglevel = "debug"
運行。gunicorn 的默認配置文件名就是 gunicorn.conf.py,如果文件名不同,可以使用 -c 參數來指定。
gunicorn main:app
傳統進程管理:實現自動啓動
在傳統服務器部署時,常見的進程守護方式有:
- 配置 crontab + shell 腳本。定時檢查進程在不在,不在就啓動。
- 配置 supervisor。
- 配置 systemd。
由於 supervisor 需要單獨安裝,而本着能用自帶工具就用自帶工具、能少裝就少裝的原則,個人一般不會使用 supervisor,因此本文不會涉及如何使用 supervisor。
在服務器部署時,一般也會為項目單獨創建 Python 虛擬環境。
# 使用 Python 內置的 venv,在當前目錄創建 Python 虛擬環境目錄 .venv
python3 -m venv .venv
source .venv/bin/activate
python -m pip install -r ./requirements.txt
# 如果使用uv, 直接uv sync 即可
crontab + shell 腳本 (不推薦生產環境)
剛入行的時候對 systemd 不熟悉,經常用 crontab + shell 腳本來守護進程,現在想想這種方式並不合適,比較考驗 shell 腳本的編寫水平,需要考慮方方面面
- 首先要確保用户級 crontab 啓用,有些生產環境會禁用用户級的 crontab,而且也不允許隨便配置系統級的 crontab。
- crontab 是分鐘級的,服務停止時間可能要一分鐘。
- 如果有控制枱日誌,需要手動處理日誌重定向,還有日誌文件輪轉問題。
- 如果 ulimit 不高,還得控制 ulimit。
- 經常出現殭屍進程,shell 腳本來要寫一堆狀態檢查的邏輯。
如果只需要簡單用用,也可以提供個示例
#!/bin/bash
# 環境配置
export FLASK_ENV="production"
export DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
export REDIS_URL="redis://localhost:6379/0"
script_dir=$(cd $(dirname $0) && pwd)
app_name="gunicorn" # 實際進程名是 gunicorn,不是 Flask app
wsgi_module="wsgi:app" # 替換 WSGI 入口
socket_path="${script_dir}/myapp.sock" # Unix Socket 路徑(避免 /run 重啓丟失)
log_file="${script_dir}/app.log"
pid_file="${script_dir}/gunicorn.pid" # 用 PID 文件控制
# 進程檢測
is_running() {
if [ -f "$pid_file" ]; then
pid=$(cat "$pid_file")
if ps -p "$pid" > /dev/null 2>&1 && grep -q "gunicorn.*${wsgi_module}" /proc/"$pid"/cmdline 2>/dev/null; then
echo "Gunicorn (PID: $pid) is running"
return 0
else
rm -f "$pid_file" # 清理失效 PID
echo "Stale PID file found, cleaned up"
return 1
fi
else
# 備用檢測:通過 socket 文件 + 進程名
if [ -S "$socket_path" ] && pgrep -f "gunicorn.*${wsgi_module}" > /dev/null 2>&1; then
echo "Gunicorn is running (detected by socket)"
return 0
fi
echo "Gunicorn is not running"
return 1
fi
}
# 啓動應用
start_app() {
is_running
if [ $? -eq 0 ]; then
echo "Already running, skip start"
return 0
fi
echo "Starting Gunicorn at $(date)"
echo "Socket: $socket_path"
echo "Log: $log_file"
# 確保 socket 目錄存在
mkdir -p "$(dirname "$socket_path")"
# 啓動命令(關鍵:不加 --daemon,用 nohup 託管)
cd "$script_dir" || exit 1
# 生成 PID 文件
nohup "$script_dir/venv/bin/gunicorn" \
--workers 3 \
--bind "unix:$socket_path" \
--pid "$pid_file" \
--access-logfile "$log_file" \
--error-logfile "$log_file" \
--log-level info \
"$wsgi_module" > /dev/null 2>&1 &
# 等待啓動完成
sleep 2
if is_running; then
echo "✓ Start success (PID: $(cat "$pid_file" 2>/dev/null))"
return 0
else
echo "✗ Start failed, check $log_file"
return 1
fi
}
# 停止應用
stop_app() {
is_running
if [ $? -eq 1 ]; then
echo "Not running, skip stop"
return 0
fi
pid=$(cat "$pid_file" 2>/dev/null)
echo "Stopping Gunicorn (PID: $pid) gracefully..."
# 先發 SIGTERM(優雅停止)
kill -15 "$pid" 2>/dev/null || true
sleep 5
# 檢查是否還在運行
if ps -p "$pid" > /dev/null 2>&1; then
echo "Still running after 5s, force killing..."
kill -9 "$pid" 2>/dev/null || true
sleep 2
fi
# 清理殘留
rm -f "$pid_file" "$socket_path"
echo "✓ Stopped"
}
# 重啓應用
restart_app() {
echo "Restarting Gunicorn..."
stop_app
sleep 1
start_app
}
# 入口函數
main() {
# 檢查 Gunicorn 是否存在
if [ ! -f "$script_dir/venv/bin/gunicorn" ]; then
echo "ERROR: Gunicorn not found at $script_dir/venv/bin/gunicorn"
echo "Hint: Did you activate virtualenv? (source venv/bin/activate)"
exit 1
fi
local action=${1:-start} # 默認動作:start
case "$action" in
start)
start_app
;;
stop)
stop_app
;;
restart)
restart_app
;;
status)
is_running
;;
cron-check)
# 專為 crontab 設計:只檢查+重啓,不輸出干擾日誌
if ! is_running > /dev/null 2>&1; then
echo "[$(date '+%F %T')] CRON: Gunicorn down, auto-restarting..." >> "$log_file"
start_app >> "$log_file" 2>&1
fi
;;
*)
echo "Usage: $0 {start|stop|restart|status|cron-check}"
echo " cron-check: Silent mode for crontab (logs to app.log only)"
exit 1
;;
esac
}
main "$@"
手動運行測試
bash app_ctl.sh start
配置 crontab
# 編輯當前用户 crontab
crontab -e
# 添加以下行(每分鐘檢查一次)
* * * * * /opt/myflaskapp/app_ctl.sh cron-check >/dev/null 2>&1
配置logrotate
# /etc/logrotate.d/myflaskapp
/opt/myflaskapp/app.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate # 避免 Gunicorn 丟失文件句柄
}
systemd (推薦生產環境使用)
- 創建 systemd 服務文件
sudo vim /etc/systemd/system/myflaskapp.service
- 示例如下
[Unit]
Description=Gunicorn instance for Flask App
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/path/to/your/app
Environment="PATH=/path/to/venv/bin"
ExecStart=/path/to/venv/bin/gunicorn \
--workers 4 \
--bind unix:/run/myapp.sock \
--access-logfile - \
--error-logfile - \
wsgi:app
# 禁止添加 --daemon!systemd 需直接監控主進程
Restart=on-failure # 僅異常退出時重啓(非0狀態碼、被信號殺死等)
RestartSec=5s # 重啓前等待5秒
StartLimitInterval=60s # 60秒內
StartLimitBurst=5 # 最多重啓5次,防雪崩
TimeoutStopSec=30 # 停止時等待30秒(優雅關閉)
# 安全加固
PrivateTmp=true
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/run /var/log/myapp
[Install]
WantedBy=multi-user.target
- 設置開機自啓並啓動服務
sudo systemctl daemon-reload
sudo systemctl enable myflaskapp # 開機自啓
sudo systemctl start myflaskapp
可以試試用kill -9停止後端服務進程,觀察能否被重新拉起。
注意,
kill -15算是正常停止,不算異常退出。
Docker 部署方案
- Dockerfile。Python 項目通常不需要多階段構建,單階段即可。
FROM python:3.11-slim-bookworm
# 安全加固
## 創建非 root 用户(避免使用 nobody,權限太受限)
RUN useradd -m -u 1000 appuser && \
# 安裝運行時必需的系統庫(非編譯工具)
apt-get update && apt-get install -y --no-install-recommends \
libgomp1 \
libpq5 \
libsqlite3-0 \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get autoremove -y \
&& apt-get clean
# Python 優化
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# 利用 Docker 層緩存:先複製 requirements
COPY requirements.txt .
RUN pip install --no-cache-dir --prefer-binary -r requirements.txt \
# 清理 pip 緩存(雖然 --no-cache-dir 已禁用,但保險起見)
&& rm -rf /root/.cache
# 應用代碼
COPY --chown=appuser:appuser . .
# 使用非root用户運行
USER appuser
# 啓動
EXPOSE 8000
CMD ["gunicorn", "--config", "config/gunicorn.conf.py", "wsgi:app"]
- 編寫 docker-compose.yaml
services:
web:
image: myflaskapp:latest
container_name: flask_web
# 端口映射
## 如果 nginx 也使用 Docker 部署,而且使用同一個網絡配置,則可以不做端口映射
ports:
- "8000:8000"
# 環境變量
environment:
- FLASK_ENV=production
- DATABASE_URL=postgresql://user:pass@db:5432/mydb
- REDIS_URL=redis://redis:6379/0
# 健康檢查
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s # 每 30 秒檢查一次
timeout: 5s # 超時 5 秒
start_period: 15s # 啓動後 15 秒開始檢查(給應用初始化時間)
retries: 3 # 失敗重試 3 次後標記 unhealthy
# 自動重啓策略
restart: unless-stopped # always / on-failure / unless-stopped
# 資源限制
deploy:
resources:
limits:
cpus: '2' # 最多 2 個 CPU
memory: 1G # 最多 1GB 內存
reservations:
cpus: '0.5' # 保留 0.5 個 CPU
memory: 256M # 保留 256MB 內存
# ulimit 限制(防資源濫用)
ulimits:
nproc: 65535 # 最大進程數
nofile:
soft: 65535 # 打開文件數軟限制
hard: 65535 # 打開文件數硬限制
core: 0 # 禁止 core dump
# 安全加固
security_opt:
- no-new-privileges:true # 禁止提權
# 只讀文件系統(除 /tmp 外)
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=100m
# 卷掛載(日誌、臨時文件)
volumes:
- ./logs:/app/logs:rw
# - ./static:/app/static:ro # 靜態文件(可選)
# 網絡
networks:
- app-network
# 網絡配置
networks:
app-network:
driver: bridge
# 卷配置
volumes:
db_data:
driver: local
redis_data:
driver: local
Kubernetes 部署方案
Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: flask-app
namespace: default
labels:
app: flask-app
tier: backend
spec:
replicas: 3
selector:
matchLabels:
app: flask-app
template:
metadata:
labels:
app: flask-app
tier: backend
spec:
securityContext:
runAsNonRoot: true # 禁止 root 運行
runAsUser: 1000 # 使用非 root 用户
runAsGroup: 1000
fsGroup: 1000
seccompProfile:
type: RuntimeDefault # 啓用 seccomp 安全策略
containers:
- name: flask-app
image: myregistry.com/myflaskapp:1.0.0
imagePullPolicy: IfNotPresent # 生產環境建議用 Always
ports:
- name: http
containerPort: 8000
protocol: TCP
env:
- name: FLASK_ENV
value: "production"
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: flask-app-secrets
key: database-url
- name: REDIS_URL
valueFrom:
secretKeyRef:
name: flask-app-secrets
key: redis-url
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: flask-app-secrets
key: secret-key
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "512Mi" # 超過會 OOM Kill
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8000
scheme: HTTP
initialDelaySeconds: 30 # 啓動後 30 秒開始檢查
periodSeconds: 10 # 每 10 秒檢查一次
timeoutSeconds: 3 # 超時 3 秒
successThreshold: 1
failureThreshold: 3 # 失敗 3 次後重啓容器
readinessProbe:
httpGet:
path: /health
port: 8000
scheme: HTTP
initialDelaySeconds: 10 # 啓動後 10 秒開始檢查
periodSeconds: 5 # 每 5 秒檢查一次
timeoutSeconds: 2
successThreshold: 1
failureThreshold: 3 # 失敗 3 次後從 Service 移除
startupProbe:
httpGet:
path: /health
port: 8000
scheme: HTTP
failureThreshold: 30 # 最多重試 30 次
periodSeconds: 5 # 每 5 秒一次,共 150 秒容忍慢啓動
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false # 禁止提權
readOnlyRootFilesystem: true # 根文件系統只讀
capabilities:
drop:
- ALL # 刪除所有 Linux capabilities
privileged: false
volumeMounts:
- name: tmp-volume
mountPath: /tmp
- name: config-volume
mountPath: /app/config
readOnly: true
imagePullSecrets:
- name: registry-secret # 如果使用私有鏡像倉庫
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values:
- flask-app
topologyKey: kubernetes.io/hostname # 避免所有 Pod 調度到同一節點
volumes:
- name: tmp-volume
emptyDir:
medium: Memory # 使用內存卷,更快
sizeLimit: 100Mi
- name: config-volume
configMap:
name: flask-app-config
Service
apiVersion: v1
kind: Service
metadata:
name: flask-app-service
namespace: default
labels:
app: flask-app
tier: backend
spec:
type: ClusterIP
selector:
app: flask-app
ports:
- name: http
port: 80 # Service 端口
targetPort: 8000 # Pod 端口
protocol: TCP
ingress-nginx
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: flask-app-ingress
namespace: default
annotations:
# ==================== Nginx 配置 ====================
kubernetes.io/ingress.class: "nginx"
# 啓用 HTTPS 重定向
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
# 限流(每秒 10 個請求,突發 20)
nginx.ingress.kubernetes.io/limit-rps: "10"
nginx.ingress.kubernetes.io/limit-burst-multiplier: "2"
# 客户端真實 IP
nginx.ingress.kubernetes.io/enable-real-ip: "true"
nginx.ingress.kubernetes.io/proxy-real-ip-cidr: "0.0.0.0/0"
# 連接超時
nginx.ingress.kubernetes.io/proxy-connect-timeout: "60"
nginx.ingress.kubernetes.io/proxy-send-timeout: "60"
nginx.ingress.kubernetes.io/proxy-read-timeout: "60"
# 緩衝區大小
nginx.ingress.kubernetes.io/proxy-buffering: "on"
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
# Gzip 壓縮
nginx.ingress.kubernetes.io/enable-gzip: "true"
nginx.ingress.kubernetes.io/gzip-level: "6"
nginx.ingress.kubernetes.io/gzip-min-length: "1024"
nginx.ingress.kubernetes.io/gzip-types: "text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript"
# 安全頭
nginx.ingress.kubernetes.io/configuration-snippet: |
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 認證
# nginx.ingress.kubernetes.io/auth-type: basic
# nginx.ingress.kubernetes.io/auth-secret: flask-app-basic-auth
# nginx.ingress.kubernetes.io/auth-realm: "Authentication Required"
# 自定義錯誤頁面
# nginx.ingress.kubernetes.io/custom-http-errors: "404,500,502,503,504"
# nginx.ingress.kubernetes.io/default-backend: custom-error-pages
# 重寫目標
# nginx.ingress.kubernetes.io/rewrite-target: /$1
# WAF(如果安裝了 ModSecurity)
# nginx.ingress.kubernetes.io/enable-modsecurity: "true"
# nginx.ingress.kubernetes.io/modsecurity-snippet: |
# SecRuleEngine On
# SecRequestBodyAccess On
spec:
tls:
- hosts:
- flask.example.com
secretName: flask-app-tls-secret # TLS 證書 Secret
rules:
- host: flask.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: flask-app-service
port:
number: 80