刚开始学Docker的时候,我一度觉得自己会了,因为命令都背得滚瓜烂熟,build、run、ps、exec信手拈来。但真到项目部署的时候才意识到,会敲命令和会容器化完全是两码事。很多人跟我一样卡在这——单机跑一个容器容易,但一涉及多服务协作、镜像怎么构建更小、Compose怎么编排,就乱成一锅粥。这篇总结我不打算写成API文档式的罗列,而是按“想清楚本质—动手写Dockerfile—用Compose把多容器串起来”这条路走一遍,把能落地的经验直接给到各位。
1. 先搞清楚Docker到底解决了什么问题
1.1 “我这跑得好好的,怎么到你那就挂了”
做后端开发的几乎都经历过这种对话。代码在本地运行流畅,一部署到测试环境就是启动失败、链接不上数据库、缺一堆系统依赖。传统虚拟机太重了,动辄几个G的镜像,启动要等分钟级别。Docker的杀招不是“虚拟化”,而是“标准化打包”。
Docker把应用连同它运行所需的整套环境——例如特定版本的运行时、编译链、系统工具库、配置文件——一起塞进一个轻量级的镜像里。这个镜像就是一个自带运行环境的打包容器,术语叫镜像,运行起来之后叫容器。这套设计最关键的地方在于:环境一致性。
1.2 镜像与容器的关系:类和实例
新手最容易混淆的就是镜像和容器。打个比方,镜像是一个“类”,容器是这门类的“实例”;也可以理解成镜像是一张光盘,容器是光驱读出来的游戏会话。镜像可复制、不可变,容器则是有状态的,你可以启动多个、可以停止、可以删除。
用白话去理解镜像分层:Docker镜像不是一个大块头,而是由一层只读文件系统叠加起来的。每一层对应Dockerfile里的一条指令。比如基础镜像是一层,装依赖又是一层,拷贝代码又是一层。这样设计最大的好处是复用——
- 两三百个镜像都基于同一个Ubuntu基础镜像时,底层重复层只在磁盘上存一份
- 构建时命中缓存层,速度能快上好几倍
- 日志、运行数据写在容器可写层,容器删了不心疼,因为镜像部分才是不动的源码
1.3 容器是进程,不是微型虚拟机
这一点极其重要。很多人把容器当成“轻量级虚拟机”,于是习惯性地说“进入容器看看”,这在开发调试时没问题,但设计架构时如果这么想就出偏差了。容器本质是一组相互隔离的进程,受到命名空间(Namespace)和资源限制(CGroup)的约束,共享宿主机内核。它没有自己的内核,没有独立的操作系统启动过程,所以才能做到秒级启动。
了解这层本质之后,很多问题就能自己推断出来:
| 问题 | 原因 | 结论 |
|---|---|---|
| 容器里看不到某个系统服务 | 容器只跑你指定的进程,没有完整OS | 别在容器里期待systemd |
| 内存上限设了之后程序OOM | CGroup限制了资源 | 调优要按容器配额来 |
| 容器里时间不对 | 默认时区不是系统时区 | 在Dockerfile挂载时区配置 |
| 容器日志占满磁盘 | 默认日志驱动无限增长 | 配置max-size和max-file |
2. Docker镜像的获取与管理:先学会当“搬运工”
2.1 镜像来源的完整链路
Docker镜像的来源有三个层级:公共仓库拉取、自己构建、私有仓库分发。日常开发中90%的镜像来自Docker Hub,生产环境则大概率用到私有仓库。
拉取镜像用的命令是docker pull,但它的背后是“分块下载+叠加解压”的过程。之前我遇到过拉取大镜像卡住,后来发现是默认的国外仓库太慢,使用慢速镜像加速器之后,下载速度能提到十几倍。这个配置建议在安装Docker之后第一件事就做完。
2.2 用docker run跑起来一个容器的正确姿势
docker run不是单纯“运行镜像”,它会经历完整生命周期:检查本地镜像是否存在→不存在则拉取→创建容器可写层→分配网络端口映射→执行启动命令。所以你在docker run里看到的每一个参数,都有对应的幕后动作。
一个基础的Nginx命令行看起来像这样:
docker run -d \ --name web_server \ -p 8080:80 \ -v /opt/nginx/html:/usr/share/nginx/html:ro \ --restart=always \ nginx:1.24-alpine-d后台运行。不加的话,终端会挂在前台,Ctrl+C直接杀进程--name给容器起名字,否则系统随机分配一个难记的名字-p 8080:80把宿主机的8080端口映射到容器的80端口。容器之间互相访问走的是内部网络,宿主机外部访问必须通过端口映射-v挂载目录。冒号前面是宿主机路径,后面是容器内路径,ro代表只读--restart=always设置重启策略,Docker服务启动时容器就跟着启动
2.3 镜像太多占磁盘?管理和清理策略
镜像管理的最大痛点不是构建,而是磁盘占用。长时间开发后本机上可能挂着几十个悬空镜像(dangling images)和废弃容器。
判定哪些镜像能删是有技巧的:
docker image ls docker ps -a docker system dfdocker system df能直观显示镜像、容器、本地卷、构建缓存的空间占用,适合日常体检。清理手段从保守到激进各有选择:
docker container prune # 清理停止状态的容器 docker image prune # 清理悬空镜像 docker image prune -a # 清理未被使用的镜像 docker system prune -a --volumes # 全量清理,网络和缓存一并干掉我自己一般只用到container prune和image prune,全量清理容易把正在用的镜像缓存也干没了,反而拖慢下次启动。
3. Dockerfile编写实战:从零构建一个靠谱镜像
3.1 Dockerfile的执行逻辑:不是写“安装手册”,是写“分层指令”
有人把Dockerfile当作“在容器里安装软件的脚本”,这是大误区。Dockerfile更像一份构建说明书,每一条指令都会生成一个新层。构建时Docker从上到下逐条读取,如果有缓存且缓存没失效,就直接复用中间层,跳过后面的重复执行。
每一步缓存的校验依据是:基础镜像是否改变、构建上下文是否有变化、上一条指令的结果是不是还有效。所以指令顺序要设计:把不常变的放前面,把频繁改动的放最后。
FROM python:3.11-slim ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD ["sh", "-c", "python manage.py runserver 0.0.0.0:8000"]你体会一下顺序:先是环境变量、工作目录,再拷贝依赖清单并安装,最后拷贝源码。如果只改动代码,构建时前三步全部命中缓存,只有最后一层重建——几秒钟完成。如果把COPY . .放在前面,任何文件一变,依赖安装全部重来。
3.2 常用指令的语义拆解:RUN、CMD、ENTRYPOINT、COPY、ADD
RUN:在构建阶段执行的命令,结果会被记录为镜像的一层。它最适合做的是系统依赖安装、包管理操作、编译过程。
CMD与ENTRYPOINT:这俩是经典难点。简单区分就是:
| 指令 | 作用 | 是否可被docker run参数覆盖 |
|---|---|---|
| CMD | 容器启动时默认执行命令 | 可被run时传入的命令覆盖 |
| ENTRYPOINT | 容器的主进程入口 | 默认不可覆盖,除非配合--entrypoint |
如果只有CMD,docker run image ls /会直接执行ls而跳过CMD;如果又写了ENTRYPOINT又写了CMD,CMD就成为ENTRYPOINT的参数。合理的配合是把固定入口设为ENTRYPOINT,把可变默认参数放进CMD:
ENTRYPOINT ["python", "app.py"] CMD ["--host=0.0.0.0", "--port=8000"]这样既保证容器启动必然执行的是你的应用,又允许通过docker run image --port=9000覆写参数。
COPY与ADD:两者都能把构建上下文中的文件复制进镜像,区别在于ADD天然支持URL下载和自动解压tar包。现代实践里推荐优先用COPY,ADD那俩附加能力反而容易引入不可预测行为,而且用ADD去COPY远程文件也不利于缓存管理。
3.3 多阶段构建:镜像体积从1.2GB瘦身到180MB的实战套路
很多团队卡在“镜像太大”这个问题上,尤其是编译型语言。解决办法是多阶段构建——一个Dockerfile里写多个FROM,前面的阶段负责编译出产物,后面的阶段只把产物和运行环境装进来。
举一个后端服务的经典例子,编译阶段和运行阶段分离:
FROM golang:1.21 AS builder WORKDIR /build COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o app . FROM alpine:3.18 WORKDIR /app COPY --from=builder /build/app . RUN adduser -D runner USER runner EXPOSE 8080 CMD ["./app"]第一段构建环境里有完整的Go语言工具链,编译出静态二进制;第二段只扔一个极简Alpine基础镜像,再塞入编译产物。多阶段的关键词是AS和COPY --from,它能跨阶段取文件。这种方式不只适用于Go,前端项目(Node构建产物移入Nginx镜像)、Java项目(构建阶段用Maven,运行阶段用JRE)都能用同样套路。
3.4 构建前后要注意的隐藏细节:.dockerignore与镜像可复现性
.dockerignore是Dockerfile的好搭档。它决定哪些文件不会发往构建上下文,直接影响构建体积和缓存命中率。常见需要忽略的包括:
.git node_modules __pycache__ *.log .gitignore Dockerfile .dockerignore.dockerignore漏写的话,本地几G的node_modules目录会把构建上下文直接拖垮。另外还需要注意,某些镜像维护者会在构建脚本里嵌入当前时间、随机数,做出来的镜像每次构建都不同,这类不可复现镜像在排查线上故障时会让你非常痛苦。后文推荐:依赖锁文件必须固定版本号,不使用latest标签。
4. 容器编排核心:多容器协作的Compose实战
4.1 多服务必然出现:单条docker run的极限
当你只需要跑一个MySQL时,docker run还能应付。但一旦是Web应用+Redis+MySQL+消息队列的组合,手动敲五六个docker run,还要处理它们之间的网络互通和启动顺序,这就开始失控了。 Docker Compose就是为这件事而生的——用一份YAML文件声明式描述整个服务栈,然后一行docker compose up -d拉起全部。
Compose文件的核心是顶层模块services,它的底层就是帮你把一组docker run参数翻译成结构化配置。别把Compose当成编制集群的工具,它更精确的名字是“单机编排”,负责的是本机上多容器的协同工作。
4.2 Compose文件的配置项与真实语义
下面写一个带依赖关系、数据持久化、健康检查的项目级Compose文件:
version: "3.8" services: db: image: mysql:8.0 container_name: app_db restart: always environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: app_db MYSQL_USER: app_user MYSQL_PASSWORD: app_pass volumes: - db_data:/var/lib/mysql healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] interval: 5s timeout: 3s retries: 10 web: build: context: . dockerfile: Dockerfile ports: - "8000:8000" depends_on: db: condition: service_healthy environment: DB_HOST: db DB_USER: app_user DB_PASSWORD: app_pass volumes: - ./src:/app/src volumes: db_data:拆开看几个关键的为什么:
depends_on配合condition: service_healthy:很多团队踩过“Web先起来了但连不上数据库”的坑,于是加depends_on: - db。但默认的depends_on只保证容器启动顺序,不保证内部服务就绪。MySQL容器起跑了,但MySQL进程还在初始化中,Web连上去照样报错。健康检查就是解决这个的,等到MySQL真正能接受连接,Web容器才开始启动。不配置健康检查,等于把“就绪”的判断交给了运气。
environment直接写密码:本地开发可以这么干,但端口暴露到公网的服务就不行了。生产环境建议从环境文件读取,或者交给密钥管理工具处理。
4.3 Compose默认网络:服务名即主机名
Compose会自动创建一个自定义网桥网络,所有在同一个Compose文件里的服务都会被接入这个网络。容器之间通过服务名就能互相访问,不需要通过IP——这是编排和单机docker run之间最大的体验差异。
所以你在代码里配置数据库地址时写db而不是127.0.0.1或者某个随机IP。因为每个容器有自己的网络栈,localhost指向的是容器自身。这类问题新手几乎人人都会碰到一次,排查时第一反应应该是去查代码、配置文件里的连接地址用的是不是服务名。
4.4 单文件多环境的变量替换技巧
项目的开发环境、测试环境、预发布环境往往只是端口、密码不同。用env_file或者变量插值可以做到一套Compose文件应对多个环境。
先在项目根目录放.env:
DB_PASSWORD=devpassword WEB_PORT=8000再在Compose里引用:
services: db: image: mysql:8.0 environment: MYSQL_PASSWORD: ${DB_PASSWORD} web: ports: - "${WEB_PORT}:8000"这种做法把可变部分集中到一个文件中,Docker Compose启动时会自动读取同目录下的.env文件做变量替换。不同的部署环境只需准备对应的环境文件,Compose文件本身不用改。
5. 开发、测试、上线三阶段的不同编排策略
5.1 本地开发:追求改代码即时生效
本地跑容器的关键是“热更新”。如果代码都拷进镜像里才运行,每次改一行代码就得重新build,非常痛苦。正确做法是把源码目录挂载进容器,让容器直接使用宿主机上的源码:
services: web: build: . volumes: - ./src:/app/src改完宿主机的代码,服务进程监听到文件变化就自动重载。数据库、中间件都放在容器里跑,但源码不镜像化。这里有个坑必须提醒:挂载目录覆盖镜像内目录后,镜像里原本在/app/src下通过RUN创建的文件会被隐藏掉,如果你依赖构建阶段生成的文件,需要考虑做挂载目录的合并或调整放文件路径。
5.2 测试环境:镜像固化,数据可控
测试环境下,源码不再用挂载方式,而是完整打包进镜像——这样测试组跑出来的结果才与部署到生产环境时一致。镜像一旦构建成功,里面是哪版代码就是哪版代码,人人都能复现。
这个阶段建议顺带准备一个初始化脚本,插入测试数据,把数据库卷独立命名,比如test_db_data,方便用完清理重建。
5.3 生产环境:容器编排需要切换成Docker Swarm或Kubernetes
Compose不是万能的。生产环境如果要考虑滚动更新、无损升级、多节点调度、故障自愈,Compose就力不从心了。但这篇文章既然重点讲单机环境,就不展开集群方案了。需要明确的是:
- Compose适合单机、开发环境、小型CI流程
- Swarm模式兼容Compose语法,但已逐渐被边缘化
- Kubernetes是生产集群的事实标准,但复杂度陡增
前几年我见过不少团队直接用Compose上生产。实例少、访问量低的小项目,用docker compose up -d配合restart策略确实可以顶一阵,但一旦容器多起来,单独更新其中一个服务带来的一系列操作就开始出现混乱。进化的路线可以是:先在Compose上把所有服务、环境变量、卷配置全部跑熟,为日后迁移编排工具积累配置经验。
6. Docker化过程中我踩过的坑与调整思路
6.1 时区问题:镜像默认是UTC,日志时间永远少8小时
容器默认时区不是宿主机的时区,打出来的日志、生成的定时任务执行时间都会比北京时间慢8小时。排查时第一眼看到日志时间不对,千万别以为是代码bug,先检查时区。
解决方式在Dockerfile里加一层:
ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone需要注意基础镜像里不一定有zoneinfo文件,轻量镜像(比如基于distroless的)可能还没有时区数据,需要用tzdata包补足。
6.2 日志无限制增长,最终撑爆磁盘
用容器跑起来的服务如果自己不写日志文件,所有输出都进stdout,由Docker捕获为JSON日志。默认情况下这个日志文件是不会自动清理的,运行半年以后占用几十个G完全可能。
Docker配置里加上log限制:
docker run \ --log-opt max-size=10m \ --log-opt max-file=3 \ nginx:1.24-alpine在Compose文件里同样可以声明:
services: web: logging: driver: json-file options: max-size: "10m" max-file: "3"6.3 容器内进程退出,容器状态变成Exited
容器不是一个持久化进程,PID 1的进程如果退出,容器就立刻停止了。编译型二进制直接运行还好,但脚本语言、GUI程序这类容易遇到一个情况:进程后台fork之后父进程退出,容器跟着退出。
这其实引出一个很核心的实践原则:每个容器只跑一个主进程。如果你需要在一个容器里启动多个服务,用supervisord这类进程管理器作为PID 1,但这会带来额外的信号处理和进程管理复杂度。更推荐的做法是拆分成多个容器,让每个容器职责单一,编排层面去协调它们的关系。
6.4 镜像体积焦虑:用Alpine和slim之前先想清楚
看到Alpine基础镜像体积只有几MB,人人都会心动。但Alpine的C库是musl而非glibc,部分二进制包、Python的特定wheel、某些C扩展编译产物没法直接在Alpine上跑。遇到No such file or directory这种报错时,很可能不是文件缺失,而是动态链接器不兼容。
选择基础镜像的建议:
- 纯静态编译的Go:选择
scratch或alpine,直接把最小值做到底 - Python/Node/Java:优先选择带
-slim标签的官方镜像,体积适中,支持度好 - 需要编译原生依赖的:不必过度追求小体积,用标准镜像避免踩兼容性坑
6.5 容器退出状态码的排查思路
容器异常退出时会有一个退出状态码,它是排查问题的第一线索:
| 状态码 | 含义 | 常见原因 |
|---|---|---|
| 0 | 正常退出 | 周期任务跑完,或被手动停止 |
| 1 | 应用级错误 | 脚本报错、配置错误、启动命令不合法 |
| 127 | 命令未找到 | 镜像里不存库在的程序路径被直接执行 |
| 137 | 被强制杀死 | OOM或手动kill,建议检查内存限制 |
排查时先用docker logs 容器名看日志,再考虑docker inspect看详细状态,不要盲改代码。
7. Docker本地实验环境:别急着上云,先把这台机器玩明白
7.1 在本地机器上搭一套完整的实战环境
与其看一堆教程,不如自己从头搭一个可复现的实验环境。推荐的路径是:
- 安装Docker Engine和Compose插件,确认版本号正确
docker --version docker compose version拿一个简单的Nginx镜像练手,跑起来、停止、删除
自己写一个仅有“hello world”输出的小镜像
给这个镜像加环境变量、命令行参数,区分ENTRYPOINT与CMD
写第一个Compose文件:Web应用+Redis+MySQL全套,打通服务间调用
这一步走通之后,你就能应付绝大多数分布式开发场景了。
7.2 检视自己是否真正掌握的三个题目
熟悉不等于掌握,拿下面几道题对照一下:
- 题目一:构建镜像时,为什么把
COPY requirements.txt放在紧随FROM之后、COPY . .放在最后?该题考察“分层与缓存”。 - 题目二:Compose启动后MySQL的密码设在哪?Web应用又如何读取?该题考察“环境传递”。
- 题目三:同一台机器上跑了两个项目,各有一个MySQL容器,端口都是3306,如何处理不冲突?该题考察“端口映射与自定义网络”。
如果这三个问题都能不看任何资料就回答出来,那你Docker的入门关就算真正过了。
7.3 我的一点心得:学会看文档和看源码,但别急着背命令
很多初学者收藏了一堆命令清单,真到用的时候还是要翻文档。这不是记忆力问题,而是Docker的命令设计遵从“名词—动词”结构,docker 对象 动作 参数,理解了对象体系(镜像、容器、网络、卷、编排服务)之后,大部分命令都能举一反三。
排查问题的时候,优先用docker inspect查看完整元数据,它能显示网络IP、挂载源、环境变量、重启次数、健康状态,这种“白盒视角”比一个个猜测要可靠得多。我也慢慢总结出经验:Docker的难点从来不在命令本身,而在对隔离、分层、进程模型的理解。理解到位了,一切命令都是顺理成章的操作。
最后再分享一个实用技巧:本机做实验时经常会创建大量容器,结束后随手清理会省下不少磁盘和心智负担。我习惯把所有涉及容器的操作在Compose文件里组织好,上游一份docker compose down -v把卷一并清掉。这样来回折腾多次,环境还是停留在刚搭好的干净状态,比手动删来删去高效得多。