☰
Python Web 生产部署:Docker + Nginx + Gunicorn 容器化方案实战
2026/10/9 3:22:30 网站建设 项目流程

前阵子帮朋友把一个 Python Web 项目从开发机搬到服务器,本地跑得好好的,一上线就各种“意外”:502、静态文件丢失、Python 依赖冲突、进程半夜挂掉。折腾了整整一个周末后,我把整套部署方案理成了 Docker + Nginx + Gunicorn 的组合。后来再部署新项目,基本一小时以内搞定。这篇文章就把我实际用下来的这套流程写出来,适合准备把 Flask/Django 应用送上线的朋友,也适合不想只抄命令、想搞清楚“为什么这么搭”的人。

1. 部署架构与核心思路:为什么是 Docker + Nginx

先说结论:我最终选的架构是“客户端 → Nginx → Gunicorn → Python Web 应用”,所有组件都跑在 Docker 容器里,用 docker-compose 统一编排。这套方案并不花哨,但它能解决我在生产环境里踩过的绝大多数坑。

1.1 直接在服务器上跑 Python 为什么会出问题

很多人刚接触部署时,会把服务器当成另一台开发机:先装 Python,再 pip install,最后 python app.py。这种思路在本地 demo 没问题,但一放到生产环境就很痛苦。

最典型的问题有两个。第一是环境隔离。今天装的是 Python 3.11,明天为了另外一个项目改了 PATH 或者装了个新版本,整个系统环境就乱了。pip 的包默认装到全局,A 项目用 Flask 2.0,B 项目需要 Flask 3.0,时间一长依赖必然打架。第二是进程管理。直接用开发服务器启动,终端一关进程就没了;就算用 nohup 让它后台跑,一旦崩溃没人帮你拉起来,更别说开机自启和日志轮转。

Docker 解决的就是这三件事:环境隔离、启动方式统一、构建可复现。把 Python 版本、依赖、启动命令全部写进镜像,不管在哪台机器上跑,行为都是一样的。

1.2 Nginx 在这里不是抢饭碗,而是补短板

有人可能会问:Flask 有内置服务器,Django 也有 runserver,为什么还要 Nginx?

因为 Python 自带的开发服务器压根不适合直接暴露到公网。它单进程、性能一般,也没有经过严格的安全加固。生产环境里我会让 Gunicorn 起多个 worker 来跑 Python 应用,但直接把 Gunicorn 暴露到公网同样不理想:它不擅长处理慢连接、大并发下的静态文件、SSL 卸载这些事。

Nginx 是事件驱动的,处理高并发静态请求非常轻松,配置还简单。它在这里扮演“前台接待”的角色,Gunicorn 是“后厨”。客人不会直接冲到后厨点菜,而是先由前台接单、分流、判断该让谁处理。

1.3 一条完整请求到底是怎么走的

理解请求链路对排查问题非常重要。我用一个最简单的例子说明:

  1. 用户输入域名,DNS 解析到服务器 IP。
  2. 请求到达服务器的 80 或 443 端口,这个端口只被 Nginx 容器监听。
  3. Nginx 根据 server_name 和 location 规则匹配路径。
  4. 通过 proxy_pass 把请求转发到 Docker 内部网络里的 app 容器,端口是 8000。
  5. 容器里的 Gunicorn 收到请求,分配给某个 worker 执行 Python 代码。
  6. 响应原路返回给 Nginx,Nginx 再返回给浏览器。

这里有个关键认知:Docker 容器内部的 8000 端口,和服务器本机的 8000 端口不一定是一回事。如果容器没有做-p 8000:8000映射,宿主机上访问 localhost:8000 是打不开的。后面部署时,我们要刻意利用这一点做隔离:应用端口只在容器内部网段开放,外面只暴露 Nginx 的 80/443。

2. 先把 Docker 环境准备好

正式写部署配置之前,先把服务器上的 Docker 环境装好。下面以 Ubuntu 22.04 / Debian 12 为例,CentOS 的包管理命令会不一样,但思路相同。

2.1 安装 Docker 与 docker-compose 插件

安装 Docker 本身不复杂,但版本容易搞混。我用的是软件源自带的 docker.io 和 docker-compose-v2,够用且稳定。如果你想体验最新特性,再考虑去 Docker 官方源装 docker-ce。

sudo apt update sudo apt install -y docker.io docker-compose-v2 sudo systemctl enable --now docker sudo usermod -aG docker $USER newgrp docker

装完以后先验证一下:

docker --version docker compose version

usermod -aG docker的目的是让当前用户不用每次 sudo,但加进 docker 组后,一定要重新登录会话或者执行newgrp docker才会生效。这一步容易漏,漏了之后会发现 docker 命令一直提示权限不足。

注意:能让当前用户直接执行 docker 确实方便,但这也意味着这个用户拥有很高的系统权限。如果你和多人共用一台服务器,建议还是给系统管理员保留 sudo,普通用户一律走容器部署流程。

2.2 写一个生产可用的 Dockerfile

很多教程里的 Dockerfile 只是把代码复制进去然后启动开发服务器,这种镜像拿来上线会埋雷。我常用的 Python 服务镜像大概是这样的:

FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["gunicorn", "app:app", "-b", "0.0.0.0:8000", "-w", "4"]

解释几个关键点:

  • python:3.12-slim比完整版小很多,又保留了编译环境,兼容性比 alpine 稳。如果项目里用到 psycopg2、bcrypt 这类需要编译的库,alpine 很容易让你陷入“装 gcc 装到怀疑人生”的局面。
  • PYTHONDONTWRITEBYTECODE是让 Python 不要生成__pycache__,PYTHONUNBUFFERED是让日志实时输出,这两个环境变量对容器场景非常有用。
  • COPY requirements.txt .放在COPY . .之前,是为了利用 Docker 构建缓存。只要依赖没变,后面改代码重新构建时不会重复装依赖。
  • 最后一定要用 Gunicorn 启动,不要用python app.py。app:app的意思是app.py文件里的app对象。如果项目是 Django,启动命令要改成wsgi:application这种入口。
  • USER appuser解决的是容器内 root 权限问题。镜像里单独建一个普通用户,能避免一部分容器逃逸风险。

2.3 .dockerignore 别忘

和.gitignore一样,.dockerignore用来排除不需要打进镜像的文件。没有它,你可能会把本地的虚拟环境、缓存、.env密钥文件全塞进镜像。

__pycache__/ *.pyc *.pyo .venv/ venv/ .env .git/ .gitignore Dockerfile .dockerignore

我见过有人把数据库密码写进.env,然后忘记在.dockerignore里排除,最后镜像传到仓库,谁拿下来都能看到。这种事故完全可以靠一行规则避免。

2.4 先在本地把镜像跑通,再传服务器

写完了 Dockerfile,别急着往服务器传。先在本地或者一台测试机构建并运行:

docker build -t my-python-app:1.0.0 . docker run --rm -p 8000:8000 my-python-app:1.0.0 curl http://127.0.0.1:8000/

如果能返回页面,说明镜像本身没问题。这里有个细节:Gunicorn 绑定的地址必须写0.0.0.0:8000,不能只写127.0.0.1:8000。否则容器外部访问不到,因为 127.0.0.1 在容器里代表容器自己,而不是宿主机。

3. Nginx 反向代理配置细节

Nginx 是整套方案的入口。下面这套配置我用了很久,可以复用。

3.1 一个能用的 Nginx 容器配置

我推荐让 Nginx 也容器化,这样整个项目从构建到运行都是 Docker 管到底。先建一个nginx/nginx.conf文件:

server { listen 80; server_name example.com; # 改成你的域名或服务器 IP client_max_body_size 20m; location / { proxy_pass http://app:8000; proxy_set_header Host $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; } }

这里的http://app:8000不是随便写的。在 docker-compose 里,服务名app会自动解析到对应容器的内网 IP。只要 Nginx 容器和 app 容器在同一个网络里,就可以直接用服务名通信。

proxy_set_header这些头部非常关键。X-Forwarded-For用来保留真实客户端 IP,Host必须转成原始域名,否则 Flask/Django 生成绝对 URL 时会用错 host。

注意:很多 502 都是因为这里写成了http://localhost:8000。在容器里 localhost 指向 Nginx 容器自己,不是 app 容器。只要在 docker-compose 网络里,就老老实实用服务名。

3.2 静态文件与用户上传文件交给 Nginx

如果你做的是 API 后端,静态文件可以全交给 CDN。但如果项目里有 Flask admin、Django admin、用户头像这些资源,一定要让 Nginx 直接处理,不要让请求经过 Gunicorn。

在 Nginx 配置里加一段:

location /static/ { alias /var/www/html/static/; expires 7d; access_log off; } location /media/ { alias /var/www/html/media/; expires 30d; }

alias和root的区别是新手最容易搞混的。我用alias是因为它不会把 URL 里的/static/拼到目录路径后面。如果写成root /var/www/html/static/,Nginx 会去找/var/www/html/static/static/xxx,多半会 404。

3.3 HTTPS 配置与反向代理的注意点

现在上线没有 HTTPS 说不过去。签发正式证书推荐用 Let's Encrypt 或 Certum 的免费证书。我习惯先在 Nginx 容器停止的情况下,用 certbot 的 standalone 模式签好证书,再把证书文件挂载进容器。

HTTPS 的 server 配置大概是这样:

server { listen 443 ssl; server_name example.com; ssl_certificate /etc/nginx/ssl/fullchain.pem; ssl_certificate_key /etc/nginx/ssl/privkey.pem; location / { proxy_pass http://app:8000; proxy_set_header Host $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; } } server { listen 80; server_name example.com; return 301 https://$host$request_uri; }

开启 HTTPS 后,应用层面也要配合。Flask 和 Django 都有PREFERRED_URL_SCHEME类似的配置,让它们在生成重定向链接时读X-Forwarded-Proto。否则会出现“网页是 https 访问的,但点击登录后跳到了 http”这种诡异问题。

4. 用 docker-compose 把所有依赖管起来

到了这一步,我会用 docker-compose 把 app、nginx、数据库放在一起管理。单独 docker run 一个服务很简单,但多个服务之间的网络、启动顺序、持久化存储,用 compose 文件才是正解。

4.1 一份可以直接改的 docker-compose.yml

下面是一个比较完整的示例。如果你没数据库,把 db 服务删掉就行。

services: app: build: . restart: unless-stopped expose: - "8000" env_file: - .env volumes: - static_volume:/app/static - media_volume:/app/media depends_on: db: condition: service_healthy nginx: image: nginx:1.27-alpine restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - static_volume:/var/www/html/static:ro - media_volume:/var/www/html/media:ro - ./ssl:/etc/nginx/ssl:ro depends_on: - app db: image: mysql:8.0 restart: unless-stopped environment: MYSQL_DATABASE: ${DB_NAME} MYSQL_USER: ${DB_USER} MYSQL_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} volumes: - db_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 10s timeout: 5s retries: 5 volumes: static_volume: media_volume: db_data:

注意 app 服务里用的是expose而不是ports。expose只把端口暴露给 Docker 网络里的其他容器,宿主机无法直连 8000。这能避免你的 Python 服务被外部绕过 Nginx 直接访问,是一个免费的安全层。

4.2 环境变量和密钥不能写进镜像

.env文件是 compose 自动读取的,里面放数据库密码、API Key、各种密钥。第一步的.dockerignore里把.env排掉,所以构建镜像时它不会被打进去。运行时再通过env_file把环境变量注入容器,成本和风险都低很多。

.env的一个好处是 compose 文件里所有${DB_NAME}占位符都会被替换。用docker compose config命令可以查看最终生效的配置,避免出现“我以为改了变量但容器没读到”的尴尬。

4.3 首次上线、更新与回滚

首次上线只需要一条命令:

docker compose up -d --build

执行完之后看看所有服务的状态:

docker compose ps docker compose logs -f --tail=200 app

如果有数据库迁移,要在容器里执行:

docker compose exec app python manage.py migrate

更新流程也很固定:

git pull # 或者直接用 scp 把新代码传到服务器 docker compose up -d --build docker compose exec app python manage.py migrate

回滚时我吃过教训:不要在 compose 文件里写image: myapp:latest。latest 很难让人判断“现在跑的是哪个版本”。推荐做法是构建镜像时打上明确的版本号,比如myapp:1.0.0,发布后用 1.0.0,出问题就改回上一个 tag。假如这次发布后线上 502,先别慌,把 compose 里的 tag 从 1.0.1 改成 1.0.0,再docker compose up -d,几秒钟就能恢复服务。

5. 常见故障排查与安全加固

部署完以后,不等于万事大吉。下面这些问题都是我实际处理过的。

5.1 502、404、403 的排查顺序

我把最常见的现象、原因、排查手段整理成了一张表,遇到问题先看症状,再对表操作:

现象常见原因排查思路
502 Bad Gatewayapp 容器没起来、Gunicorn 绑定错误、Nginx 指错了服务名docker compose ps看状态;docker compose logs app看报错;确认 Nginx 配置里是http://app:8000
404 Not FoundNginx 默认站点没关、server_name 不匹配、路径 location 写错docker compose exec nginx nginx -T查看实际加载的配置;把/etc/nginx/sites-enabled/default删掉或停用
403 Forbidden静态目录权限不够、Nginx 没有读取权限查看挂载目录权限;容器内外用户 UID 是否一致;检查目录所有者
端口一直不通安全组/防火墙没放行、Nginx 容器没映射端口docker compose ps确认端口映射;在服务器上ss -lntp看监听;去云控制台检查安全组入方向

502 是最常见也最让人抓狂的。我的习惯是最先看日志,不看日志就改配置只会越改越乱。docker compose logs里如果出现Connection refused,九成是 app 容器没起来或者端口不匹配。

5.2 静态文件 404 别忽略 alias 的尾部斜杠

路径问题里有一个特别隐蔽的坑:location /static/和alias /var/www/html/static/的尾部斜杠。反斜杠要么都写,要么都不写,但要求对应 Nginx 的合并规则。我自己的经验是保持前后都有/,测试最稳。

另外,容器挂载时我用了:ro只读模式,避免 Nginx 容器不小心改动静态文件。如果你用 bind mount 直接挂宿主机目录,还要注意文件夹权限。Nginx 工作进程一般是以 nginx 用户运行,宿主机目录权限是 755 通常没问题,但如果 700 就会 403。

5.3 给容器加资源限制

Python 应用有时候内存会突然飙高,一个 worker 异常可能拖垮整台服务器。生产环境最好在 compose 文件里限制资源占用:

services: app: mem_limit: 512m cpus: 1.0

mem_limit和cpus是 docker compose 里比较通用的写法。限制之后,最坏情况下单个容器占满资源,也不会让整台宿主机直接卡死。配合restart: unless-stopped,容器崩溃后会自动重启,用户感知会小很多。

5.4 上线前的安全清单

这个清单是根据我自己的事故经验总结出来的:

  • .env和数据库密码不要提交到 Git,也不要写进 Dockerfile。
  • 应用端口不要映射到宿主机,只需要expose。
  • 容器内用非 root 用户运行应用,Dockerfile 里加USER appuser。
  • Nginx 配置里加client_max_body_size,防止大量上传请求撑爆内存。
  • 定期备份数据库 data volume。备份最简单的方式是用docker compose exec db sh -c 'exec mysqldump ...'导出到宿主机。
  • 上线前先加一个/healthz健康检查接口,方便 nginx 或监控系统探测。

6. 上线前最后检查清单

最后分享一个我每次上线都会过的检查清单,虽然看起来简单,但能避免九成低级失误:

  • 本地docker build是否通过?
  • 本地docker run后 curl 是否返回正常页面?
  • compose 文件里的服务名和 Nginx 配置里的proxy_pass是否一致?
  • .env是否已经复制到服务器,并且权限设为 600?
  • docker compose config是否解析成功?
  • nginx 配置有没有先做语法检查?docker compose exec nginx nginx -t
  • 数据库迁移是否执行过?生产数据有没有备份?

这套 Docker + Nginx 的部署方式我用了很久,从 Flask 到 Django,从小项目到带 MySQL、Redis 的服务,基本都是同一个套路。再复杂的微服务,也只不过是把 compose 文件里的服务加多几个。把流程固定下来以后,你会发现自己不再害怕“上线上坏了怎么办”,因为每一环都有回滚方案和排查路径。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询