☰
Python Web 部署实战:Docker+Nginx 从服务器到 HTTPS 上线
2026/10/21 13:17:49 网站建设 项目流程

本地跑得好好的 Python 应用,一上服务器就各种翻车,这事我见了太多次。依赖装不全、系统 Python 版本跟开发环境对不上、进程没人守护挂了也不知道、端口一冲突直接懵圈。这套问题其实早在 2013 年我刚开始部署 Flask 应用的时候就踩过一轮,折腾到最后,我沉淀出一套相对省心的标准方案:Docker + Nginx。这篇文章就围绕这个组合,把从服务器准备到 HTTPS 上线的完整流程和坑点全部过一遍,覆盖 Flask 和 FastAPI 两类常见框架,适合那些已经能本地跑通 Python Web 应用、但还没正经部署过线上服务的开发者参考。

1. 部署方案的整体思路与选型

1.1 为什么是 Docker + Nginx 这个组合

先说结论:Docker 解决“环境不一致”和“进程守护”的问题,Nginx 解决“对外入口”和“流量分发”的问题,两者配合正好把部署里最痛的两块补上。

我见过不少新手直接把python manage.py runserver或者uvicorn main:app挂到服务器上,再用nohup放后台,完事。这种玩法在开发环境没问题,生产环境就是定时炸弹。runserver是 Django 自带的开发服务器,性能差不说,还没有并发处理能力;nohup启动的进程一旦因为内存崩溃退出,没有任何机制把它拉起来。Docker 的restart: unless-stopped策略,加上容器的隔离能力,能把这些基础问题一次性解决。

Nginx 的角色很多人也不理解。Python Web 框架本身能监听端口、能返回 HTTP 响应,为什么前面还要再挡一层 Nginx?原因有三个。

第一,静态文件处理。Nginx 处理图片、CSS、JS 这类静态资源的效率远超 Python 进程,它能把请求直接拦截下来返回文件,不消耗 Python 的计算资源。第二,TLS 终结。HTTPS 证书的安装、续期、加解密都交给 Nginx,Python 应用内部继续用明文 HTTP 通信,降低了应用层的复杂度。第三,进程管理与反向代理。Nginx 可以把请求转发给本机任何一个端口上的应用实例,将来就算扩展成多个实例做负载均衡,也只需要改几行配置,应用本身不用动。

1.2 整体架构与数据流向

这套架构的数据流向很清晰:用户请求先到服务器的 80/443 端口,Nginx 在这里接收并解析请求。如果是静态资源,Nginx 直接返回文件;如果是动态请求,Nginx 通过proxy_pass把请求转发给 Docker 容器内监听某个内部端口的 Python 应用进程,拿到响应后再返回给用户。

Python 应用进程本身不能裸奔,我习惯在容器里再套一层 Gunicorn(Flask/Django 项目)或者 Uvicorn(FastAPI 项目)。Gunicorn 是 Python 生态里最成熟的 WSGI 服务器,负责管理多个 worker 进程、处理并发;容器里的启动命令就从python app.py变成gunicorn -w 4 -b 0.0.0.0:8000 app:app。

这里要强调一个新手最容易理解错的点:Docker 里的端口映射不要直接暴露到公网。比如应用容器内部监听 8000,写在 compose 文件里是"8000:8000",这意味着服务器公网 IP 的 8000 端口直接对外可达,绕过 Nginx 直达应用。正确的做法是只做容器间通信,应用容器的端口只对 Nginx 可见,Nginx 才是唯一的公网入口。后面我会详细演示这个配置。

2. 服务器环境准备与 Docker 安装

2.1 服务器基础配置

我个人的习惯是选择 Ubuntu 22.04 LTS 作为服务器系统,原因是 Python 和 Docker 生态对它的兼容性最好,遇到问题搜到的解决方案也最多。服务器规格方面,一个日活几百人的小型应用,2 核 4G 内存的配置就非常充裕了;如果只是个人项目或者演示项目,1 核 2G 也能跑,后面我会提到在这种情况下怎么避免内存不足。

拿到服务器后第一件事不是装环境,而是做基础安全配置。用 root 登录后,先创建一个普通用户并赋予 sudo 权限:

adduser deploy usermod -aG sudo deploy

然后配置 SSH 密钥登录。在本机生成密钥对,把公钥放到服务器的~/.ssh/authorized_keys里,之后所有部署操作都通过这个普通用户完成。root 远程登录一定要禁用,具体做法是编辑/etc/ssh/sshd_config,把PermitRootLogin改成no,然后重启 sshd 服务。这一步不是可有可无的仪式,公网服务器每天会被大量扫描尝试登录,关掉 root 能挡掉绝大多数暴力破解。

系统基础更新也别跳过:

sudo apt update && sudo apt upgrade -y

顺手再把时区设成 Asia/Shanghai。容器里默认是 UTC 时间,日志里看到的时间和本地时间对不上是常事,服务器层面先统一时区能省掉后面很多困惑。

2.2 安装 Docker 与 Compose 插件

Docker 的安装我用的是官方 apt 源,不用系统自带的旧版本。步骤很简单,几条命令的事:

sudo apt install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg

接着把 Docker 官方源写进 apt 配置,然后安装docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin。这里重点说docker-compose-plugin,它提供的是docker compose子命令,跟老的docker-compose独立程序是两回事。新项目一律用docker compose,语法更简洁,跟 Docker CLI 的集成也更好。

安装完成后,把deploy用户加入docker组,这样才能免 sudo 执行 docker 命令:

sudo usermod -aG docker deploy

提示:加入 docker 组后必须重新登录会话才生效。另外从安全角度讲,docker 组的用户权限很大,等价于 root 权限,所以只在可信的部署账号上这么做。

验证安装是否成功,运行docker --version和docker compose version,能打印出版本号就说明环境就绪了。

2.3 防火墙与安全设置

服务器防火墙用 UFW 管理,规则控制在三条以内:

sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable

别把 8000、5000 这些应用端口加进去,理由前面说过:应用端口不需要暴露到公网,唯一入口就是 80 和 443。这里有个细节,ufw allow OpenSSH是放行 sshd 实际监听的端口,如果你的 SSH 改了非默认端口,要写对应的端口号而不是这个服务名。

做完这些基础工作,服务器的状态是:可以 SSH 登录、80/443 端口开放、Docker 就绪。到这里环境准备就结束了,下面进入正题,把应用装进容器。

3. 把 Python 应用装进容器

3.1 写一份靠谱的 Dockerfile

Dockerfile 是整条部署链路的地基。我在多个项目里反复打磨过这份文件,下面以 FastAPI + Uvicorn 为例给出一个可以直接改用的模板:

FROM python:3.12-slim AS builder WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --user --no-cache-dir -r requirements.txt FROM python:3.12-slim RUN groupadd -r app && useradd -r -g app app WORKDIR /app ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 COPY --from=builder /root/.local /root/.local COPY --chown=app:app . . ENV PATH=/root/.local/bin:$PATH USER app EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

这份 Dockerfile 看起来简单,但每一行都有讲究。

python:3.12-slim基础镜像基于 Debian slim 版本,体积小,日常够用。我不用alpine版本,虽然它更小,但很多 Python 包需要重新编译,musl环境的兼容性问题能让人调试到怀疑人生,生产环境省那几十 MB 磁盘不值得。

PYTHONDONTWRITEBYTECODE=1防止 Python 生成.pyc缓存文件,PYTHONUNBUFFERED=1让日志实时输出而不是攒在缓冲区里——这两个环境变量在容器场景下都是必须的,尤其是第二个,否则你在docker logs里看不到应用的最新输出,排查问题会非常痛苦。

我用的还是多阶段构建。第一阶段装编译工具(build-essential),把依赖包含原生扩展的包(比如pydantic-core、cryptography)编译好并安装到用户目录;第二阶段只拷贝编译结果,不带编译工具。这样最终镜像里没有 gcc 那一堆工具链,大小能节省三四百 MB。依赖里用pip install --user装到/root/.local,第二阶段通过COPY --from=builder拷贝过来。

容器里必须创建非 root 用户,这个容易被忽略。默认以 root 运行容器,一旦应用被注入恶意代码,攻击者直接就拿到了容器内的最高权限。创建app用户后,用USER app切换过去,再把代码文件的所有权也改成这个用户,权限模型就清晰了。

3.2 .dockerignore 与构建验证

.dockerignore必须写,否则COPY . .会把本地的虚拟环境、Git 仓库、缓存文件全部拷进镜像,构建慢不说,镜像还大。我的模板:

__pycache__/ *.pyc .env .git/ .venv/ venv/ tests/ *.md

写完 Dockerfile,本地先构建验证:

docker build -t myapp:latest . docker run -d --name myapp-test -p 8000:8000 myapp:latest curl http://localhost:8000/health

本地能通再往服务器上传,别一上来就在服务器上试错。这里我多说一句:千万不要把整个项目目录用 scp 传到服务器再构建,如果项目里有大文件或者虚拟环境目录,很容易传半天或者把服务器磁盘塞满。正确做法是把项目推到代码仓库,服务器上git clone/git pull,或者用 CI 构建好镜像推到镜像仓库,服务器只负责拉取运行。个人项目没有 CI 条件的话,我建议用tar把项目打包(排除掉虚拟环境等目录)再传输,比裸拷目录可控得多。

3.3 镜像瘦身与依赖管理的实操心得

镜像大小直接影响服务器的拉取和启动速度。除了多阶段构建,还有几个实用技巧。

依赖锁定要精确。requirements.txt里我习惯把传递依赖的版本也锁死,用pip freeze > requirements.txt的方式从开发环境生成,而不是手写几个大包名。否则今天构建镜像装到的是 1.2 版本,下周同样的 Dockerfile 构建出来却装成了 1.3,行为可能就不一样了,排查起来非常头疼。

一层层缓存也要利用好。Dockerfile 里COPY requirements.txt和RUN pip install放在拷贝全部代码之前,是因为 Docker 构建会缓存每一层。只要 requirements.txt 没变,pip install 这层缓存就直接命中,后续改代码重新构建只需要几秒钟。如果把代码先拷进去再装依赖,那怕只改了一行 Python,整个依赖安装流程都得重跑一遍。

注意:不要在生产容器里用pip install -e .或者依赖开发模式的安装方式,它会绕过 Docker 的层缓存机制,每次构建都全量重装,而且镜像里会残留源代码副本。

4. 用 Docker Compose 把服务编排起来

4.1 编写 docker-compose.yml

单个容器用docker run加一堆参数还能应付,一旦应用除了自身还要配数据库、缓存、队列,命令就会变得不可维护。Compose 的价值就是把整个服务栈的生命周期统一管理。我的一份典型 compose 配置长这样:

services: app: build: . container_name: myapp restart: unless-stopped env_file: .env expose: - "8000" volumes: - static_volume:/app/staticfiles - media_volume:/app/media networks: - app_net healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s timeout: 5s retries: 3 start_period: 10s nginx: image: nginx:1.27-alpine container_name: nginx restart: unless-stopped ports: - "80:80" - "443:443" volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./certbot/conf:/etc/letsencrypt - static_volume:/var/www/staticfiles:ro - media_volume:/var/www/media:ro depends_on: app: condition: service_healthy networks: - app_net volumes: static_volume: media_volume: networks: app_net:

注意app服务只写了expose: ["8000"],这个指令只是声明容器对外提供这个端口,并不会发布到宿主机。它在 Compose 自动创建的网络里可以被 Nginx 容器通过服务名app直接访问。这是上一节说过的“应用端口不对公网开放”的具体落地写法。

Nginx 容器和 app 容器在同一个app_net网络里,Nginx 配置里的proxy_pass http://app:8000就是通过 Docker 内置 DNS 解析到 app 容器的 IP。这里不要写死 IP,容器的 IP 会变,服务名才是稳定的。

4.2 环境变量与敏感信息管理

配置管理是部署里最容易翻车的地方。所有跟环境相关的变量——数据库地址、密钥、配置开关——都走.env文件,由env_file指令注入到容器。.env文件不要提交进 Git 仓库,服务器上手动创建一份即可。

这里有个常见的理解误区:env_file: .env是把文件里的变量注入成容器内环境变量,Compose 本身读取.env则是为了在docker-compose.yml里做变量替换。两者不是一回事。我建议在 compose 文件里尽量不要写${VAR}形式的宿主环境替换,而是统一用env_file注入,这样无论在哪台机器上跑,行为都一致。

敏感信息的安全底线是:密钥只存在于服务器而不进代码仓库。把.env加入.gitignore,生产环境的密钥跟开发环境彻底分离。我见过有人在公开仓库里看到数据库密码和密钥,基本一晚上服务器就被挖矿脚本光顾了,这个学费太高。

4.3 数据持久化、健康检查与重启策略

容器是易失的,镜像重建、容器删除之后,容器文件系统里的数据就没了。所以凡是需要长期保存的数据——用户上传文件、数据库文件、日志——都必须放到卷(volume)里。

static_volume和media_volume就是这种场景。Python 应用把生成的静态文件收集到/app/staticfiles,把用户上传的图片存到/app/media,这个目录落在命名卷里,容器重建不会丢。Nginx 容器又把这些卷以只读方式挂载到自己文件系统的/var/www/下,两个容器通过同一个卷实现文件共享,不依赖容器间通信协议,简单可靠。

健康检查是我后来才补上的关键配置。healthcheck每隔 30 秒请求一次应用的/health接口,连续 3 次失败就判定容器不健康。depends_on的condition: service_healthy保证 Nginx 不会在应用还没起来时就开始转发流量。没有这一步,首次部署时经常出现 Nginx 起来了而应用还没就绪,用户看到 502 的概率会高很多。

restart: unless-stopped是进程守护的底线策略。任何原因导致的容器退出,Docker 都会自动拉起它,唯一例外是你手动执行了docker stop。这个策略比always更友好的一点是,它在系统重启后也会恢复容器,但不会在你明确停止容器后自作主张再拉起来。

5. Nginx 反向代理与 HTTPS

5.1 Nginx 放宿主机,还是也放容器里

上一节的 compose 里我把 Nginx 也放进容器,这确实是我的默认选择。统一用 Compose 管理整个栈,配置、日志、启停都在一个体系内,非常舒服。不过 Nginx 放宿主机也是一个完全可行的方案,两者各有取舍。

Nginx 放容器的好处是隔离性好,服务器上不用装任何 Web 服务器软件,所有依赖都在镜像里固定版本。坏处是配置文件在容器里被覆盖或误改时,排查起来要多绕一层。Nginx 放宿主机的最大好处是排障直观——systemctl status nginx直接看状态,nginx -t测配置,出了问题宿主机上直接操作,对不熟悉容器运维的人来说更友好。

我经历过两种方案后,现在的习惯是:只要服务器上只有这一个站点,Nginx 放容器;如果服务器上要跑多个项目、多个站点,Nginx 直接装宿主机更灵活。后者可以同时代理多个容器、多个端口,不改动原来的部署。下面配置示例我按宿主机方案写,逻辑是通用的,如果你用容器 Nginx,把配置文件挂载进去即可。

5.2 反向代理配置实战

先安装 Nginx:

sudo apt install -y nginx

配置文件放在/etc/nginx/sites-available/,然后软链接到/etc/nginx/sites-enabled/。这个“可用/启用”双目录机制是 Debian 系的做法,好处是随时可以保留多套配置、通过创建或删除软链接来切换站点。

核心配置如下:

server { listen 80; server_name example.com www.example.com; client_max_body_size 20M; location /static/ { alias /var/www/staticfiles/; expires 30d; } location /media/ { alias /var/www/media/; } location / { proxy_pass http://127.0.0.1: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; proxy_read_timeout 60s; } }

从容器 Nginx 方案切到宿主机方案时,这里有个关键变化:proxy_pass的目标从http://app:8000变成了http://127.0.0.1:8000。因为宿主机 Nginx 无法直接解析 compose 里的服务名,所以应用容器发布端口时必须写成"127.0.0.1:8000:8000"——只绑定到本机回环地址,而不是"8000:8000"那样绑定所有网卡。同样的意思,应用端口依然不对公网开放,因为回环地址的外部访问不到。

location /里转发的请求头不能省。Host头是必须的,否则后端应用拿到的 Host 是127.0.0.1而不是你的域名,Django 的ALLOWED_HOSTS校验会直接拒绝请求。X-Forwarded-For让后端能拿到真实用户 IP,否则应用日志里全是 127.0.0.1。X-Forwarded-Proto告诉后端这次请求是不是 HTTPS,否则重定向到登录页时可能生成http://链接而丢了 HTTPS。

FastAPI 项目如果用到 WebSocket,location /里需要额外加两行支持协议升级:

proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";

没这两行,WebSocket 连接会被 Nginx 按普通 HTTP 处理,前端会出现连接建立 1 秒就断开的现象。

5.3 HTTPS 证书申请与自动续期

现在的应用不上 HTTPS 基本说不过去。我用 Let's Encrypt 的 certbot 来申请和续期证书,流程非常标准:

sudo apt install -y certbot python3-certbot-nginx sudo certbot --nginx -d example.com -d www.example.com

certbot 会自动检测 Nginx 配置,修改配置加载证书,并设置 80 端口到 443 的跳转。申请成功后,续期问题交给定时任务,certbot 安装时会自动配置 systemd timer,每天检查证书是否需要续期。我只需要定期跑一下certbot renew --dry-run确认流程正常,然后注意千万别手动禁用 80 端口——续期需要用到它做 HTTP 验证,这也是为什么前面防火墙里我特意放行了 80。

证书续期后,Nginx 需要重载才能生效。certbot 的 hook 配置里可以加上--deploy-hook "systemctl reload nginx",这样每次续期成功自动重载,一劳永逸。

注意:不要复制证书文件到服务器指定路径之外的位置,更不要改 https 监听端口为 8443 之类的非常规端口。保持标准端口,let's encrypt 的验证流程和大多数用户的网络环境都能正常兼容。

6. 上线、更新与问题排查实录

6.1 首轮部署的完整操作流

环境都配好了,部署动作其实就几步。在服务器上:

git clone <your-repo-url> /srv/myapp cd /srv/myapp docker compose build docker compose up -d

up -d一条命令拉起所有服务。之后docker compose ps查看状态,docker compose logs -f跟日志。如果一切正常,浏览器打开域名就能看到应用了。

这一步我强烈建议在前一天构建好的镜像基础上做,不要在服务器上现 bild(除非是首次)。生产服务器构建会拉依赖、编译原生包,消耗 CPU 和内存,还可能因为网络波动失败。日常更新流程是:本地构建并推送到镜像仓库,服务器上docker compose pull && docker compose up -d,整个更新过程几秒钟完成,几乎无感知。

关于更新策略还有一点值得展开:up -d对配置没变化的容器默认不会重建。改了 Dockerfile 或者依赖之后,必须手动docker compose build app重新构建,再up -d生效,不然很容易出现“我改了代码但线上没反应”的尴尬。为避免这个问题,在更新时我习惯加上--build标记:docker compose up -d --build,它会先检查镜像是否有变化,有变化就重建再启动。

6.2 高频报错与排查思路

我整理了几个出现频率最高的报错场景和排查路径,都是实战踩出来的。

502 Bad Gateway。这是最常见的一个。原因基本都是 Nginx 转发目标不可达:后端容器没起来、容器内端口写错、proxy_pass地址写错。排查顺序是:先docker compose ps看容器状态,再docker compose logs app看应用是否成功启动,最后在 Nginx 所在环境里curl http://127.0.0.1:8000/health直接测目标地址,一步步缩范围。

容器一直重启(Restarting)。大概率是应用启动直接崩了。docker compose logs app看退出原因,常见的是:数据库连不上(服务没启动或连接串错误)、端口被占用(容器内用了别的进程监听同端口)、配置文件缺失(.env没放对位置)。大多数情况下日志会直接告诉你原因。

静态文件 404。Nginx 配置了/static/指向一个目录,但目录里没文件。Django 项目需要先执行python manage.py collectstatic把静态文件收集到指定目录,这一步我常放在 Dockerfile 的构建阶段做,或者在应用启动命令前执行。容器挂载卷后,文件要落在卷对应的目录里,Nginx 才能通过共享卷读得到。

数据库数据丢失。这是最痛的。数据库容器没配卷,容器一删数据全没。凡是带状态的中间件(数据库、Redis 持久化配置),必须把数据目录挂载到命名卷或宿主机目录。这也是我前文反复强调数据持久化的原因,一旦吃过大亏就不会再忘。

宿主机名解析失败。Compose 里服务间通过服务名通信,但容器内部要访问宿主机时,localhost指向容器自己而不是宿主机。正确写法是使用host.docker.internal(Docker Desktop 可用)或者把宿主机 IP 通过环境变量传进去。我踩过 Redis 在宿主机上、容器里连localhost连不上的坑,最后帮别人排查这个问题时也花了不少时间。

6.3 日常维护与后续扩展

部署上线不是终点,日常维护才见功夫。几个基础操作要熟练:

docker compose logs -f --tail=200 app # 看应用日志 docker compose ps # 看容器健康状态 docker system df # 看磁盘占用 docker image prune -f # 清理悬空镜像

磁盘占用是最容易被忽视的。每次更新镜像都会留下旧的悬空镜像,时间一长磁盘就满了,导致数据库写入失败、容器创建失败。我习惯把docker image prune和docker builder prune -f放进每周的清理脚本里,再配合磁盘告警监控,基本不会出问题。

日志这块,容器日志默认走 Docker 的 json-file 驱动,单个容器日志文件无限增长也会撑爆磁盘。在/etc/docker/daemon.json里配置:

{ "log-driver": "json-file", "log-opts": { "max-size": "20m", "max-file": "5" } }

然后重启 Docker,每个容器的日志单文件就不会超过 20M,最多保留 5 个轮转文件。这个配置改动小,但能避免一次大故障,强烈建议上线前就设好。

再往后扩展的话,这个架构的演进路径也很清晰:应用实例从 1 个扩到多个,让docker compose up -d --scale app=3配合 Nginx 的upstream配置就能做到基本负载均衡;引入 Redis、PostgreSQL 就加两个 service;需要自动化上线流程,就接 CI,构建镜像推到仓库,服务器用 Webhook 触发拉取和重启。这套 Docker + Nginx 的基础骨架在这些场景下都不用推翻重来,这也是我推荐它的核心原因。

最后分享一个我自己的体会:部署这件事,设计比操作重要。很多人觉得部署就是把代码传到服务器跑起来,但我踩过的所有大坑几乎都源于架构层面的疏忽——端口暴露、数据没持久化、健康检查缺失。这些在设计阶段想清楚,操作阶段就是几条命令的事。先想清楚再动手,你会少熬很多夜。

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

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

立即咨询