
把一个Python写的Web项目从本地跑到服务器上表面上看起来就是“代码传上去、跑起来”这么简单。但真正操作过的人都知道从本地开发环境到一台干净的服务器中间隔着Python版本、依赖包、系统库、端口占用、静态文件、并发模型一堆坑。这篇内容就是我基于一个完整的Python Web项目用Docker加Nginx这套组合做服务器部署的全过程记录。你会看到我为什么选Docker而不是直接在服务器上装环境也会看到Nginx在整个链路里到底扮演什么角色每一步命令、每一段配置文件都有实操版本可以直接抄走。如果你正准备把自己的Flask或FastAPI项目部署上云服务器或者单纯想把本地的Python服务“搬到公网上”这篇内容会很有参考价值。1. 部署方案设计与选型思路1.1 为什么是 Docker Nginx而不是传统裸机部署很多人在服务器上部署Python项目的第一反应是装Python、装pip、装虚拟环境、装gunicorn、用systemd拉起来。这套流程前几年很主流现在依然能用但痛点非常明显。第一是环境隔离问题。一台服务器上可能同时存在多个项目一个项目依赖Python 3.9另一个依赖3.11还有系统自带的包管理器会用掉一部分系统级库。用Docker之后每个项目把自己需要的Python版本、系统依赖、运行时环境全部固化在镜像里互不干扰。一个镜像就是一个完整的、可复制的运行环境。第二是部署一致性。你知道最让人头疼的是什么吗“在我电脑上明明能跑”这句话。开发环境、测试环境、生产环境之间的差异往往就是线上问题的主要来源。Docker镜像保证了代码从一个环境到另一个环境行为一致。我本地构建的镜像是什么样服务器上跑的就是什么样中间不存在“换个机器依赖装不上”的意外。第三是回滚容易。传统裸机部署如果想回退版本要把旧代码或者旧依赖重新装一遍费劲。但用Docker镜像只要之前构建的镜像还在一条命令就能把整个服务拉回旧版本几乎无痛回滚。这个特性在大版本升级翻车的时候能救命。至于Nginx它在架构里承担的是“入口层”的角色。Python应用本身有gunicorn就能提供HTTP服务但直接暴露应用服务端口到公网既浪费性能也不安全。Nginx可以做反向代理、静态文件服务、SSL终止、请求压缩、限流这些能力让Python应用只需要专注业务逻辑剩下的交给Nginx处理。1.2 完整的请求链路长什么样我部署的这个项目是一个典型的Flask应用有API接口也有静态资源后端用PostgreSQL存数据。部署完成后的请求链路是这样的用户浏览器访问域名DNS解析到服务器IP服务器上Nginx监听80/443端口接收请求后匹配server_name和location规则。如果是静态资源请求Nginx直接从磁盘返回文件如果是动态API请求Nginx把请求转发给容器内的gunicorn监听127.0.0.1:8000gunicorn再把请求交给Flask应用处理。应用读写PostgreSQL返回JSON或渲染页面响应再原路返回给用户。这套链路的好处是每一层职责单一。Nginx管网络接入gunicorn管Python进程执行Flask管业务逻辑PostgreSQL管数据存储。后续想加缓存、加队列、加第二个应用都不会打乱这个基本结构。1.3 不同部署方式的核心差异我整理了一张对比表这张表基本上概括了三种常见方案的取舍部署方式环境一致性回滚速度运维成本适航场景传统裸机部署差靠手工保证慢需要重装依赖高每台机器单独维护单机单项目、无容器化条件Docker 单容器部署好镜像级一致快切镜像即可中需管理容器和网络的细节中小项目、单人维护Docker Compose 多服务部署好服务间协作方便快版本一致中适合微服务化有数据库/缓存/多服务的项目Kubernetes最好大规模统一编排最快滚动更新极高需要额外集群维护成本多副本、高可用、大型团队我最终选的是Docker Compose方案原因很简单这个项目需要Web服务、数据库服务、Nginx服务三个容器协同工作Compose可以用一个yaml文件把三者串起来一条命令搞定启动和停止完全不用写复杂的编排脚本。2. 部署前准备环境、目录结构与配置规划2.1 服务器环境准备服务器我用的是一台2核4G内存的Ubuntu 22.04云主机这个配置对于一个小型Python Web项目来说完全够用。系统选择推荐的长期支持版本LTS稳定、软件源齐全、社区资料多。登录服务器后第一件事是更新系统包、安装基础工具然后安装Docker和Compose插件。Ubuntu上安装的方式最省心的是用Docker官方提供的apt源这样可以保证装到的是最新稳定版本sudo apt update sudo apt install apt-transport-https ca-certificates curl software-properties-common curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg echo deb [archamd64 signed-by/usr/share/keyrings/docker-archive-keyring.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 docker-ce docker-ce-cli containerd.io docker-compose-plugin安装完成后可以用两个命令验证docker --version和docker compose version。这里有个小坑如果你懒得上sudo前缀可以将当前用户加入docker组sudo usermod -aG docker $USER newgrp docker加了之后再执行docker命令就不需要反复敲sudo了。这个操作无关紧要但能省掉很多日常使用中的麻烦。另外服务器的防火墙或者云安全组一定要放行80和443端口否则Nginx配得再对外部也访问不到。2.2 项目目录结构规划目录结构是整个部署过程中最容易忽视却影响很大的部分。一个清晰的目录能让你在三个月后回来看时依然秒懂项目布局。我推荐按职责拆分project/ ├── app/ # Python应用代码 │ ├── __init__.py │ ├── main.py # Flask应用入口 │ ├── models.py │ ├── views.py │ └── utils/ ├── migrations/ # 数据库迁移文件 ├── static/ # 静态资源CSS/JS/图片 ├── templates/ # Jinja2模板 ├── docker/ │ ├── Dockerfile │ ├── gunicorn.conf.py │ └── nginx.conf ├── docker-compose.yml ├── requirements.txt ├── .env.prod # 生产环境变量 └── .env.local # 本地开发环境变量注意docker相关配置我单独放在docker目录下和项目代码分开。这样应用代码目录保持纯粹部署相关的东西集中在一起。环境变量文件不提交到git仓库生产环境的值由运维人员手动维护避免密钥泄漏。2.3 依赖文件与环境变量requirements.txt的生成不能直接pip freeze requirements.txt因为这样会把本地环境里所有包全部导出来包括很多和项目无关的包。正确姿势是只在项目虚拟环境里安装核心依赖后用pip freeze生成或者手动梳理成一份清晰的清单。我实际项目的依赖大概是flask3.0.0 gunicorn21.2.0 psycopg2-binary2.9.9 python-dotenv1.0.0 flask-sqlalchemy3.1.1 flask-migrate4.0.5生产环境变量方面我单独维护一个.env.prod文件里面的内容包括数据库密码、SECRET_KEY、调试开关等。这些变量不允许写死在代码里而是通过docker compose注入到容器环境中。代码里获取环境变量的方式就一句import os DATABASE_URL os.environ.get(DATABASE_URL, postgresql://dev:devlocalhost/dev)这句的隐含意思是如果环境变量没设置就用本地默认值如果设置了就使用生产环境的值。这样同一套代码在开发和线上都能跑只是读取的配置不同。3. Docker 化应用从 Dockerfile 到 Gunicorn3.1 Dockerfile 的关键细节Dockerfile是构建应用镜像的说明书。我用的是一个比较成熟且经过多次验证的写法FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE1 \ PYTHONUNBUFFERED1 \ TZAsia/Shanghai RUN apt update apt install -y --no-install-recommends \ gcc \ libpq-dev \ curl \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt \ pip install --no-cache-dir gunicorn COPY . . RUN useradd -m appuser chown -R appuser:appuser /app USER appuser EXPOSE 8000 CMD [gunicorn, -c, docker/gunicorn.conf.py, app:app]这个Dockerfile里有几个细节值得说道说道。基础镜像选择python:3.11-slim而不是python:3.11或python:3.11-alpine因为完整版镜像体积太大动辄几个G而slim版在体积和兼容性之间平衡得很好。alpine体积更小但很多Python包需要编译折腾起来费时费力不推荐新手使用。PYTHONDONTWRITEBYTECODE和PYTHONUNBUFFERED这两个环境变量很有用前者防止Python生成.pyc缓存文件减少镜像脏数据后者让Python日志不缓冲直接输出到标准输出保证docker logs能及时看到日志。依赖安装放在代码复制之前这个顺序对镜像构建缓存很关键。Docker构建时每一层都会尝试复用缓存如果requirements.txt没变那就不会重新安装依赖如果代码变了只需要重新复制项目文件那层构建速度快非常多。最后用USER appuser切换到非root用户运行。容器里用root跑应用万一应用被入侵攻击者直接就是root权限风险太高。创建一个低权限用户是基本的安全素养。3.2 Gunicorn 配置与 worker 数量计算Gunicorn是Python Web应用的进程管理器负责启动多个worker进程来并发处理请求。Flask自带的开发服务器并不面向生产环境性能差且不稳定所以生产部署必须用gunicorn这类WSGI服务器。我的gunicorn配置单独放在一个文件里方便维护import multiprocessing import os bind 0.0.0.0:8000 workers multiprocessing.cpu_count() * 2 1 worker_class sync timeout 30 graceful_timeout 30 loglevel info accesslog - errorlog -worker数量的经验公式是2 * CPU核数 1在2核服务器上就是5个worker。这个公式不是谁发明的而是一个业界广泛认可的经验值能在吞吐量和资源占用之间取得较好平衡。worker太多内存占用大进程切换频繁worker太少并发能力不够请求排队。worker_class这里用sync即可对于绝大多数I/O密集型并非极端的Web应用来说足够。如果项目里大量使用长轮询或WebSocket可以考虑gevent或者uvicorn针对ASGI应用。但代价是引入更多概念和依赖没有明确需求前不要盲目上。loglevel设置为info访问日志和错误日志都输出到标准输出。这一点在Docker环境里很重要因为Docker的docker logs命令会捕获容器内进程的stdout和stderr只要日志输出到了标准输出就能用docker compose logs -f实时查看不需要额外配置日志文件路径。3.3 健康检查与优雅退出Dockerfile里我虽然写了CMD启动gunicorn但没提健康检查。实际上一个生产可用的容器最好配置HEALTHCHECKHEALTHCHECK --interval30s --timeout5s --retries3 \ CMD curl -f http://localhost:8000/health || exit 1对应的应用端至少要有一个/health接口返回{status: ok}这样的简单JSON。这个接口的存在价值在于编排工具和负载均衡器可以靠它判断容器是否存活。没有健康检查容器吞了所有请求但内部应用已经僵死docker还会认为服务是好的问题排查会非常痛苦。优雅退出方面docker stop命令默认会给容器内主进程发送SIGTERM信号等待10秒后如果还没退出就发送SIGKILL强杀。gunicorn收到SIGTERM后会停止接收新请求等待已接收的请求处理完成再退出graceful_timeout设置了30秒给足了缓冲时间。这套机制让服务更新时的请求中断率降到最低。4. 用 docker compose 编排完整服务栈4.1 docker-compose.yml 服务划分与网络模型Compose文件是整套部署的“总开关”。我写的compose文件包含了三个服务web应用、PostgreSQL数据库、Nginx代理。services: web: build: context: . dockerfile: docker/Dockerfile restart: unless-stopped env_file: - .env.prod volumes: - static_data:/app/staticfiles networks: - app_network depends_on: db: condition: service_healthy expose: - 8000 db: image: postgres:15-alpine restart: unless-stopped environment: - POSTGRES_DB${POSTGRES_DB} - POSTGRES_USER${POSTGRES_USER} - POSTGRES_PASSWORD${POSTGRES_PASSWORD} volumes: - db_data:/var/lib/postgresql/data networks: - app_network healthcheck: test: [CMD-SHELL, pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}] interval: 10s timeout: 5s retries: 5 nginx: image: nginx:1.25-alpine restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro - static_data:/app/staticfiles:ro - ./certbot/conf:/etc/letsencrypt:ro networks: - app_network depends_on: - web networks: app_network: driver: bridge volumes: db_data: static_data:这里几个关键设计可以单独说明。关于端口暴露web容器用的是expose: 8000而不是ports: 8000:8000。区别在于expose只让这个服务能被Compose网络内的其他服务访问不会暴露到宿主机和公网。应用端口应该只在内部网络中被Nginx访问直接从外部访问应用端口是安全风险。Nginx容器用ports把80和443暴露出去因为它是入口。关于depends_onweb容器依赖db服务健康之后才开始启动。condition: service_healthy表示compose会等db的healthcheck通过后再启动web。避免一种很常见的局面web启动时数据库还没就绪导致应用反复尝试连接数据库而崩溃重启。关于restart: unless-stopped容器因为异常退出或服务器重启后会自动拉起。这个策略很适合生产环境省去了手动重启的麻烦。4.2 数据持久化与静态文件共享数据持久化通过命名卷named volume实现。db_data卷挂载到容器内PostgreSQL的数据目录这样数据库文件存在于宿主机的一个特殊目录中即使容器删除重建数据也不会丢失。这是生产环境绝对不能省略的配置丢了就是事故。静态文件共享用的是同一个命名卷挂载不同容器的不同路径。web容器把生成/收集的静态文件写入卷中Nginx容器把同一个卷挂载到自己的/app/staticfiles目录直接提供HTTP服务。这样做的好处是用户请求静态资源时根本不会进入Python应用Nginx直接从本地磁盘返回文件速度非常快。这里有个常见坑如果应用在运行过程中生成了新的静态文件例如用户上传的图片那么需要用一个可写的共享卷两边容器都挂载同一个卷并且权限要处理好。否则Nginx容器读取不到新文件或者因为权限问题返回403。4.3 首次启动与后续更新部署首次启动的完整流程很简单cd project/ docker compose up -d docker compose ps docker compose logs -fup -d会在后台启动所有服务。首次构建过程中Docker会逐层构建镜像这个过程取决于网络状况和项目大小。构建完成后容器进入运行状态。这时候打开docker compose logs -f能看到gunicorn启动日志和Nginx访问日志确认一切正常。日常更新部署的套路是git pull origin main docker compose up -d --build--build参数会让compose检查镜像是否需要重建。如果代码变了就重新构建应用镜像如果代码没变就直接复用已有镜像秒级完成“部署”。回滚操作的逻辑也简单。只需要找到上一次正常运行的旧镜像ID或tag修改compose文件指定该镜像并重新up即可。5. Nginx 反向代理与静态资源处理5.1 Nginx 配置文件核心结构Nginx的配置文件是整个部署的门面。我把它放在docker目录下挂载进容器时会复制到/etc/nginx/conf.d/default.conf。完整配置如下upstream web_backend { server web:8000; keepalive 32; } server { listen 80; server_name example.com; gzip on; gzip_types text/plain text/css application/json application/javascript; gzip_min_length 1024; location /static/ { alias /app/staticfiles/; expires 30d; add_header Cache-Control public, immutable; } location /media/ { alias /app/mediafiles/; expires 30d; add_header Cache-Control public; } location / { include proxy_params; proxy_pass http://web_backend; proxy_http_version 1.1; proxy_set_header Connection ; } }5.2 反向代理的关键请求头上面配置中我提到一个include proxy_params这个文件存放着反向代理的标准配置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;这几个头信息缺一不可。特别是X-Forwarded-Proto如果你的应用需要生成绝对URL比如用户上传图片后返回完整链接没有这个头应用就不知道原始请求是HTTP还是HTTPS生成的链接就可能是错的。proxy_http_version 1.1和proxy_set_header Connection 是为了支持HTTP长连接。默认情况下Nginx向后端转发请求时使用HTTP/1.0且每请求都新开连接。改成HTTP/1.1并且清空Connection头可以让gunicorn复用已有的TCP连接性能有明显提升。upstream块里配置的server web:8000这里的web是Compose网络内web服务的名称Docker DNS会自动解析到对应容器的IP不需要写死IP地址。这样只要web容器被Compose重建IP变了也没关系Nginx始终能找到它。5.3 静态文件缓存与压缩静态资源的性能优化是Nginx承担的最重要工作之一。对于CSS、JS、图片这类不常变化的资源我设置了30天的过期时间expires 30d; add_header Cache-Control public, immutable;immutable告诉浏览器这个资源在过期前不会变化可以直接从本地缓存读取连校验都不需要发。发布新版时如果静态文件内容变了文件名应该按照包含内容哈希的方式命名例如app.8f0c3a.css这样文件名变了浏览器自然就会请求新文件。gzip压缩能显著减少传输体积特别是对于文本类资源。开启gzip后JSON API响应可以从几百KB压缩到几十KB体验提升明显。需要注意的是gzip_min_length 1024避免压缩那些本来就很小、压缩收益低的内容。6. 常见部署问题与排查实录6.1 502 Bad Gateway 的三种最常见原因502是我部署过程里遇到最多的错误没有之一。这个错误的本质是Nginx无法连接到后端应用。但具体原因可能有好几种第一种gunicorn容器启动失败。可能是Python代码有语法错误也可能是依赖没装全。排查方式docker compose logs web看应用启动日志。如果看到ModuleNotFoundError基本就是依赖问题。第二种容器启动了但监听端口不对。我曾踩过把gunicorn的bind配置成127.0.0.1:8000的坑。这在裸机部署时没问题但在容器里就不对了因为Nginx和web不是一个容器Nginx访问不到web容器内的lo地址。必须绑定0.0.0.0:8000否则Nginx连接的时候只会收到拒绝连接。第三种web容器虽然活着但应用僵死。这就是前面健康检查发挥作用的地方。如果健康检查失败compose会不停重启容器Nginx在容器重启的间隙就会返回502。6.2 容器内应用无法连接数据库这个问题的典型报错是connection refused或could not translate host name db to address。先说could not translate host name。这说明应用在尝试连接一个叫db的主机名但DNS解析失败。原因通常是web容器和db容器不在同一个Docker网络。比如你手动启动了web容器但没加入Compose创建的app_network。解决方案是在compose文件里明确把两个服务放到同一个networks里。再说connection refused。很多时候是db容器起来了但Web容器启动得太快数据库还没初始化完毕。这就是我在compose配置里加depends_on: condition: service_healthy的原因。另外如果你从宿主机直接用localhost:5432连接数据库也会被拒绝因为PostgreSQL在容器内只监听了容器自己的网络接口。6.3 静态文件404 与路径问题静态文件404是Nginx配置最容易出问题的地方。典型场景是请求/static/css/style.css返回404。问题通常出在alias和root的混淆上。下面这段配置是错误的location /static/ { root /app/staticfiles/; }root会把实际路径拼成/app/staticfiles/static/css/style.css显然路径里多了个static结果就是404。而alias是直接替换前缀/static/对应/app/staticfiles/才能匹配到正确的文件路径。还有一个容易忽略的点权限。Nginx容器用ro方式挂载静态目录如果宿主机上这个目录的权限不对Nginx会返回403而不是404。排查时可以用docker compose exec nginx ls /app/staticfiles/确认容器内能看到哪些文件。6.4 一个排查思路速查表整理一个诊断用的速查表当你遇到问题时从上到下依次排查现象可能原因排查命令502 Bad Gatewaygunicorn启动失败、端口绑定错误、容器重启docker compose logs web503 Service UnavailableNginx找不到upstream主机docker compose ps静态资源404alias/root写错、资源路径不对docker compose exec nginx ls /app/staticfiles/静态资源403挂载卷权限不足docker compose exec nginx ls -l /app/staticfiles/数据库拒绝连接网络不互通、数据库未就绪docker compose exec web ping db请求响应超慢gunicorn worker数太少、数据库查询慢docker compose logs web7. 一些值得长期坚持的部署经验写到这里整个部署过程基本完整了。最后分享几个在多次部署中沉淀下来的体会。日志绝对不要写到容器内的文件里。Docker容器的文件系统是临时性的容器删除后日志就丢了。正确做法是让应用直接往stdout输出靠docker compose logs集中查看。如果需要长期归档可以让Docker的log driver把日志转发到宿主机或日志系统。我见过很多人在容器内配置log目录结果排查问题时还得进容器翻文件多此一举。镜像体积值得花时间优化。用slim基础镜像、清理apt缓存、合理利用构建缓存这些操作能把镜像从2G压到500M以内。体积小构建快传输快启动快磁盘占用小没有一个坏处。数据库备份要提前设好。我可以负责任地说第一次做备份的时机往往是在数据丢失之后。可以用一条cron任务定期执行docker compose exec -T db pg_dump -U user dbname backup.sql把备份文件同步到对象存储或者另一台机器。这个习惯希望你养成得比我早。部署这件事本身并不难难的是每个环节都知道为什么这么配、出了问题往哪里查。希望这篇内容能帮你把整个链路梳理清楚遇到问题时有排查的思路。如果最后再让我说一条最想强调的那就是先把本地到测试环境的链路走通再考虑生产环境。环境越简单问题越少一次只改一个变量永远好过同时动三样东西然后不知道哪里出错。