Docker 这个词这几年在运维和开发圈子里出现的频率太高了,高到很多刚入行的朋友一上来就被“容器”“镜像”“Dockerfile”“编排”这一串名词砸得有点懵。我见过不少团队新人,装完 Docker 之后照着教程敲docker run hello-world,屏幕上打印出一段欢迎语,然后呢?然后就没有然后了——他并不知道刚才那一瞬间到底发生了什么,那个“hello-world”是从哪冒出来的,跑完之后又去了哪里。这篇内容就是想把这件事讲透:容器和镜像到底是什么关系,它们各自解决什么问题,以及为什么今天几乎所有的后端交付都绕不开这套东西。不管你是刚接触 Linux 服务端开发的新手,还是写了几年代码但一直没搞明白“为什么我的程序在我机器上能跑、到服务器上就崩”的老兵,下面这些内容都值得你花时间读完。我会尽量用生活里能摸得着的东西做类比,同时把真正动手时会遇到的细节和坑一并交代清楚,让你看完就能在自己的机器上把整套流程跑一遍。
1. 先把“镜像”和“容器”这两个词从抽象拉回现实
很多人学 Docker 卡在第一步,不是因为命令不会敲,而是因为脑子里对这两个概念没有建立起一个稳定的画面。概念不清,后面所有的操作都变成了死记硬背,换个场景就不会用了。所以这一节我们不急着敲命令,先把这两个东西的本质说清楚。
1.1 镜像是一份“只读的安装蓝图”,不是压缩包
你可以把**镜像(Image)**理解成一份极其完整的、只读的“系统安装蓝图”。注意“只读”这两个字,它是理解一切的关键。这份蓝图里打包了什么呢?通常包括一个精简的操作系统用户空间(比如一个极简的 Debian 或 Alpine)、你的应用程序本身、程序运行所需要的运行时环境(比如 Python 解释器、Node 运行时、JRE),以及一些环境变量和启动配置。
打个比方,镜像就像一张装机光盘或者一份蛋糕配方加所有原料的预拌粉。它本身是静态的、不会变的,你把它放在那里放一年,它还是那个样子。它不会“运行”,也不会消耗 CPU,它只是躺在磁盘上的一堆文件层。
这里有个特别容易被误解的点:很多人以为镜像就是一个.tar压缩包,解压出来就是一堆文件。方向对了一半,但 Docker 镜像的组织方式比普通压缩包讲究得多。它是由一层一层的文件系统层(Layer)叠加起来的,每一层只记录相对于上一层的“变化”。比如第一层是基础操作系统,第二层装了 Python,第三层拷贝了你的代码,第四层设置了启动命令。这种分层设计带来的直接好处是复用和缓存:如果你本地已经有那个基础操作系统的层了,再拉取一个基于它的新镜像时,这一层根本不用重新下载。
用docker image ls能看到本地所有镜像,用docker history <镜像名>能看到一个镜像到底由哪些层构成、每层多大。这个命令我强烈建议你在刚开始学的时候多敲几次,它能把“分层”这个抽象概念直接摊在你面前。
1.2 容器是镜像“跑起来”之后的那个活体
**容器(Container)**则是镜像被“启动”之后产生的东西。还是用上面的类比:镜像是装机光盘,容器就是用这张光盘装好并正在运行的那台电脑;镜像是蛋糕配方加预拌粉,容器就是按配方烤出来、正摆在桌上的那个蛋糕。
从技术上说,容器是镜像之上加了一个可写的薄薄一层(可写层,Writable Layer),再加上一圈由操作系统内核提供的隔离机制(命名空间 Namespace 负责“看不见别人”,控制组 Cgroup 负责“用不超量”)。你的程序在容器里跑,它以为自己拥有一整套独立的文件系统、独立的进程空间、独立的网络接口,但实际上它和宿主机上其他容器共享同一个内核。
这个“共享内核”是容器和虚拟机最本质的区别,也是容器轻量的根源。虚拟机要模拟一整套硬件再装一个完整的操作系统,启动动辄几十秒、占几个 G 内存;容器只是在内核上划出一块隔离区域,启动常常是几百毫秒到一两秒,内存开销就是你的程序本身那么大。所以同一台机器上跑十几个虚拟机可能就喘了,跑几十上百个容器却很常见。
一个镜像可以启动出无数个容器,就像一张光盘可以装无数台电脑、一份配方可以烤无数个蛋糕。而且这些容器彼此隔离,一个容器里删文件、改配置,完全不影响镜像本身,也不影响从同一个镜像启动的其他容器。这就是为什么容器特别适合做“一次构建、到处运行”的交付。
1.3 一张表把两者的边界钉死
概念讲完,我用一张表把镜像和容器的关键差异列清楚,这张表建议你存下来,面试和实际排查问题时都用得上:
| 对比维度 | 镜像(Image) | 容器(Container) |
|---|---|---|
| 本质 | 只读的多层文件系统模板 | 镜像 + 可写层 + 隔离的运行环境 |
| 是否运行 | 不运行,静态存放 | 正在运行(或已停止但保留状态) |
| 能否修改 | 构建后不可改,改则生成新镜像 | 可随意读写,改动只在可写层 |
| 数量关系 | 一个镜像 | 可派生任意多个容器 |
| 存储占用 | 各层共享,占用相对固定 | 额外占用可写层的变化量 |
| 生命周期 | 长期保存 | 可创建、启动、停止、删除 |
| 类比 | 装机光盘 / 蛋糕配方 | 装好的电脑 / 烤好的蛋糕 |
把这张表吃透,后面所有命令你都能对上号:docker build是在造蓝图,docker run是拿蓝图开工,docker ps是看哪些“活体”在跑,docker rm是销毁活体,docker rmi是销毁蓝图。
2. 为什么“在我机器上能跑”这个老问题,容器能治
理解了概念,接下来要回答一个更根本的问题:这东西到底解决了什么痛点?如果只是“又一个部署工具”,它不至于火成这样。答案藏在软件交付最古老的那个矛盾里。
2.1 环境差异才是交付事故的头号元凶
做过线上部署的人都懂那种痛:本地开发环境跑得好好的服务,一上测试环境就报“找不到某个动态库”,上了生产环境又变成“版本不兼容”。排查半天发现,开发机上是 Python 3.9,服务器上是 3.6;本地装了个系统级的图像处理库,服务器上压根没装;甚至时区、字符集、文件路径分隔符都能成为事故点。
这类问题的本质是:你的程序从来不是孤立运行的,它依赖一整套环境。而这套环境在过去是靠“文档 + 人工配置”来复现的,文档会过时,人工会漏步骤,不同人的机器状态千差万别。于是“在我机器上能跑”成了一句行业黑话,背后是无数个加班夜。
容器的思路非常直接:既然环境差异是问题,那就把程序和它依赖的整个环境一起打包。镜像里不仅有你的代码,还有那个确定版本的解释器、那些确定版本的库、那份确定的系统配置。到了任何一台装了容器运行时的机器上,解包、启动,环境和你打包时一模一样。这就把“配置环境”这件不确定的事,变成了“搬运一个文件”这件确定的事。
2.2 隔离带来的额外红利:依赖不再打架
除了环境一致性,容器还顺手解决了另一个长期困扰运维的问题:同一台机器上多个服务的依赖冲突。
传统部署方式下,如果服务 A 需要某个库的 1.0 版本,服务 B 需要同一个库的 2.0 版本,而这两个版本又不兼容,那它们就很难和平共处在一台机器上,只能分机器部署,成本立刻上去。容器因为自带隔离的文件系统,A 容器里装 1.0、B 容器里装 2.0,互不干扰,同一台宿主机上跑得稳稳当当。
这个红利在微服务架构下被放大得尤其明显。一个稍大的系统拆成十几个服务,每个服务语言不同、依赖不同、版本节奏不同,用容器来隔离是最省心的方案。这也是为什么容器技术和微服务几乎是绑在一起被提起的。
2.3 资源利用率和启动速度,是它干掉传统虚拟机的硬实力
再往深一层说,容器之所以能取代很多场景下的虚拟机,靠的是两个硬指标:启动快和吃得少。
启动快是因为它不用模拟硬件、不用引导操作系统,本质上只是启动一个被隔离的进程,所以常常一秒内就绪。这个特性直接支撑了弹性伸缩:流量高峰来了,几秒钟拉起几十个容器实例扛住,高峰过去再快速回收,成本控制得非常精细。虚拟机做同样的事,光是启动时间就够呛。
吃得少是因为共享内核,省掉了每个实例一整套操作系统的内存和磁盘开销。一台 16G 内存的机器,跑虚拟机可能三四个就紧张了,跑容器跑十几个中等负载的服务是常态。
注意:共享内核也意味着隔离强度不如虚拟机。容器里的进程和宿主机共享内核,理论上内核漏洞可能被利用来逃逸。所以对隔离要求极高的多租户场景,业界通常会在容器外再套一层轻量虚拟机来加固。这一点初学者知道即可,日常单团队内部部署不必过度担心,但心里要有这根弦。
3. 从零把第一个容器跑起来:每一步都在发生什么
概念和动机都清楚了,现在动手。这一节我会把一条最基础的命令拆开,告诉你每个部分在干什么,而不是让你无脑复制。装 Docker 的过程各平台差异较大,桌面版直接官网下载安装包一路下一步即可,Linux 服务器上一般用包管理器安装,这里不展开安装细节,重点放在“跑起来之后发生了什么”。
3.1 一条 docker run 背后藏着五个动作
假设你敲下这么一条命令:
docker run -d --name my-web -p 8080:80 nginx:1.25看起来简单,实际上 Docker 在背后依次做了这些事:
- 检查本地有没有
nginx:1.25这个镜像。没有的话,去配置的镜像仓库拉取。注意:1.25是标签(Tag),指定了版本;如果不写标签,默认是latest,这在生产环境是大忌,后面会专门讲。 - 在镜像的只读层之上,创建一个可写层,形成一个全新的容器文件系统。
- 创建一对虚拟网络接口,把容器接入默认的桥接网络,并按照
-p 8080:80做端口映射,把宿主机的 8080 端口转发到容器的 80 端口。 - 设置隔离的命名空间,让容器拥有独立的进程号、网络、挂载点视图。
- 执行镜像里预设的启动命令(nginx 镜像里预设的是启动 nginx 前台进程),容器进入运行状态。
-d表示后台运行(detach),--name给容器起个固定名字方便后续操作,-p是端口映射。这几个参数是日常使用频率最高的,先把它们用熟。
跑起来之后,浏览器访问宿主机的 8080 端口,应该能看到 nginx 的欢迎页。这时候你敲docker ps,能看到这个容器的 ID、镜像、状态、端口映射;敲docker logs my-web,能看到 nginx 的访问日志。这两个命令是你日后排查问题的左膀右臂。
3.2 进容器里看看,验证“隔离”不是玄学
光看欢迎页还不够,得进容器内部感受一下隔离。执行:
docker exec -it my-web /bin/bashexec是在一个正在运行的容器里再启动一个进程,-it是分配交互式终端。进去之后你会发现,自己仿佛置身于一个独立的 Linux 系统:ps aux只能看到容器里的进程,看不到宿主机上跑的其他东西;根目录的文件结构是镜像里那一套,和宿主机完全不同。
这时候做个小实验:在容器里touch /tmp/test.txt,然后退出容器(exit),再docker stop my-web把它停掉,最后docker start my-web重新启动,再进去看,那个文件还在——因为停止再启动用的是同一个容器,可写层保留着。但如果你docker rm my-web把容器删了,再重新docker run一个,文件就没了,因为新容器是从镜像重新派生出来的,可写层是全新的。
这个实验把“镜像只读、容器可写、删容器丢数据”这条铁律演示得明明白白。这也是新手最容易踩的坑:把重要数据写在容器内部。容器天生是“用完即弃”的,任何需要持久化的东西都必须放到容器外面去,下一节专门讲这个。
3.3 停止、删除、清理,把生命周期走完整
一个容器的完整生命周期是:创建(create)→ 启动(start)→ 运行(running)→ 暂停/停止(stop)→ 删除(rm)。日常最常用的组合是:
docker stop my-web # 优雅停止,给进程发 SIGTERM,超时再 SIGKILL docker rm my-web # 删除已停止的容器 docker rm -f my-web # 强制删除运行中的容器(慎用)镜像的清理则是docker rmi nginx:1.25。这里有个常见现象:你删镜像时提示“image is being used by stopped container”,意思是还有容器(哪怕是停止状态的)引用着它。得先把相关容器删干净,镜像才能删。这个设计其实很合理——防止你误删了还在被使用的蓝图。
还有一个特别实用的清理命令:docker system prune,能一次性清掉所有停止的容器、悬空镜像、无用网络。磁盘吃紧的时候它是救星,但执行前务必确认没有还需要保留的停止容器,因为它删起来是不留情的。
4. 数据持久化和端口网络:容器真正落地时的两道坎
把 hello-world 和 nginx 跑通只是热身。真拿容器跑业务,立刻会撞上两个问题:数据往哪存、外面怎么访问。这两块没搞明白,容器就只能当玩具。
4.1 容器一删数据就没,卷(Volume)是正解
前面实验已经证明了,容器可写层里的数据随容器删除而消失。那数据库这种必须持久化的东西怎么办?答案是卷(Volume)和绑定挂载(Bind Mount),本质都是把宿主机上的一个目录“挂”进容器里,容器读写这个目录时,实际读写的是宿主机上的位置,与容器生命周期解耦。
两种方式的选择有个简单原则:
| 方式 | 宿主机路径 | 适用场景 | 管理方式 |
|---|---|---|---|
| 卷 Volume | 由 Docker 管理(通常在/var/lib/docker/volumes) | 数据库数据、需要备份迁移的持久数据 | docker volume系列命令 |
| 绑定挂载 Bind Mount | 你指定的任意宿主机路径 | 开发时挂代码、挂配置文件 | 直接操作宿主机文件 |
举个例子,跑一个需要持久化的数据库服务:
docker run -d --name my-db \ -v my-db-data:/var/lib/mysql \ -e MYSQL_ROOT_PASSWORD=change-me \ mysql:8.0这里的-v my-db-data:/var/lib/mysql就是创建(或复用)一个名为my-db-data的卷,挂到容器内的数据目录。之后无论你怎么删容器、重建容器,只要还挂这个卷,数据就一直在。用docker volume ls能看到所有卷,docker volume inspect my-db-data能看到它在宿主机上的真实路径。
开发场景更常用绑定挂载,比如把本地代码目录挂进容器,改代码即时生效,不用重新构建镜像:
docker run -d -p 3000:3000 -v $(pwd):/app my-node-app提示:绑定挂载的路径一定要写绝对路径(
$(pwd)就是干这个的),相对路径在不同环境下解析结果可能出乎意料。另外挂载会覆盖容器内该路径原有的内容,挂之前想清楚容器里那个目录原本有没有东西。
4.2 端口映射和容器间通信,别只会 -p
-p 8080:80这种端口映射解决的是“从宿主机外部访问容器”的问题,格式是宿主机端口:容器端口。有几个细节值得说清楚:
- 只写一个端口(如
-p 8080)时,Docker 会随机分配一个宿主机端口,用docker port <容器名>查实际映射。 - 绑定到指定网卡可以写成
-p 127.0.0.1:8080:80,这样只有本机能访问,对外不暴露,安全性更好。 - 容器之间通信不应该走宿主机端口映射,而应该走 Docker 的自定义网络。默认桥接网络下容器之间只能用 IP 互访,IP 会变,很不可靠。正确做法是创建一个自定义网络,容器加入后可以用容器名当主机名互相访问:
docker network create my-net docker run -d --name api --network my-net my-api docker run -d --name web --network my-net my-web # 此时 web 容器里可以直接用 http://api:端口 访问 api 容器这个“用容器名做服务发现”的能力是自定义网络相对默认桥接网络最大的优势,也是后面学编排工具的基础。我见过不少人在默认网络里用 IP 硬编码,容器一重启 IP 变了,服务就挂,排查半天才发现是这个原因。
5. 镜像从哪来:拉取、构建与标签的那些门道
会用现成镜像是第一步,能造自己的镜像才是真正把 Docker 用起来。这一节讲镜像的来源和构建,重点放在那些文档里一笔带过、实际却天天坑人的细节上。
5.1 拉取镜像时,标签(Tag)的选择直接关系到稳定性
docker pull从镜像仓库拉取镜像,语法是docker pull 仓库地址/命名空间/镜像名:标签。大部分公开镜像可以省略仓库地址。关键在于标签。
前面提过,不写标签默认latest。latest听起来像“最新最好”,实际上它只是一个普通标签,谁都可以往上面推,内容随时可能变。你今天拉的latest和明天拉的latest可能完全是两个东西。生产环境用latest等于把“环境一致性”这个容器的最大卖点亲手扔掉了。
正确做法是锁定明确版本,比如nginx:1.25.3、mysql:8.0.35,甚至更严格地用镜像摘要(Digest)锁定到不可变的内容哈希。版本号至少能保证你大概知道里面是什么,出问题能回滚到上一个确定版本。
另外,拉取慢是常见问题,可以配置镜像加速地址(各云厂商都提供),配置在 Docker 的守护进程配置文件里,改完重启服务生效。这个属于环境配置,按你所用平台的官方说明操作即可。
5.2 Dockerfile 的本质:把“装环境”的步骤写成可复现的脚本
自己构建镜像靠的是Dockerfile,一个纯文本文件,里面按顺序写着构建步骤。它的价值在于把过去靠人肉记忆和文档的“装环境”过程,变成了可版本控制、可复现、可审查的代码。
一个典型的 Node 应用 Dockerfile 长这样:
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . EXPOSE 3000 CMD ["node", "server.js"]逐行解释一下,每一行都有讲究:
FROM指定基础镜像,alpine版本体积小(几十 MB),但用的是 musl libc,某些依赖 glibc 的二进制包可能跑不起来,选之前要确认你的依赖兼容。WORKDIR设置后续命令的工作目录,不存在会自动创建。- 先 COPY 依赖清单、再 RUN 安装、最后 COPY 全部代码,这个顺序是刻意安排的。因为 Docker 构建有层缓存,只要
package.json没变,npm ci这一层就直接复用缓存,改业务代码时不用重新装依赖,构建速度天差地别。这是构建优化里性价比最高的一条,务必养成习惯。 npm ci而不是npm install,前者严格按 lock 文件安装,结果确定,适合构建环境。CMD是容器启动时的默认命令,用数组形式(exec form)能让进程直接作为 1 号进程接收信号,优雅停止才有效。用 shell 形式(CMD node server.js)会多套一层 shell,信号可能传不到,停止时容易超时被强杀。
构建命令是docker build -t my-node-app:1.0.0 .,-t打标签,最后的.是构建上下文目录。这里有个坑:构建上下文会把该目录下所有文件发给 Docker 守护进程,如果目录里有node_modules、.git这种大块头,构建会慢得离谱。解决办法是写一个.dockerignore文件,把不需要的排除掉,作用和.gitignore类似。
5.3 多阶段构建:让最终镜像瘦下来的实用技巧
前端项目、编译型语言项目有个共同点:构建时需要一大堆工具链,运行时却只需要产物。如果把这些工具链都留在最终镜像里,镜像动辄上 G。多阶段构建就是为此而生:
# 构建阶段 FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # 运行阶段 FROM nginx:1.25-alpine COPY --from=builder /app/dist /usr/share/nginx/html最终镜像只包含 nginx 和构建产物,构建工具链全部丢弃,体积能从几百 MB 降到几十 MB。镜像小带来的好处是实打实的:拉取快、启动快、攻击面小、存储省。我经手的项目里,光是加上多阶段构建这一项,镜像体积普遍能砍掉七成以上。
6. 新手最容易栽的几个坑,以及我踩过之后的处理方式
理论和操作都过了一遍,最后这部分是我觉得最有价值的内容——那些文档不会重点写、但实际一用就撞上的问题。每一条背后都是真实浪费过的时间。
6.1 容器一启动就退出,先看日志再看退出码
新手最常见的困惑:docker run -d之后docker ps看不到容器,docker ps -a才发现状态是Exited (0)或Exited (1)。容器不是“坏了”,而是它里面的主进程执行完退出了,容器就跟着结束。容器的生命周期绑定的是 1 号进程,1 号进程一死,容器就停。
排查顺序很固定:先docker logs <容器名>看输出,再看docker inspect <容器名>里的State.ExitCode和State.Error。常见原因有几类:启动命令写错(比如路径不对)、配置文件缺失、依赖的服务还没就绪、前台进程被写成了后台运行(比如 nginx 没加-g 'daemon off;',启动后自己 daemon 化了,前台没进程,容器立刻退出)。
提示:容器里的主进程必须是前台运行的。任何会自己转到后台的服务,都要配置成前台模式,否则容器会误以为任务完成而退出。这是容器和传统服务器运维思维最大的差异之一。
6.2 时区和字符集,两个不起眼却高频的坑
容器默认时区常常是 UTC,日志时间和你本地差八小时,排查问题时对时间线对到怀疑人生。解决办法是在运行或构建时设置环境变量TZ=Asia/Shanghai,或者在镜像里装好时区数据。字符集同理,某些精简基础镜像默认没有中文 locale,处理中文文件名或输出时可能乱码,需要在构建时安装对应语言包并设置LANG。
这两个问题单个都不大,但组合起来能让一个简单的日志排查变成玄学。我的习惯是在基础镜像层面就统一处理好时区和字符集,业务镜像直接继承,一劳永逸。
6.3 磁盘悄悄被占满:悬空镜像和日志是元凶
跑了一段时间后df -h发现磁盘告警,一查全是 Docker 占的。两个主要来源:一是悬空镜像(dangling image),即构建过程中产生的、没有标签引用的中间层,docker build频繁的话会越积越多;二是容器日志,Docker 默认把容器标准输出写到宿主机文件里,不做轮转,一个话痨服务几天就能写出几十 G。
处理方式:悬空镜像用docker image prune清理;日志要在守护进程配置里设置日志驱动的大小上限和轮转份数,比如限制单文件 50MB、最多保留 3 个。这个配置属于全局设置,改完对之后新建的容器生效。我见过不止一个团队因为没配日志上限,半夜被磁盘告警叫醒。
6.4 别在容器里跑多个服务,一个容器一个进程
最后一个观念性的坑:有人图省事,把一个容器当成一台小服务器,里面同时跑 Web 服务、数据库、定时任务。这在容器世界里是反模式。原因很实在:容器的生命周期绑定单个主进程,多进程谁当 1 号进程?日志混在一起怎么区分?某个进程挂了怎么单独重启?水平扩展时想只扩 Web 不扩数据库怎么办?全都变得别扭。
正确姿势是一个容器一个关注点,多个服务用多个容器,通过自定义网络互联,需要统一管理时再上编排工具。这个原则一开始可能觉得麻烦,但它换来的是每个组件都能独立构建、独立扩缩、独立升级,长期看省下的心力远超初期多写的几行配置。
把上面这些走一遍,你对“镜像是一份只读蓝图、容器是蓝图跑起来的活体”这件事就不再是背概念,而是有了手感。我个人在实际操作中的体会是,Docker 的学习曲线前陡后平——头两天被各种名词和命令绕得晕,一旦把镜像分层、容器可写层、卷持久化这三件事在脑子里串成一条线,后面再看 Dockerfile、看编排工具,都是水到渠成的事。真正值得反复琢磨的不是命令本身,而是“为什么要把环境打包”“为什么数据不能放容器里”这些设计背后的取舍,想通了这些,工具换了、平台变了,思路依然管用。