这两年不管是后端、运维还是做前端自建个人项目,大家聊到“怎么把服务稳定跑起来”,基本绕不开 Docker。Docker 是什么,一句话讲清楚:它是一种容器化技术,把应用和它运行所需的依赖、配置、环境一起打包成标准化的镜像,再在任意装了 Docker 的机器上变成容器运行。这意味着你本地跑起来的东西,在测试环境、生产环境里也能用同样的方式跑起来,不再出现“在我电脑上是好的”这种尴尬。这篇文章我从项目设计思路、核心概念、完整实操到生产环境常见坑,做一次系统复盘,适合刚接触容器化、或者已经会docker run但总觉得理解不深的读者。
1. 项目画像:Docker 到底在解决什么问题
1.1 从环境不一致说起
先回想一下以前部署服务时最常见的场景:代码在本地跑通了,一放到服务器上就崩溃。原因可能只是服务器上的 Python 版本高了 0.1,或者 MySQL 的编码集不一样,又或者是某个系统依赖库没装。你花一天时间排错,最后发现是环境差异,这种时间损失真的很亏。
Docker 的处理思路是:把代码、运行时、依赖库、环境变量、端口配置全部打进一个镜像里。镜像相当于一个打包好的“完整运行环境”,容器则是这个环境在机器上的运行实例。换个比喻来说,传统方式像搬家时只搬家具,到了新房子还得重新接水电、量尺寸;Docker 则是把整个房间连同装修一起封装,搬到哪都能直接住。
这个设计之所以能成立,靠的是底层两个机制:
- 命名空间:容器里的进程、文件系统、网络、用户等资源被隔离在自己的命名空间里,彼此看不见,像多个独立的小隔间。
- 控制组:限制每个容器能使用的 CPU、内存、磁盘 IO,防止一个容器把整台机器资源吃光。
这也是容器和虚拟机最大的不同。虚拟机需要模拟一个完整的操作系统,通常要占几个 GB 的磁盘、开起来要分钟级;容器直接共享宿主机的内核,镜像可能只有几百 MB,启动速度是秒级。这也是我后来在项目里优先选择 Docker 的重要原因。
1.2 它适合什么,不适合什么
不是所有场景都需要 Docker,但它确实覆盖了绝大多数常见的工程痛点:
适合用 Docker 的场景:
- 开发环境统一:新同事入职,拉代码后一条命令起数据库和中间件,不用再折腾安装包。
- 微服务拆分:不同服务用不同语言、不同依赖版本,容器天然隔离,互不干扰。
- CI/CD 交付产物:构建好的镜像可以推送到仓库,部署时拉取即可,回滚也简单。
- 快速搭建中间件:比如 Redis、MySQL、Nginx,以前要下载编译安装,现在一条
docker run就能跑起来。
不太适合用 Docker 的场景:
- 带 GUI 的桌面应用,容器对图形界面支持比较麻烦。
- 对极致性能敏感的高性能计算,虚拟化层多少会有损耗。
- 需要直接操作宿主硬件设备、内核模块的场景,容器隔离反而成了障碍。
所以我的原则是:先用容器解决环境一致性和交付标准化,不要把 Docker 神化。
2. 核心概念拆解:把这几个东西彻底弄明白
2.1 镜像:只读模板与分层缓存
镜像是容器的模板,它由一层层只读文件系统组成。每一层对应 Dockerfile 里的一条指令,比如FROM拉取基础镜像,RUN安装依赖,COPY拷贝文件。这些层级会形成缓存,构建时如果某一层没变,就直接复用缓存,构建速度会快很多。
举个例子,一个 Python 项目的镜像,可能由这些层组成:
- 基础镜像,比如
python:3.12-slim - 安装 pip 依赖层
- 拷贝项目代码层
如果只改动代码,依赖层没变,重新构建时只需要重新拷贝代码,而不用重新下载依赖。这个特性是我在设计 Dockerfile 时最关注的点。
镜像常用操作也很简单:
# 查看本地镜像 docker images # 拉取远程镜像 docker pull nginx:1.27-alpine # 删除本地镜像 docker rmi nginx:1.27-alpine有一点值得专门提醒:不要用docker commit来制作镜像。虽然它可以把一个容器的当前状态保存成镜像,但会丢失 Dockerfile 的可追溯性,镜像体积也容易膨胀,里面的历史操作都无法复现。团队协作或者生产发布,规范做法永远是用 Dockerfile 构建。
2.2 容器:可写层的运行实例
有了镜像这个只读模板,docker run会基于它创建一个容器,并在最上层加一个可写层。你对容器内部文件系统的修改都写在这个可写层里,容器删除后,可写层也跟着消失。
容器本质上是宿主机上的一个进程,只不过被隔离在了自己的命名空间里。它和虚拟机的差异,前面已经说过。这里用一个类比:镜像是一张写满内容的光盘,容器则是在某台机器上读取这张光盘并在上面做笔记。光盘本身不变,笔记跟着你读完放下就没了。
常用操作:
# 启动一个交互式容器 docker run -it ubuntu:22.04 bash # 查看运行中的容器 docker ps # 查看所有容器(包括已退出) docker ps -a # 进入运行中的容器 docker exec -it my-container sh # 停止和删除容器 docker stop my-container docker rm my-container有个常见误解是“容器就是一台小虚拟机,想怎么改就怎么改”。实际生产中,我建议把容器当作一次性进程:要改配置就改 Dockerfile,重新构建、重新启动,而不是进入容器去改文件。原因很简单,容器一旦重建,所有手动修改全都消失。规范化的交付流程,应该保证任何环境都从同一个镜像得到同样的结果。
2.3 数据卷:把状态从容器里剥离出来
前面说了,容器的可写层生命周期很短,容器删除后数据会丢。所以凡是需要持久化的数据,比如数据库文件、上传的图片、日志文件,都必须放到数据卷里。
Docker 提供两种常见挂载方式:
- 命名卷:由 Docker 管理的目录,
docker volume ls可以看到,适合数据库数据这类不需要直接关心的内容。 - 绑定挂载:直接把宿主机的某个目录映射到容器里的路径,适合看日志、改配置,或者开发时热更新代码。
用法示例:
# 使用命名卷挂载 MySQL 数据目录 docker run -d \ --name mysql-demo \ -v mysql-data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=root \ mysql:8.0 # 使用绑定挂载,把当前目录映射到容器工作目录 docker run -d \ --name app-demo \ -v "$(pwd):/app" \ my-app-image-v 卷名:容器内路径和-v /宿主机绝对路径:容器内路径的区别在于第一个冒号左边是卷名,第二个是宿主机路径。很多新手踩坑都是把这两者混着用,结果数据被写到了意想不到的位置。
权限问题要特别注意:宿主机目录如果权限不对,容器里的进程可能没法读写。最常见的表现是 MySQL 容器启动时报数据目录权限错误。这种情况要么调整宿主机目录的属主和权限,要么在挂载时指定正确的用户 ID。我后面在问题排查章节会专门展开。
2.4 网络模型:容器之间如何通信
默认情况下,每个容器有自己独立的网络栈,外部访问它需要做端口映射。比如把宿主机的 8080 端口映射到容器的 3000 端口:
docker run -d -p 8080:3000 demo-app容器与容器之间通信,最推荐的方式是创建一个自定义网络,然后把多个容器放进去。自定义网络里,Docker 内置 DNS 解析,容器可以直接用服务名互相访问,不需要关心 IP 地址变化。
# 创建网络 docker network create app-net # 启动两个容器并加入同一网络 docker run -d --name app --network app-net demo-app docker run -d --name redis --network app-net redis:7-alpine在app容器里访问redis服务时,直接连接redis:6379即可,不需要写 IP。这一套机制在docker-compose里体现得更加明显,后面的实操章节会直接用到。
3. 实操复盘:从零部署一个带数据库的 Web 服务
3.1 先写一个需要连接外部服务的程序
空谈概念没有意义,我带着你从零到一跑一个完整项目。这次选一个小型 Web 服务,用 Node.js 写,对外提供两个接口,依赖 Redis 和 MySQL。技术栈大家都很熟,重点是容器化的思路。
项目结构先搭出来:
demo-app ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── package.json └── server.jspackage.json如下,只引入最少依赖:
{ "name": "demo-app", "version": "1.0.0", "dependencies": { "express": "^4.19.2", "mysql2": "^3.9.7", "redis": "^4.6.14" }, "scripts": { "start": "node server.js" } }server.js实现一个健康检查接口,同时验证 Redis 和 MySQL 是否连通:
const express = require('express'); const redis = require('redis'); const mysql = require('mysql2/promise'); const app = express(); const port = 3000; const redisClient = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379', }); redisClient.connect(); const db = mysql.createPool({ host: process.env.MYSQL_HOST || 'localhost', user: process.env.MYSQL_USER || 'root', password: process.env.MYSQL_PASSWORD || 'root', database: process.env.MYSQL_DATABASE || 'appdb', }); app.get('/', (req, res) => { res.json({ message: 'demo app is running' }); }); app.get('/health', async (req, res) => { try { await redisClient.ping(); await db.query('SELECT 1'); res.json({ status: 'ok' }); } catch (err) { res.status(500).json({ status: 'error', detail: err.message }); } }); app.listen(port, () => { console.log(`demo app listening on ${port}`); });这些环境变量都从外面传进来。这样做的好处是,同一份代码在不同的环境里,可以通过环境变量切换数据库地址,镜像本身不需要重新构建。
3.2 编写 Dockerfile 的注意点
Dockerfile 是构建镜像的配方文件。下面是这个项目的 Dockerfile:
FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json* ./ RUN npm install --registry=https://registry.npmjs.org COPY . . EXPOSE 3000 CMD ["node", "server.js"]逐行说一下为什么这么写:
FROM node:20-alpine:选择 Alpine 版基础镜像。Alpine 体积小,比标准镜像小不少,对部署和传输都很友好。WORKDIR /app:设置容器里的工作目录。后续命令都在这个目录下执行,避免文件乱放。COPY package.json package-lock.json* ./:先只拷贝依赖清单文件。这是 Docker 分层缓存的核心技巧,后续改代码时,依赖安装层不会变化,构建会直接复用缓存。RUN npm install:在镜像构建时安装依赖。如果你用 npm 私服,也可以把 registry 地址替换成私服地址。COPY . .:把项目代码拷贝进去。注意前面必须配置.dockerignore,否则本地的node_modules也会被拷贝进去,污染镜像。EXPOSE 3000:声明容器对外提供 3000 端口。这只是一种元信息,真正发布端口还是要靠docker run -p。CMD ["node", "server.js"]:容器启动时执行的命令。需要注意它和RUN的区别:RUN是构建阶段执行的,CMD是运行阶段执行的。
.dockerignore文件不能省:
node_modules npm-debug.log .git .gitignore Dockerfile docker-compose.yml这个文件的作用是让构建上下文里不包含无关文件。我在实际项目里见过不少人把整个项目目录塞进构建上下文,镜像变得巨大,构建也慢,就是因为没写.dockerignore。
3.3 用 docker-compose 编排整套服务
前面只用单个容器,现在要把应用、Redis、MySQL 三个服务一起跑起来。如果每条都用docker run去启动,不仅命令长,还得手动管理网络和依赖顺序,非常容易出错。这时候用docker compose来做编排最合适。
创建docker-compose.yml:
services: app: build: . ports: - "8080:3000" environment: REDIS_URL: redis://redis:6379 MYSQL_HOST: mysql MYSQL_USER: root MYSQL_PASSWORD: root MYSQL_DATABASE: appdb depends_on: redis: condition: service_healthy mysql: condition: service_healthy restart: unless-stopped redis: image: redis:7-alpine volumes: - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 5 mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: appdb volumes: - mysql-data:/var/lib/mysql healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -p$$MYSQL_ROOT_PASSWORD"] interval: 5s timeout: 3s retries: 5 volumes: redis-data: mysql-data:这个编排文件里值得关注的几个点:
app服务没有指定image,而是使用build: .,表示用当前目录的 Dockerfile 构建。ports把宿主机的 8080 映射到容器内的 3000。environment注入环境变量,应用代码里从process.env读取,这一步就把前面代码里的坑补上了。depends_on配合condition: service_healthy,确保 Redis 和 MySQL 真正就绪后,应用才开始启动。不然应用启动时数据库还没准备好,会出现一堆连接错误。- 两个数据库服务都挂了命名卷,容器删掉重建,数据还在。
restart: unless-stopped让服务在意外退出时自动重启,这是生产环境比较稳妥的策略。
启动命令非常干净:
docker compose up -d查看运行状态:
docker compose ps查看日志:
docker compose logs -f app停止和清理:
docker compose down注意docker compose down默认不会删除命名卷。如果想把数据一起清理掉,需要加-v参数,但请一定慎重,加了之后数据库里的数据会被清空,没有后悔药。
3.4 验证部署结果与日常操作
整套服务起来之后,可以直接验证:
# 查看所有服务状态 docker compose ps # 访问应用根路径 curl http://localhost:8080/ # 访问健康检查接口 curl http://localhost:8080/health如果一切正常,/health接口会返回:
{"status":"ok"}在开发调试过程中,我常会用到的操作还有这些:
# 进入应用容器内部排查 docker compose exec app sh # 查看某个容器的资源占用 docker stats # 在宿主机直接访问容器内 Redis docker compose exec redis redis-cli ping这套组合命令基本覆盖了日常开发和线上排查百分之八九十的场景。
4. 常见问题排查实录:从报错到恢复
4.1 端口映射不生效或访问被拒绝
现象:宿主机上访问http://localhost:8080报错,但容器日志显示服务已经正常启动了。
排查顺序:
- 先看容器有没有在运行:
docker ps。 - 查看端口是否正确映射:
docker port app。 - 查看容器日志:
docker compose logs app。 - 在宿主机上监听端口状态:
ss -lntp | grep 8080。
一个常见原因是端口冲突。8080 已经被另一个进程占用,Docker 会映射失败,但不同版本的 Docker 行为不一样,有的会直接启动失败,有的会报端口占用错误。解决办法是换一个宿主机端口,比如改成8081:3000。
还有一种情况是容器监听了127.0.0.1,导致外部访问不通。如果程序里监听的地址是localhost或127.0.0.1,容器外部就访问不到。记得把监听地址设为0.0.0.0。
4.2 容器一重启,数据就没了
现象:重启容器后,数据库的数据或者上传的文件全部消失,退回初始状态。
原因:数据写在了容器的可写层,没有挂载数据卷。容器重建后,可写层被丢弃,数据自然就没了。
解决办法:按前面 3.3 节的写法,给所有需要持久化的路径挂载命名卷或绑定挂载。MySQL 挂/var/lib/mysql,Redis 挂/data,这是各自镜像文档约定好的数据目录,不能随意挂。
注意:不要只依赖
docker commit来“保存数据”,这是一种很不优雅的备份方式,维护成本极高。
4.3 构建镜像非常慢,或者缓存总是失效
现象:每次改一行代码,重新构建时都要重新安装依赖,耗时很久。
原因:Dockerfile 里的COPY顺序不合理。如果把COPY . .写在RUN npm install之前,那么任何代码改动都会让“拷贝代码”这一层缓存失效,后面的依赖安装层也只能重新执行。
正确顺序:先拷贝依赖清单文件,再安装依赖,最后拷贝源码。这样源码变动不会影响依赖缓存。
COPY package.json package-lock.json* ./ RUN npm install COPY . .另外,启用 BuildKit 后,缓存策略会有更多选项,比如RUN --mount=type=cache可以缓存包管理器下载的临时文件,让镜像内层缓存更高效。
4.4 容器内权限不足或数据目录无法写入
现象:启动 MySQL 或者其他服务时,日志里出现Permission denied,或者挂载目录里文件自动变成 root 所有,程序运行报错。
原因:镜像内的服务进程以特定用户运行,而宿主机挂载进来的目录权限和该用户不匹配。Docker 的-v挂载不会自动调整权限,特别是绑定挂载时最容易遇到。
解决办法:
- 指定容器以宿主机当前用户运行:
docker run --user $(id -u):$(id -g) ...。 - 调整宿主机目录属主:
chown -R 1000:1000 ./data。 - 在镜像里预创建目录并调整权限。
RUN mkdir -p /data && chown -R node:node /data USER node4.5 应用日志无限增长,磁盘被打满
现象:运行一段时间后,系统磁盘空间告警,定位发现是容器日志文件占用大量空间。
原因:容器内的标准输出默认会被 Docker 记录到本地的 json 文件里,如果应用日志比较多,且没有设置轮转策略,文件会一直膨胀。
解决办法:在 Docker 守护进程配置里统一设置日志轮转,或者在 compose 文件里设置:
services: app: logging: driver: json-file options: max-size: "10m" max-file: "5"这样单个日志文件超过 10MB 后会自动切割,最多保留 5 份,不会再无限占盘。
常见问题可以先记成这样一张速查表:
| 问题 | 可能原因 | 快速排查 | 推荐处理 |
|---|---|---|---|
| 端口访问失败 | 端口冲突、程序绑定地址不对 | docker ps、docker port | 换端口、绑定0.0.0.0 |
| 数据丢失 | 未挂载数据卷 | docker inspect看 Mounts | 挂载命名卷 |
| 构建慢 | 依赖层缓存失效 | 观察构建日志每层耗时 | 调整 COPY 顺序 |
| 权限不足 | 挂载目录属主不匹配 | ls -l查看宿主机目录属性 | --user参数或调整目录权限 |
| 磁盘暴涨 | 日志无轮转策略 | du -h /var/lib/docker/containers/*/*.log | 配置 log rotation |
5. 除了踩坑,日常使用中值得坚持的一些习惯
5.1 基础镜像选择要克制
选基础镜像时,优先考虑 Alpine 版本或官方提供的 slim 版本,体积小、攻击面小。但也要注意,Alpine 使用 musl 而不是 glibc,有些二进制依赖可能不兼容。如果遇到诡异的运行时错误,可以先怀疑基础镜像换回debian:stable-slim试试。
固定镜像标签也很重要。FROM node:20这个写法虽然方便,但node:20标签会随着时间更新,今天构建和三个月后构建可能拿到不同内容。对于生产发布,建议把标签精确到小版本,比如node:20.14.0-alpine,或者使用镜像摘要(digest)保证完全一致。
5.2 给容器加上健康检查
健康检查不是锦上添花,而是多服务协作时的必要条件。前面 3.3 节的 compose 文件里已经写了healthcheck,它的意义在于让编排工具知道容器是否真正可用,而不是只看进程还在不在。比如 Redis 容器启动了,但可能还在加载持久化数据,此时访问会失败。有了健康检查,依赖它的应用容器就不会过早启动。
在没有 compose 的单容器场景下,也可以直接在docker run参数里加:
docker run -d \ --name redis \ --health-cmd="redis-cli ping" \ --health-interval=5s \ --health-retries=5 \ redis:7-alpine5.3 限制容器资源,避免“一个容器吃整台机器”
生产环境一定要给容器设置资源上限。不加限制的话,一个出现内存泄漏的容器可能把宿主机内存耗尽,导致同机器上的其他服务全部受影响。
docker run -d --name app \ --memory=512m \ --cpus=0.5 \ my-app-image在 compose 里对应的写法是:
services: app: deploy: resources: limits: cpus: "0.5" memory: 512M对于 Java 这类有堆内存配置的程序,还要注意把 JVM 的堆参数设置好,避免容器限制和 JVM 感知的内存不一致。
5.4 把镜像当普通包来管理,不要依赖容器内部修改
我在项目组里一再强调:一切对容器的修改,都应该通过重新构建镜像来解决。进入容器里apt-get install、手动改配置文件、手工复制二进制文件,这些操作虽然在当下能解决问题,但容器一重建就会全部丢失。更关键的是,如果这台容器承载线上流量,手工改动会让机器漂移出标准状态,以后排查问题都无从下手。
如果有临时调试需求,比如在容器里看网络连通性、查端口,可以使用docker exec进入容器,但不要在里面做持久性修改。调试完直接退出,需要什么能力就在 Dockerfile 里加。
5.5 镜像体积压缩与安全扫描
构建体积太大的时候,除了换小基础镜像,还可以用多阶段构建。以前端项目为例,第一阶段用完整 Node 镜像安装依赖并构建静态文件,第二阶段用 Nginx 镜像只拷贝构建产物,最终镜像里不包含任何源代码和构建工具,既小又安全。
镜像构建完成后,有条件的话可以引入安全扫描,比如用开源的扫描工具检查依赖漏洞。我们平时的习惯是把构建和扫描一起接入 CI 流程,镜像有问题时直接阻断发布,而不是上线后再补救。
这套流程跑顺之后,你会发现部署一个新环境的时间从小时级降到分钟级,交付的东西也变成标准的、可复现的镜像。如果让我只留一个建议,那就是从今天开始,每个项目都坚持写一份合适的 Dckerfile 和 compose 编排,别图省事去手工折腾环境。这个动作节省的时间,会远超你最初学习容器化的成本。