☰
Docker+Nginx+Gunicorn部署Python Web应用:从零到生产环境完整指南
2026/10/15 20:05:23 网站建设 项目流程

把 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-slim

slim版本基于 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/timezone

slim 镜像里默认带不带 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:ro

4.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
静态文件404alias路径错误、卷覆盖了已有文件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 的组合,本质上就是在帮你把不稳定因子一个个隔离掉、固定住。如果你现在还在手动往服务器上拷贝代码、手动装依赖、手动起进程,我建议花一个周末把这套方案搭起来,之后每一次上线都会轻松很多。

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

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

立即咨询