把 Python Web 应用部署到服务器,几乎是每个写后端的人绕不开的一步。本地 debug 模式跑得风生水起,一旦要挂到公网上,各种问题就冒出来了:依赖装不上、进程没人管、端口冲突、静态文件 404……我自己第一次部署一个后台管理面板 Demo 的时候就折腾到凌晨两点。后来把所有流程梳理成一套固定套路——Docker 做环境打包,Nginx 做入口代理,Gunicorn 跑 Python 进程,之后每次上线基本半小时搞定。这篇文章就围绕这套组合,把从服务器准备、镜像编写、容器编排到 Nginx 配置的完整过程拆开揉碎,顺便把我踩过的那些坑全部交代清楚。适合已经把项目本地跑通、正准备把它搬到服务器上的朋友,也适合部署过一次但总被各种小问题卡住的读者。
1. 为什么要用 Docker + Nginx 这套组合
1.1 先搞明白一次请求进来以后发生了什么
在写配置之前,我建议先在脑子里过一遍整个链路。用户浏览器访问你的域名或 IP,这个请求先到达服务器上的 Nginx,Nginx 根据配置把请求转发给后端的 Python 应用进程,Python 应用处理完以后把响应原路返回。这里有一个关键点:Python 框架自带的开发服务器并不能直接扛生产流量。
拿最常见的 Flask 举例,它自带的app.run()启动的是 Werkzeug 开发服务器,这个服务器设计目标就是在本地调试时用的,单进程、性能弱、还带着调试模式的安全风险。生产环境里我们一般用 Gunicorn 或者 uWSGI 这类专门的 WSGI 服务器来跑 Python 应用。所以完整链路是 Nginx → Gunicorn → 你的 Flask/FastAPI 应用,而不是浏览器直接访问 Python 进程。
用 Docker 之后,这条链路会变成两个或三个容器:一个容器跑 Gunicorn 和你的应用代码,一个容器跑 Nginx。两个容器通过一个内部网络互相通信,外部请求只经过 Nginx。理解这个结构之后,后面所有配置都不会懵。
1.2 Docker 解决了环境一致性这个老大难
我以前在服务器上部署 Python 项目,最头疼的就是环境问题。本地用的是 Python 3.11,服务器上是 3.8,某个依赖编译不过去;或者本地装好了 libpq,服务器上没有导致 psycopg2 安装失败。这类问题浪费的时间比写代码还多。Docker 的解决思路很粗暴:把 Python 环境、依赖、代码、启动命令全部打包进镜像,镜像在哪个机器上跑,环境就一模一样。
镜像本身是不可变的,所以回滚也方便。新代码出问题,把容器换成上一个镜像启动就完事,不用在服务器上卸载重装各种包。而且 Docker 的容器和宿主机是隔离的,你在容器里装什么都不会污染服务器本身的系统环境。对只有一台云服务器、要跑好几个项目的人来说,这个隔离性价值很大。
1.3 Nginx 为什么要挡在最前面
既然 Gunicorn 能直接监听端口对外提供服务,为什么前面还要加一层 Nginx?我在本地实验的时候也想过这个问题,后来在公网环境跑过就明白了。
第一是静态文件的处理效率差异非常大。Gunicorn 作为一个 Python 进程,处理图片、CSS、JS 这类静态文件的能力远不如 Nginx。Nginx 处理静态文件用的是 C 实现的事件驱动模型,几万并发静态请求都不太喘气,Python 这边一旦静态请求多了,动态接口的响应速度就被拖下来。
第二是 Nginx 可以做很多 HTTP 层的事:请求体大小限制、超时控制、Gzip 压缩、SSL 证书终结、请求日志记录。这些如果用 Python 代码自己实现,要写不少逻辑,而且性能还比不上 Nginx 的原生实现。
第三是进程管理上的优势。Gunicorn 暴露在公网上意味着任何人都可以直接打你的 Python 进程端口,一些恶意扫描、HTTP 头攻击都没有缓冲层。Nginx 在中间可以配置各种过滤规则,而且即使 Nginx 后面挂的服务重启了,Nginx 本身不会断,用户得到的只是一个临时 502,恢复后请求又能继续转发,体验上好很多。
2. 部署前的准备:服务器环境与本地项目打磨
2.1 服务器的初始设置
不管你用哪家云服务商,拿到一台新服务器后我建议先做几件基础的事。
系统我推荐 Debian 系,比如 Ubuntu 22.04 或 Debian 12。不是其他系统不好,而是 Debian 系的软件包版本比较新、社区资料多,遇到问题搜索一下基本都有答案。
拿到服务器先更新系统包:
sudo apt update sudo apt upgrade -y然后设置时区,这个特别容易漏。我遇到过容器里的日志时间比北京时间慢 8 个小时,排查半天才发现是系统时区没设置。直接统一设置:
sudo timedatectl set-timezone Asia/Shanghai新建一个普通用户来操作,不要一直用 root。即使你是个人服务器,也建议养成这个习惯。然后在服务器上安装基础工具,比如curl、git、vim,这些看个人习惯。
2.2 安装 Docker 与 Compose 插件
Docker 的安装方式,官方那边有多种途径。我这里给出一套在 Debian/Ubuntu 上比较通用的方法。
先安装依赖,让 apt 可以通过 HTTPS 拉取软件包:
sudo apt install -y ca-certificates curl gnupg lsb-release添加 Docker 的官方 GPG 密钥和软件源。注意不同系统的代号不一样,下面的/etc/os-release可以自动识别:
sudo mkdir -p /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null然后安装:
sudo apt update sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin安装完以后验证一下:
sudo docker run hello-world docker compose version如果docker compose version能输出版本号,说明 Compose 插件装好了。我给个建议:不要单独去装那种 Python 写的旧版docker-compose,直接用现在 Docker 自带的docker compose插件,命令更统一,也不会有语法版本不一致的问题。
把当前用户加入 docker 组,免得每次都要 sudo:
sudo usermod -aG docker $USER newgrp docker这里提醒一句,加入 docker 组实际上相当于给了这个用户 root 级别的权限,因为 docker 可以挂载宿主机目录。个人服务器问题不大,但心里要有数。
2.3 本地项目在部署前要解决的三件事
线上部署踩坑,很多时候不是服务器环境的问题,而是本地项目本身就没准备好。我在部署前一定会过三关。
第一关,依赖锁定。如果项目里只有一个requirements.txt,里面的包名没有锁版本号,比如requests>=2.0,那在服务器上构建镜像时可能装到完全不同的版本,导致应用行为不一致。更好的做法是用 pip 生成精确的锁文件:
pip freeze > requirements.txt但pip freeze会把一些间接依赖也列进去,稍微有点脏。也可以用 pip-tools 这类工具管理,写一个requirements.in,再编译出requirements.txt,这样可控性更高。总之目标是:同一份依赖文件,在任何机器上装出来的环境都一致。
第二关,配置不要写死在代码里。数据库地址、密钥、调试开关这些东西,一定要从环境变量或外部配置文件读取。因为在 Docker 环境下,代码打进镜像后是没法临时改文件的,所有可变配置都得通过环境变量传进去。一个简单但可靠的做法是在代码里读os.environ.get(),本地开发时加载.env文件,线上由 docker compose 注入。
第三关,本地用 Gunicorn 先试跑一下生产模式。不要等到服务器上才发现应用没法在多进程下正常工作。特别是项目里用了全局对象、内存缓存、文件锁这类东西的,多进程模式下经常出问题。本地先跑一遍 Gunicorn 能提前暴露掉一大部分。
pip install gunicorn gunicorn --workers 2 --bind 0.0.0.0:8000 wsgi:app这里wsgi:app指你的 WSGI 入口文件是wsgi.py,里面定义了一个名为app的 Flask/FastAPI 实例。跑通以后关掉,接着进入镜像构建环节。
3. 编写 Dockerfile:每个细节都将影响线上稳定性
3.1 基础镜像怎么选
Dockerfile 的第一行是FROM,这里的选择直接决定镜像的体积和兼容性。我见过很多新手一上来用python:latest,这个标签看着省事,但有两个问题:版本不固定,今天构建和三个月后构建可能拿到不同的 Python 版本;而且默认的python镜像是基于 Debian 完整版,体积动辄几百 MB,传到服务器上也要时间。
我的常用选择是固定版本的 slim 镜像,比如:
FROM python:3.11-slimslim版本基于 Debian 精简版,体积小不少,同时又保留了 Debian 的包管理工具,遇到编译依赖可以使用 apt 临时装。为什么不选 alpine?因为 alpine 用的是 musl libc,和常用的 glibc 有差异,一些 Python 依赖本身就是编译好的二进制轮子,在 glibc 环境直接能装,到了 alpine 要从源码编译,经常因为缺头文件报错。能用 alpine 的项目不少,但对新手来说,slim 是少踩坑的选择。
镜像构建过程中如果网络条件不理想,记得给 pip 配置国内镜像源,构建速度和成功率提升不是一点半点。
3.2 依赖安装顺序与构建缓存
Dockerfile 里各行命令的执行顺序直接关系到构建效率和失败排查。掌握一个原则:把不常变的操作放在前面,把经常要改的操作放在最后。
建议写成这样:
COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . .这样设计的原因在于 Docker 构建时会按照指令逐层执行,如果某一层没有变化,就会直接复用缓存。requirements.txt只有在依赖变更时才发生变化,而项目代码几乎每次部署都会变。如果把COPY . .写在pip install前面,那么任何代码改动都会导致依赖层缓存失效,每次都重新安装所有依赖,构建速度会慢很多。
另外我习惯在pip install时加上--no-cache-dir,这个参数能让镜像体积更小,因为 pip 默认会缓存下载的安装包,而这些缓存对运行完全没用。
3.3 非 root 用户、时区与健康检查
默认情况下容器内是用 root 用户运行的,这不符合最小权限原则。一旦容器被攻破,攻击者拿到的就是 root 权限,可以操作宿主机挂载进去的文件。创建专用用户很简单:
RUN useradd --create-home appuser USER appuser注意顺序:必须先复制代码、安装依赖、做完所有需要写文件系统的操作,最后再切换用户,否则后续操作可能会因为权限问题失败。
时区问题,在 slim 镜像里也需要处理:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezoneslim 镜像里默认带不带 tzdata 不一定,如果没有,先apt-get install -y tzdata再执行上面的命令。这个操作会把日志时间、定时任务时间都纠正过来,否则排错时看到的全是 UTC 时间,很难对得上真实问题发生的时间点。
健康检查很容易被忽略,但它在容器编排里非常有用。在 Dockerfile 里可以这样声明基础探活逻辑:
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/healthz')" || exit 1对应的,在应用里加一个/healthz路由,返回 200 即可。这样编排系统能自动感知容器状态,挂了就自动重启。
3.4 一个完整可用的 Dockerfile 模板
把上面这些整合起来,一份适合大多数 Flask/FastAPI 项目的 Dockerfile 大概是这样的:
FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ TZ=Asia/Shanghai WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . RUN useradd --create-home appuser USER appuser EXPOSE 8000 CMD ["gunicorn", "--workers", "4", "--threads", "2", "--timeout", "60", "--bind", "0.0.0.0:8000", "wsgi:app"]这里启动命令里的参数值得说一下。--workers的取值一般参考 CPU 核数,公式是2 * CPU核数 + 1,4 核机器上取 4 个 worker 是合理的起点。--threads是为每个 worker 增加并发线程处理能力,适合 IO 密集型的 Web 应用。--timeout 60指定 worker 在 60 秒内没有响应就会被主进程杀掉重启,防止某个慢请求把整个 worker 卡死。
4. 使用 docker compose 编排服务
4.1 服务拆分的最小合理单元
我见过有人把 Python 应用、Nginx、数据库全部塞进一个容器里,这种方案万万不可取。容器本身的理念是一个容器只跑一个进程,塞多个服务会导致进程管理混乱,而且日志、重启策略都分不清。
一个最小合理的拆分是:
web服务:以 Gunicorn 运行 Python 应用nginx服务:作为反向代理和静态文件服务器- 如果项目需要数据库和缓存,再额外加
db、redis服务
以应用加 Nginx 为例,写一份 compose 文件:
version: "3.8" services: web: build: . restart: always expose: - "8000" environment: - DATABASE_URL=postgresql+psycopg2://user:pass@db:5432/appdb - SECRET_KEY=${SECRET_KEY} volumes: - static_data:/app/static depends_on: - db nginx: image: nginx:1.25-alpine restart: always ports: - "80:80" - "443:443" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - static_data:/app/static:ro depends_on: - web db: image: postgres:15-alpine restart: always environment: - POSTGRES_USER=appuser - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=appdb volumes: - db_data:/var/lib/postgresql/data volumes: db_data: static_data:有几个设置我特别想提醒。
web服务用的是expose而不是ports。这两个词的区别人容易搞混:ports是把端口映射到宿主机,外部可以直接访问;expose只是声明容器对外提供这个端口,只有同一个 Docker 网络里的其他容器能访问。我们的设计里外部流量只应该经过 Nginx,所以web服务不需要暴露到宿主机,只用expose反而更安全。
restart: always是让容器在异常退出、服务器重启之后自动拉起,这个配置对于线上服务几乎是必备的。没有它,服务器一重启你的服务就全挂了,还得手动一个个起。
static_data:/app/static这个挂载点值得单独拿出来说。如果我们的方案决定让 Nginx 直接服务静态文件,那么 Python 容器写出的静态文件需要被 Nginx 容器共享。用同一个命名卷挂载到两个容器,就实现了文件共享。但这里有一个大坑:如果你把static_data卷挂载到/app/static,而镜像构建时/app/static里面本来有文件(比如 Django 的collectstatic产物),那么卷会覆盖镜像里这个目录的内容,容器里看到的可能是个空目录。处理方法是:要么在应用启动时先把静态文件同步到卷里(比如启动命令里先执行 collectstatic),要么不用共享卷,让 Nginx 和后端容器共用宿主机的一个目录(但这样要多一层 bind mount 权限管理)。我常用的是后者:host 目录同时挂进两个容器,简单直接,不用考虑卷初始化的问题:
services: web: volumes: - ./static:/app/static nginx: volumes: - ./static:/usr/share/nginx/static:ro4.2 环境变量管理与配置隔离
compose 文件里的${POSTGRES_PASSWORD}、${SECRET_KEY}这些变量是从哪里来的?答案是同目录下的.env文件。docker compose 启动时会自动读取.env文件,把变量注入到 compose 配置中。这个机制特别方便,因为.env文件不要提交到代码仓库,只保留在服务器上。每次部署时服务器上的.env就是一份独立的配置,不同环境配置不同、互不干扰。
这里强烈建议,项目代码仓库里只放一个.env.example作为模板,里面写上所有需要的环境变量名和示例值。服务器上复制一份改成.env,填入真实配置。这样任何人拿到项目都能快速知道需要配置哪些变量,真实密码又不会泄露。
4.3 首次启动:构建、拉起、看日志
把项目代码传到服务器上,通常我用 git 拉代码,然后进入项目目录执行:
docker compose up -d --build--build参数表示构建镜像后再启动,这样每次部署新代码时这条命令都会重新构建镜像。首次执行会下载基础镜像,这个过程时间取决于网络。
启动完先别急着访问,先看状态:
docker compose ps所有服务状态都应该是running,如果某个服务卡在restarting或者直接exited,那大概率是启动失败。这时看日志:
docker compose logs -f web docker compose logs -f nginx日志输出是定位问题最直接的手段。Python 应用启动时报错、配置文件写错、端口绑定失败,这些都能从日志里找到线索。后面专门有一个章节讲常见问题,这里先不展开。
5. Nginx 反向代理与静态资源的正确配置
5.1 什么请求交给 Nginx,什么交给 Gunicorn
Nginx 的角色可以理解为一个前台接待:收到访问请求以后,先自己判断这个请求是不是静态文件(比如图片、CSS),如果是,直接在 nginx 自己这边处理,根本不用惊动后端的 Python;如果不是,就把请求转交给后台的 Gunicorn 去处理。
这样做的好处前面已经提过:静态文件请求在高并发下很消耗资源,让 Nginx 用 C 的高性能事件模型处理,Python 就能专心干它擅长的事——跑业务逻辑、查数据库、渲染动态页面。如果你的应用主要是 API 服务,那基本没有静态文件存在,Nginx 就纯粹做反向代理和负载均衡。
5.2 配置文件的骨架与关键参数
下面是一份适合大多数场景的 Nginx 配置:容器里的 Nginx 主配置是/etc/nginx/nginx.conf。为了简单,我们直接挂载一份配置到这个路径。我给的配置是已经精简过、能直接用的版本:
worker_processes auto; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; sendfile on; keepalive_timeout 65; upstream app { server web:8000; } server { listen 80; server_name example.com; client_max_body_size 50m; location /static/ { alias /app/static/; access_log off; expires 7d; } location / { proxy_pass http://app; 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; proxy_connect_timeout 60s; proxy_read_timeout 60s; } } }一个容易被忽略的坑藏在upstream app { server web:8000; }这一行。这里的web不是 IP 地址,也不是localhost,而是 docker compose 里定义的 web 服务名。Docker 的容器编排网络会自动做 DNS 解析,把服务名解析成对应的容器 IP。如果你写成server localhost:8000,那 nginx 容器会尝试连接自己容器里的 8000 端口,结果必然是 502。这一点新手踩坑率极高,我在这里特意强调。
proxy_set_header那一组配置也要解释一下。当 Nginx 把请求转发给 Gunicorn 时,Python 应用看到的源 IP、请求协议都来自 Nginx 自己,而不是真实用户。如果你的应用里需要根据用户 IP 做限流、风控、日志统计,就必须依靠这些 header 来还原真实 IP。Host头也很关键,如果你的应用用request.host生成绝对链接,少了这个头生成出来的链接就是错的。
client_max_body_size是另一个经常让人困惑的配置。默认值是 1m,也就是说如果用户上传超过 1MB 的文件,Nginx 会直接返回 413 错误,即使后端的 Gunicorn 完全能处理大文件。需要上传功能的应用,一定记得调大这个值。
5.3 静态文件路径别搞混了
静态文件配置里的alias /app/static/;是指外部 URL 路径/static/对应到容器内的/app/static/目录。这里的路径是 nginx 容器内的路径,不是宿主机路径,也不是 Python 容器路径。如果 nginx 容器里挂载了项目静态目录到/app/static,那这个配置就是对的。
alias和root的区别也很关键。location /static/ { alias /app/static/; }的含义是请求/static/css/style.css时,直接去/app/static/css/style.css找文件;如果用root /app;,那 nginx 会去/app/static/css/style.css,效果是一样的,前提是你知道两者拼接逻辑的差异。用alias更直观,但记得路径末尾的斜杠要一致。
expires 7d是让浏览器对静态文件做 7 天缓存,下次访问直接从本地缓存读取,不再请求服务器。这是提高页面加载速度非常有效的低成本优化。
5.4 上线后验证配置是否生效
配置完成后重启 nginx 容器:
docker compose restart nginx然后测试几个点:
curl -I http://服务器IP/ curl -I http://服务器IP/static/css/style.css curl -I http://服务器IP/healthz第一个应该返回 200,第二个应该直接由 Nginx 返回 200 且响应头里有缓存相关的头信息,第三个应该转发到 Gunicorn 并由应用返回 200。如果这三个都是预期结果,恭喜你,整个链路通了。
到这里还没有完,域名解析和防火墙检查也不能省。域名方面,在 DNS 服务商那里把记录指向服务器 IP;防火墙方面,云服务商的安全组和服务器自身的 ufw 都需要放行 80 端口和 443 端口,否则外部访问不到 Nginx。
后面如果想上 HTTPS,在 Nginx 里加一个端口为 443 的 server 块,用证书工具申请免费证书并配置自动续期。这一步不是必须的,但现在很多平台的接口要求 HTTPS,有条件还是尽早配好。
6. 常见故障排查实录与速查表
6.1 502 Bad Gateway:后端没起来或者连错地址
502 这个状态码解释起来很简单:Nginx 作为代理,发现自己连接上游后端失败。你的第一反应应该是起一个终端,查看 web 容器的状态和日志:
docker compose ps docker compose logs web如果看到 Gunicorn 根本没有启动,打开日志多半会有错误提示,比如 Python 模块导入失败、数据库连不上导致启动时崩溃。我在一个实际项目里遇到过一次 502,日志显示address already in use,原因是本地起了两个 web 容器抢占同一个端口。这种问题,把多余的容器停掉就好了。
如果 web 容器看起来一切正常,但 nginx 还是 502,那几乎可以断定是 Nginx 配置里上游地址写错了。检查一下upstream块里的server web:8000;是否写成了localhost,或者端口是否和 Gunicorn 的--bind参数一致。端口不一致是新手最容易遇到的问题,Gunicorn 监听 8000,nginx 转发到 5000,那不通就是理所当然的。
6.2 504 Gateway Timeout:请求太慢被掐断了
504 表示 Nginx 成功连上了后端,但后端在规定时间内没有返回响应。两种最典型的原因:一是某个接口本身执行时间就长,比如导出报表、批量计算,超过默认的 60 秒超时;二是后端进程已经卡死,没有及时处理请求。
排查方法:先看是只有慢接口触发 504,还是所有请求都 504。如果只是个别慢接口,那就把 Nginx 的proxy_read_timeout调大,同时把 Gunicorn 的--timeout调大,两个时间参数要协调。注意 Gunicorn 的 timeout 如果小于接口实际耗时,worker 会被主进程杀死重启,表现为接口偶尔 500 或连接中断。
如果是所有请求都很慢甚至卡死,那大概率是后端进程资源耗尽,用docker stats看容器 CPU、内存占用,同时检查数据库连接数是不是被打满了。
6.3 静态文件 404:路径拼接与挂载目录不一致
静态文件 404 是让我当初最抓狂的问题,因为前端页面能打开、接口都正常,就是图片和样式全部挂掉。排查思路其实很直接:既然 Nginx 返回 404,那就是 Nginx 在容器内找不到文件。
在 nginx 容器里直接看目录内容:
docker compose exec nginx ls -la /app/static如果目录为空,说明挂载没有生效或者挂到了错误的位置。这时回到 compose 文件里检查挂载路径,注意挂载的是宿主机路径还是卷,以及目标路径是否和 Nginx 配置里的alias路径完全对应。还有那个卷覆盖镜像目录的坑:如果镜像构建时/app/static里本来有文件,但启动时被一个空的命名卷挂上了,你看到的就是空目录。查一圈下来,90% 的静态文件 404 都是路径不匹配导致的。
6.4 容器启动即退出:靠日志定位最快
容器一直处于重启循环,docker compose ps里显示restarting,这种问题如果光盯着 compose 看是看不出原因的。直接看日志:
docker compose logs web --tail 50日志里如果是ModuleNotFoundError,那就是 requirements.txt 缺依赖;如果是PermissionError,那就是权限问题,检查是否在 Dockerfile 里切换用户后还要对某些目录写权限;如果是启动命令不存在,检查 CMD 里的可执行文件是否安装了。
一个非常隐蔽的坑是我当时在CMD里用了 shell 变量但跑的是 exec 格式。写CMD ["gunicorn", "--bind", "0.0.0.0:8000", "wsgi:app"]和写CMD gunicorn --bind 0.0.0.0:8000 wsgi:app在命令解析上不一样,前者不会经过 shell,环境变量之类的不会自动展开。如果启动命令里需要用到环境变量的值,是 shell 格式更方便扩展。但 shell 格式会额外启动一个 shell 进程,容器里 PID 1 就不是 gunicorn 了,信号处理会受到影响。生产环境建议用 exec 格式。
6.5 常见问题速查表
| 现象 | 可能原因 | 排查命令 | 解决思路 |
|---|---|---|---|
| 502 Bad Gateway | 后端未启动、端口不一致、upstream配错 | docker compose logs web | 确认Gunicorn监听端口与nginx上游一致 |
| 504 Gateway Timeout | 后端处理超时、配置时间太短 | 查看nginx error.log | 调大proxy_read_timeout与gunicorn timeout |
| 静态文件404 | alias路径错误、卷覆盖了已有文件 | docker compose exec nginx ls | 核对挂载路径与alias路径 |
| 容器重启循环 | 启动命令崩溃、依赖缺失 | docker compose logs --tail 50 | 根据日志修复启动参数或依赖 |
| 端口被占用 | 多个容器或进程占用了同一个端口 | sudo ss -ltnp | 停掉冲突进程或修改映射端口 |
| 日志时间差8小时 | 容器时区未设置 | date查看容器内时间 | Dockerfile中设置TZ并安装tzdata |
这个表格里列的每一个问题,基本都是我实际操作中踩过或者帮别人排查过的。排查经验就一句话:不要瞎猜,先看日志,日志会告诉你 90% 的真相。
7. 部署还不是终点:后续还有这些小事情要处理
7.1 镜像体积优化:省下的不只是磁盘
部署完成以后,如果你回头看镜像列表,可能会发现一个项目镜像占了五六百 MB。这里面有很多是无用的内容,比如 apt 缓存、pip 缓存、构建工具,甚至还有测试代码。镜像体积大带来的问题不只是占磁盘,还有每次构建后推送到私有仓库和从仓库拉取的时间成本。个人服务器可能感受不明显,但项目一多、镜像一大,磁盘和带宽的压力会同时出现。
一个立竿见影的做法是用多阶段构建。比如有些 Python 依赖需要编译,你可以用一个带编译工具的基础镜像安装依赖,然后把装好的依赖复制到一个只有运行时的精简镜像里。这样可以丢掉大部分编译工具链。还有一个轻量级的做法:在apt-get install后清理缓存,加一行rm -rf /var/lib/apt/lists/*;在 pip 安装时加--no-cache-dir。这些前面已经提到,这里再强调一遍,积累到位以后镜像体积能缩小一半以上。
7.2 日志处理:不要让它无限增长
容器如果不配置日志轮转,Docker 默认的 json-file 驱动会把所有标准输出落成 JSON 日志文件,时间长了磁盘会被打满。尤其是一些打印频率高的应用,可能一两天就产生几个 GB 日志。
推荐用 Docker 内置的日志轮转:
services: web: logging: driver: json-file options: max-size: "10m" max-file: "3"这样每个容器的日志文件到了 10MB 就会自动切割,最多保留 3 个文件。Nginx 容器也要做同样的配置,因为 Nginx 访问日志在高流量下增长非常快。这是很多线上事故的隐患,早点配置早安心。
7.3 备份与回滚:给自己留一条后路
数据库备份是另一个经常被忽略的点。如果你用 bind mount 把 PostgreSQL 数据目录挂到宿主机,备份可以直接备份那个目录。更稳妥的方式是使用数据库自己的逻辑备份工具,定期生成 SQL 文件,然后存到服务器外部存储。
回滚方面,Docker 给了很好的天然优势:每次部署前,给当前镜像打一个 tag,部署新代码出问题时,只需要把 compose 文件里的镜像 tag 指回上一个版本,重启容器即可,整个回滚不超过一分钟。这个习惯在个人项目里可能不太起眼,但在项目服务越来越多的阶段,能够省去很多救火的时间。
写在最后:一次部署完成后的体会
整套流程走到这里,你已经拥有了一套能自动拉起的 Web 服务:Nginx 挡在前面做代理,Gunicorn 在后面跑应用,容器自动重启,日志有轮转,回滚有备份。我第一次完整跑通这套方案的时候,最大的感受是“终于不用再折腾服务器环境了”。之后新项目上线,只需要把同样的 compose 模式复制一份,改一改环境变量和域名,基本十分钟就能让项目在线运行。
我个人最想分享的技巧,不是某个具体配置,而是部署这件事的思路:把每次从零开始的部署流程固定成模板,把踩过的坑记成清单,下次出问题直接在清单里找答案。服务器环境这个东西,越折腾越容易出岔子,越稳定越省心。Docker 和 Nginx 的组合,本质上就是在帮你把不稳定因子一个个隔离掉、固定住。如果你现在还在手动往服务器上拷贝代码、手动装依赖、手动起进程,我建议花一个周末把这套方案搭起来,之后每一次上线都会轻松很多。