1. 一次半夜迁移事故后,我彻底换掉了部署方式
上个月帮朋友把一个用 Flask 写的 Web 应用从旧服务器迁到新服务器,原本以为一小时能搞定,结果忙到凌晨一点还没睡。代码明明是同一套,Python 版本也对着,可依赖就是各种出问题:一会儿提示找不到某个底层动态库,一会儿 Pillow 编译不过去,最后只能逐个排查手动补包,补到 MySQL 驱动的时候才发现是服务器的系统库版本太低,整个打包思路都得重来。
那晚我坐在电脑前盯着满屏红色的 traceback,脑子里只有一个想法:如果能把"运行环境"和"代码"绑在一起带走,是不是就不用跟服务器死磕了?后来我在新服务器上装了 Docker,把 Web 应用连同 Python 运行时、依赖库、启动命令一起打进镜像,再启动容器,十分钟就把服务跑起来了。从那次之后,我自己的项目、帮人部署的项目,能容器化的我全部容器化,几乎没有再被环境问题折腾过。
这就是我会写这篇指南的原因。虽然标题叫"如何使用 Docker 部署 Web 应用",但真正有价值的不是那几条命令,而是理解 Docker 为什么能用一种标准化的方式把"代码 + 环境"打包搬运,以及从本地开发到云端上线的全流程里,每一步最该注意什么。
这篇内容覆盖的范围包括:Docker 的核心概念、本机和云服务器的环境准备、用 Dockerfile 打包一个真实的 Web 应用、用 Docker Compose 编排多服务、把镜像推到仓库再在云端部署、数据持久化和日志处理,以及新手最容易翻车的地方。适合想把部署从"手工操作"升级为"标准流程"的后端开发者、全栈开发者,也适合只维护过个人小项目、想系统搞懂 Docker 部署的同学。
1.1 那晚到底发生了什么
很多人会把这个故事简单归因于"服务器环境不一样",但往深了看,问题出在部署流程里每一步都依赖人工记忆。在这个小项目里,运行环境其实是散落在各个地方的:Python 版本是当初手动装的,几个系统级的依赖包是网上搜到的,数据库初始化 SQL 放在服务器某个目录里,连 gunicorn 启动参数都写在朋友的 shell 历史记录里。
只要中间任何一个环节出现版本偏差,后面所有步骤都会跟着出问题。这其实可以用一句话概括:不是代码不可移植,而是运行环境不可移植。数据库换台机器、依赖升个版本、系统库变一下,程序就变得非常脆弱。而 Docker 的做法,本质上是在应用外面加了一个固定尺寸、固定内容的"箱子",箱子里所有东西在你本地是什么样,到服务器上就是什么样。
1.2 Docker 是怎么解决"环境漂移"的
Docker 的想象方式可以借一个物流业的类比:物流行业不会因为货物不同就重新设计卡车,而是把所有货物按照统一标准装进集装箱,卡车、轮船、吊车只认集装箱这个标准接口。Docker 镜像就是软件的集装箱,里面装好了应用代码、运行时、系统依赖、配置和启动命令,任何一台装了 Docker 的机器,都能用同一套逻辑把这个箱子打开、把服务跑起来。
由于容器里的进程看到的文件系统、系统库、环境变量都是打包时固定的,所以"在我电脑上明明是好的"这句话会真正成为过去式。你提交给运维、团队的产物,不再是一段口头禅加一个 git 仓库,而是一个可验证、可回滚、可复现的镜像。
1.3 这篇文章适合谁、能带你走到哪
如果你是个刚接触 Docker 的初学者,我建议你不要只把命令复制一遍就当看过了,而是把文章里的例子当成一个可以动手跟着敲的小项目,先在本机跑通,再推上云。如果你已经有了一定经验,可以直接跳到第 6 节和第 7 节,那里关于上云、持久化和升级的内容,是我踩过几次坑之后总结出来的,比文档里的标准写法要更贴近真实场景。
文章里的示例我统一用一个最小 Flask Web 应用来做,因为它的结构和很多真实项目相似:一个应用进程,后面可能要连 MySQL 和 Redis。你换成 Node.js、Java Spring Boot 或者 Go,核心逻辑完全一样,只是基础镜像和启动命令不同。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先别急着敲命令:镜像、容器和层这几个概念要过一遍
很多人刚开始学 Docker 就急着跑 docker run,跑完看到一堆输出,好像成功了,又好像啥也没明白。我建议你先花十分钟搞清楚几个最核心的概念,后面所有命令都能串起来,排查问题的时候也不会两眼一抹黑。
2.1 镜像 vs 容器:模板和实例的关系
镜像是静态的、只读的打包产物。你可以把它理解成一个操作系统的安装光盘镜像,里面包含了你应用运行所需的全部文件系统内容。容器则是镜像运行起来后的实体,有自己的进程、网络、文件读写空间。同一个镜像可以启动出多个容器,它们之间互不干扰。
打个比方,镜像是一张菜的菜谱加配好的全部食材,容器是按照菜谱做出来的那盘菜。菜谱可以反复使用,每次做出来的菜都基本一致;但如果哪一次你在菜里额外加了料,那只影响这一盘,不会污染菜谱。容器里产生的数据写入和文件变更,默认只存在于这个容器的可写层里,一旦容器被删除,这些变更也会跟着消失。
这里就引出一个新手最常忽略的结论:容器是"临时"的,你随时可以删了重建。正确的做法是让容器保持无状态,把数据库文件、用户上传文件、日志这些需要长期保存的东西放到外部存储(也就是后面会说的卷)。
2.2 分层的镜像结构,为什么它让部署变快
Docker 镜像不是一个大文件,而是由一层一层只读文件系统叠加而成的。每一条能改变文件系统内容的指令(比如 RUN pip install、COPY . .),都会产生一个新层。构建的时候 Docker 会尽量复用本地的已有层,推送到仓库和拉取下来的时候也只传输变化的部分。
这个设计给部署带来的实际好处非常明显:如果你的项目只改了代码,其他地方都没动,重新构建时只会重建最后几个 COPY 层,前面的依赖安装层可以直接用缓存,几秒钟就能出新的镜像。同理,部署新版本时服务器也只需下载增量数据,而不是整包拉取一个几百 MB 的东西。理解了这一点,你就知道为什么 Dockerfile 里写指令的顺序会影响构建速度——越不容易变化的步骤越应该放前面,越频繁变化的代码 COPY 应该放最后。
2.3 容器不是迷你虚拟机,别用虚拟机思路理解它
这是一个很常见的认知误区。虚拟机需要在物理机上虚拟出一整套硬件,然后每个虚拟机里都跑一个完整的操作系统内核,资源开销很大。而 Docker 容器共享宿主机同一个内核,只通过 Linux 的命名空间和控制组技术做进程隔离与资源限制,所以它启动快、占内存少,一个几 GB 的服务器上可以跑十几个小容器。
但是要注意,容器共享宿主机内核,也意味着你不应该指望在运行 Windows 的机器上直接运行 Linux 容器,容器里的内核版本其实是由宿主机决定的。普通 Web 应用部署根本不用关心这个差异,可当你要跑的是强依赖特定内核模块的程序时,需要额外留意。
3. 环境准备:在你自己的电脑上把 Docker 跑起来
工欲善其事,必先利其器。先把 Docker 环境装好并验证无误,后面操作都会顺很多。我分别说下个人电脑和云服务器两种场景。
3.1 Windows 和 macOS 安装 Docker Desktop 的关键点
Windows 上最省事的方式是安装 Docker Desktop,它会帮你把 Docker 引擎、命令行工具和图形界面一起装好。安装过程中最容易出的问题是虚拟机支持没有开启,启动时提示类似 "Docker Desktop failed to start because virtualisation support wasn't detected"。这种情况通常需要到 BIOS/UEFI 里确认虚拟化技术已经打开,并在 Windows 功能里启用 WSL 2 相关组件,然后重新启动电脑。装好之后不要急着用,先在终端跑一下:
bash复制docker version
docker run --rm hello-world
看到 hello-world 容器正常打印出信息,就说明整个链路没问题。macOS 上安装 Docker Desktop 相对简单,同样可以用上面两条命令验证。
这里提醒一句:Docker Desktop 在个人开发场景下很好用,但在生产服务器上一定不要装它。服务器上只需要 Docker Engine,也就是所谓"裸的 docker",后面我会讲服务器怎么装。
3.2 云服务器上装 Docker Engine
如果你有一台全新的 Ubuntu 云服务器,最稳的做法是参照 Docker 官方文档,先把系统包索引和基础依赖装好,再添加官方软件源,最后安装 docker-ce。如果不想手动敲那么多步骤,也可以使用 Docker 官方提供的安装脚本:
bash复制curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
执行完第一行后,Docker Engine 就已经装好了。第二行是把当前用户加入 docker 用户组,这样之后运行 docker 命令就不用每个命令前都加 sudo。注意,用户组变更需要重新登录服务器才会生效,我经常看到有人执行完立刻 docker ps,发现 still permission denied,以为装失败了,其实只是用户组还没刷新。
装完后建议顺手把 Docker 服务设为开机自启:
bash复制sudo systemctl enable docker
sudo systemctl start docker
如果是在你自己的日常开发服务器上,想用宝塔面板之类的东西管理容器也完全可以,但如果你是想系统掌握 Docker,我还是建议先把命令行用熟,图形面板会隐藏太多细节。
3.3 启动检查与 daemon 配置:日志大小、数据目录、镜像仓库
Docker 装好后,有件事我建议你第一时间就做,就是配置 daemon.json。很多新手的服务器跑几个月后磁盘被占满,八成是容器日志和镜像缓存没有控制。创建一个 /etc/docker/daemon.json,写入下面内容:
json复制{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"storage-driver": "overlay2"
}
这段配置的核心作用是把单个容器的日志文件限制在 10MB、最多保留 3 个文件,避免日志无限增长把系统盘写满。改完配置后需要重启 Docker:
bash复制sudo systemctl restart docker
如果你发现 docker pull 拉取镜像特别慢,卡在等待下载的状态,一般不是命令的问题,而是当前网络环境到默认镜像仓库的链路质量不稳定。方案是按你的实际网络环境来选择:使用你所在云服务商自带的镜像仓库服务,或者把常用基础镜像预先推到自己的私有仓库,再让服务器从那里拉取;也可以在你的网络条件下配置合适的 registry mirror 地址。这一步没有万能解,但思路是通用的——不要让每台服务器都依赖同一个遥远的公共仓库。
4. 第一个能跑的 Web 应用:用 Dockerfile 打包 Flask 项目
概念和环境都准备好了,现在开始进入正题:写 Dockerfile。我拿一个最基础的 Flask 项目举例,文件结构大概是这样:
text复制myweb/
├── app.py
├── requirements.txt
└── Dockerfile
4.1 先从最小的 Dockerfile 开始
app.py 内容很简单:
python复制from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return "Hello, Docker Web App"
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
requirements.txt 里写:
text复制flask==3.0.3
gunicorn==22.0.0
对应的 Dockerfile:
dockerfile复制# 指定基础镜像。选择 python:3.11-slim 而不是 python:3.11 能省很多空间
FROM python:3.11-slim
# 避免生成 __pycache__,同时让 Python 日志不被缓冲,便于容器日志收集
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
# 设置工作目录,后面所有命令都在这个目录下执行
WORKDIR /app
# 先只复制依赖清单并安装依赖,这一步是为了利用镜像层缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 再复制项目代码
COPY . .
# 声明容器对外提供服务时使用的端口,注意这只是声明,实际映射要靠 -p 参数
EXPOSE 8000
# 容器启动时执行的命令。优先用 gunicorn 而不是 flask run
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]
这段 Dockerfile 是核心,有两个细节要展开说。
第一,为什么要先用一个 Python 官方基础镜像而不是直接在宿主机上装 Python?因为官方镜像已经帮你把 Python 编译好并做了大量兼容性处理,你只需要在它上面加项目代码。选 slim 变体是为了省体积,开发时无所谓,生产环境你拉镜像的时间差会很明显。
第二,为什么要先 COPY requirements.txt 再 COPY . .?正如前面讲的镜像分层原理,只要 requirements.txt 没变化,安装依赖这一步的层就能被 Docker 缓存复用。如果你把整份代码放在依赖安装之前复制,那么每次改项目文件都会导致依赖层缓存失效,每次都要重新 pip install,慢得让人崩溃。
4.2 别忽略 .dockerignore 和镜像体积控制
在项目里建一个 .dockerignore 文件,和 .gitignore 一个思路:
text复制__pycache__
*.pyc
.git
.venv
venv
.env
Dockerfile
.dockerignore
这个文件的作用是告诉 Docker 构建时哪些文件不要打包进镜像。如果不写它,你本地虚拟环境里可能好几百 MB 的依赖目录会被一起复制进去,最后镜像体积直接爆炸。尤其注意,.env 文件如果被打进镜像,就等于把数据库密码写进了镜像历史里,这是非常危险的安全隐患。
构建并运行:
bash复制docker build -t myweb:0.1 .
docker run -d -p 8000:8000 --name myweb-demo myweb:0.1
curl http://localhost:8000
-d 表示后台运行,-p 8000:8000 表示把宿主机的 8000 端口映射到容器内的 8000 端口。看到 Hello, Docker Web App 就说明容器里的 Flask 应用已经跑起来了。
4.3 本地构建、端口映射与热更新开发体验
很多开发者会问,容器适合做日常开发吗?我觉得看场景。如果你只改代码不涉及新依赖,又不想每次手动重建镜像,可以在开发时把本地代码目录以绑定挂载的方式放进容器,配合 --reload 实现热更新:
bash复制docker run -d -p 8000:8000 -v $(pwd):/app myweb:0.1
把宿主机当前目录挂载到容器 /app 后,容器里读到的代码就是你本机实时修改的版本,改完代码 Flask 的开发服务器或 gunicorn 的 --reload 会自动重载。不过一旦引入了新的第三方包,还是要重新 build 一次镜像,因为新依赖并没有安装到正在运行的容器里。这个边界要心里有数。
5. 应用变复杂后,用 Docker Compose 一次拉起全家桶
如果你的项目只有一个 Python 进程,那单个容器就够用了。可真实 Web 应用往往不止一个进程,数据库、缓存、队列都是标配。这时要是还用一长串 docker run 命令手动管理,你会发现端口、网络、启动顺序全部是一团乱麻。Docker Compose 就是用来解决这个问题的。
5.1 为什么一个容器不够用
我见过有些人把 MySQL、Redis 和一个应用都塞进同一个容器里,用 supervisord 同时拉起多个进程,这种方案非常不建议。容器的最佳实践是一个容器只跑一个主进程,原因很简单:
- 便于单独扩缩容:访问量大了,你只想多起几个 Web 应用容器,不可能把数据库也一起多拉几个。
- 便于独立升级:数据库和应用的升级节奏完全不同。
- 便于故障隔离:应用内存溢出不应该拖垮数据库所在的进程。
所以正确思路是每个组件一个容器,容器之间通过网络互相访问。Docker Compose 的价值在于把这些容器的定义统一写在一个 YAML 文件里,一条命令完成构建、启动、停止和更新。
5.2 一份 docker-compose.yml 实例:Web + MySQL + Redis
假设项目需要一个 MySQL 存业务数据、一个 Redis 做缓存。项目根目录下创建 docker-compose.yml:
yaml复制services:
web:
build: .
ports:
- "8000:8000"
environment:
- DB_HOST=mysql
- REDIS_HOST=redis
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: myweb_db
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 5s
retries: 10
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
retries: 10
volumes:
mysql_data:
redis_data:
这个文件里值得细讲的点有三个。
第一是服务名的 DNS 功能。Compose 会在内部创建默认网络,web 服务里通过 DB_HOST=mysql 直接使用服务名 mysql 访问数据库容器,不需要填 IP 和端口。容器重启后 IP 可能会变化,但服务名不会变,这是容器网络设计的精髓。
第二是 depends_on 配合 healthcheck。仅靠 depends_on 只能控制启动顺序,不能保证 MySQL 已经真正就绪可连接。加上 healthcheck 后,MySQL 容器内部会定期执行 mysqladmin ping,只有确认数据库健康后,web 服务才会被拉起,避免出现"应用启动时连不上数据库直接崩溃退出"的经典问题。
第三是 MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}。密码不应该硬编码进 compose 文件。项目里建一个 .env 文件:
text复制MYSQL_ROOT_PASSWORD=请改成强密码
Compose 会自动读取同目录下的 .env 文件填充变量。.env 文件要加入 .gitignore 和 .dockerignore,绝不能进版本库。
5.3 常用 Compose 操作与更新部署顺序
在 docker-compose.yml 所在目录执行:
bash复制# 构建镜像并在后台启动所有服务
docker compose up -d
# 查看当前所有服务状态
docker compose ps
# 查看某个服务的日志
docker compose logs -f web
# 更新代码后重新构建并启动
docker compose up -d --build
# 停止所有服务,但保留数据卷
docker compose down
# 停止所有服务,并且删除数据卷(小心使用)
docker compose down -v
更新部署时我建议养成一个习惯:先 docker compose pull 更新镜像,再 docker compose up -d。这个命令组合会保留还在运行中的旧容器,只替换需要更新的部分,避免把整个服务组全部重启一遍。如果你是第一次在服务器上部署,直接用 docker compose up -d --build 就行。
6. 上云落地:把本地镜像搬到服务器并对外提供服务
本地跑通只是第一步,真正的目标是把应用部署到云服务器,让外网能访问。这里有一个很多人绕不过去的问题:怎么把本地构建的镜像弄到服务器上去?
6.1 镜像仓库:私有仓库还是 Docker Hub
最直接的方式是把镜像推送到一个镜像仓库,然后在服务器上拉取。如果你能接受公共仓库,Docker Hub 最省事,注册账号后执行:
bash复制docker login
docker tag myweb:0.1 你的用户名/myweb:0.1
docker push 你的用户名/myweb:0.1
服务器上再执行 docker pull 你的用户名/myweb:0.1 就能拿下来。但如果你部署的是公司内部项目,代码和数据不希望放在公共仓库,那就要用私有仓库。几个常见选择:
| 方案 | 优点 | 适合场景 |
|---|---|---|
| Docker Hub 私有仓库 | 免费、方便 | 个人项目、小团队 |
| 云厂商容器镜像服务 | 推拉速度快、与云服务器同区域 | 生产环境部署 |
| Harbor / Registry | 自托管、可控 | 对数据安全要求高的团队 |
无论选哪种,流程都一致:本地 build -> tag -> push,服务器 pull -> compose up。我个人习惯是本地构建、推到仓库、服务器拉取,而不是直接在服务器上 build,这样服务器不需要装代码仓库,也不需要保留源代码,安全性和一致性都更好。
6.2 服务器端一次性初始化脚本思路
给服务器装完 Docker 后,把项目配置文件传到服务器。假设放到 /opt/myweb:
bash复制scp docker-compose.yml user@你的服务器IP:/opt/myweb/
scp .env user@你的服务器IP:/opt/myweb/
ssh user@你的服务器IP
cd /opt/myweb
docker compose up -d
你可能会问,代码在哪里?答案是不需要把代码传到服务器,镜像已经包含代码了。服务器上只需要 compose 文件和 .env 文件。第一次部署时会自动从仓库拉取镜像并启动服务,跑起来后执行 docker compose ps 应该能看到三个服务都是 running 状态。
这里有个安全细节:不要在 compose 文件里直接暴露数据库端口。如果你写成 ports: - "3306:3306",相当于把数据库裸奔到公网,任何扫端口的人都能尝试连接。容器之间的互相访问根本不需要端口映射,只有需要对外提供访问的服务比如 web 才做端口映射。这也是我把 compose 里的 MySQL 和 Redis 都取消端口映射的原因。
6.3 用 Nginx 反代和 HTTPS 收尾
当服务跑起来后,直接通过 http://服务器IP:8000 访问看起来没问题,但真实项目几乎都会在前面加一层反向代理。这样做的目的有三个:统一入口、终结 HTTPS 证书、按域名分发流量。最简单的方式是在宿主机上装 Nginx,把云服务器的 80/443 端口交给它,再让它把流量转发给 Docker 映射出来的 8000 端口。
Nginx 配置片段:
nginx复制server {
listen 80;
server_name yourdomain.com;
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;
}
}
配好 HTTP 访问后,再用 certbot 申请免费证书并自动续期。不建议用裸 IP 和裸 HTTP 跑生产应用,浏览器会拦截,数据在链路上也没有加密。如果你不想手动维护 Nginx,也可以用 Caddy,它会在 compose 里自行申请和续期证书,配置量少很多,但灵活性不如 Nginx。
7. 数据、日志与升级:容器被删了也不能慌
容器可以被随时删除重建,但数据库里的业务数据不能跟着一起没。这一节专门说容器化之后,最容易出问题的三件事。
7.1 用卷保住数据库和上传文件
前面 compose 文件里我已经给 MySQL 和 Redis 配置了命名卷:mysql_data 和 redis_data。命名卷的数据存放在宿主机 Docker 管理的一块目录里,即便执行 docker compose down,默认也不会删除卷,下次重新 up -d 时数据还在。
判断一个服务是否需要配置卷,标准只有一个:这个容器里有没有"删了就不能重新生成的数据"。数据库文件显然是,用户上传的图片也显然是。所以如果你在应用里写了文件上传功能,一定要把上传目录用卷挂载出来:
yaml复制services:
web:
volumes:
- upload_files:/app/uploads
volumes:
upload_files:
我一开始做 Docker 部署时,没有给上传功能挂卷。后来容器因为日志占满磁盘被运维删了重建,结果用户上传的所有图片全部丢了,那种教训一次就够。数据卷看起来没有名字那么炫酷,但它是容器化应用的生命线。
备份数据库时,不需要停掉容器,直接在宿主机执行:
bash复制docker compose exec mysql mysqldump -uroot -p myweb_db > backup.sql
恢复时把备份文件传回服务器,再执行:
bash复制docker compose exec -T mysql mysql -uroot -p myweb_db < backup.sql
7.2 查看与归档容器日志
日志方面,如果你没做 Docker daemon 级别的日志限制,某个服务一旦疯狂打印日志,过几天服务器磁盘就满了。我前文建议配置的 daemon.json 里设置了单个容器日志最多 10MB、保留 3 份,就是为了防这种情况。
查看日志的正确姿势是:
bash复制# 实时跟踪某个服务的输出
docker compose logs -f web
# 只看最近 200 行
docker compose logs --tail=200 web
如果你需要把日志归档到外部系统,常见做法是让容器把日志写到标准输出,然后用采集代理去读 Docker 的标准日志,而不是在容器里写文件。因为一旦容器重建,容器内的日志文件就全没了。标准输出的日志可以被 Docker 统一管理,这才是容器日志设计的预期用法。
7.3 升级应用的正确姿势
应用改完代码后,升级流程可以这样走:
- 本地重新构建镜像:
docker build -t 你的用户名/myweb:0.2 . - 推到镜像仓库:
docker push 你的用户名/myweb:0.2 - 登录服务器,进入项目目录,修改 compose 里的镜像 tag 或者直接使用
--build - 执行
docker compose up -d
Docker 的滚动更新逻辑会先起一个新的容器实例,等它健康后再停掉旧容器。如果你的 compose 文件里没有配置 healthcheck,则默认延迟后直接切换。建议给 web 服务也加上健康检查:
yaml复制services:
web:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
interval: 10s
timeout: 3s
retries: 3
这样 docker 就能在确认新容器可以正常响应请求后,才把流量切过去。生产环境下,这一步直接决定了升级时用户会不会看到 502。
8. 新手最容易踩的 5 个坑,我一个个替你趟过了
最后分享几个我接触 Docker 这么久以来看到的新手高频翻车现场。如果你提前知道了,大概率能省下好几晚排查时间。
8.1 容器里的时间变成了 UTC
默认情况下,容器使用 UTC 时区,你的应用打印出来的日志时间会比本地时间慢 8 个小时,数据库里存的时间也乱套。最直接的解决办法是在 compose 环境变量里指定时区:
yaml复制environment:
- TZ=Asia/Shanghai
如果基础镜像里没有包含时区数据,还需要在 Dockerfile 里安装 tzdata,或者把宿主机的 /etc/localtime 以只读方式挂载进容器。这个坑特别隐蔽,因为应用日志单独看发现不了问题,只有把容器日志和应用其他日志放一起比对时才发现全错位。
8.2 容器启动后秒退
很多人第一个容器都是 docker run ... 启动后立刻 docker ps,发现列表里没有这个容器,再 docker ps -a 一看,状态是 Exited。这通常意味着容器里的主进程已经结束了,容器没有进程可跑,自然就退出了。
最常见的错误是把 CMD 写成了类似 CMD ["service", "nginx", "start"] 这样启动后台服务就马上返回的命令。容器跟虚拟机不一样,它必须在前台保持一个进程存活,容器生命周期绑定的是这个主进程。正确的写法是让进程以前台方式运行,比如 gunicorn 不加 --daemon,nginx 直接写 nginx -g "daemon off;"。如果不知道镜像默认进程是什么,可以用 docker logs 容器名 看输出,再 docker inspect 容器名 查 Entrypoint 和 Cmd。
8.3 镜像越堆越大
镜像过大不仅占用磁盘,还会显著拖慢推送和拉取速度,直接影响发布效率。体积膨胀一般有四个来源:基础镜像太大、依赖安装后残留缓存、构建过程中复制了不需要的文件、没有使用多阶段构建。
改善办法也很直接:基础镜像优先选 slim 或 alpine 变体;pip 安装加 --no-cache-dir;确保 .dockerignore 生效;如果是编译型语言项目,在 Dockerfile 里用多阶段构建,先用编译器镜像把产物编译出来,再把产物复制到一个干净的小基础镜像里。同样的项目,修正这几个点之后镜像体积差 3 倍以上非常常见。
8.4 密钥被写进镜像历史
这恐怕是最危险的一个坑。如果你在 Dockerfile 里写下 ENV MYSQL_PASSWORD=xxx,或者在构建过程中把 .env 文件复制进了镜像,那这个密钥就会固化在镜像层里。别人只要拉取镜像、执行 docker history 镜像名,就能看到每一层都执行过什么操作、设置过什么环境变量,密钥相当于直接公开了。
正确的做法是:敏感信息通过 compose 的 environment 或 .env 文件在运行时注入,而不是写死进镜像。如果必须在构建阶段访问私有依赖源,可以了解 Docker BuildKit 的 --secret 特性,它能把密钥用于构建但不会留在镜像层。作为基本底线,.dockerignore 里至少要包含 .env。
8.5 改完代码不重新构建
这也是新手常见误区:代码改了,docker compose up -d 一执行,发现页面还是旧的。原因在于 up -d 不会自动构建新镜像,它只负责用当前镜像启动容器。如果你的本地代码没有挂载进容器,容器里运行的仍然是之前构建的老版本。
正确做法是修改 compose 配置文件后执行 docker compose up -d --build,让它在启动前根据 Dockerfile 自动重建镜像。更稳妥的做法是先 docker compose build 确认镜像构建成功,再 docker compose up -d。养成这个习惯后,部署时的"为什么没生效"会减少九成。
我自己现在把所有部署都收拢到 compose 流程里之后,每次上线只需要做三件事:改代码、build 并 push 镜像、登录服务器执行 docker compose pull && docker compose up -d。整个过程可控、可回滚,出问题就切回上一个镜像 tag。如果你正在被服务器环境反复折腾,拿手头一个小 Web 应用先加一份 Dockerfile,在本地验证跑通,再往前走一步,你会发现 Docker 真正值得的地方不是技术栈很酷,而是它让"上线"这件事实在太省心了。
