ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Docker镜像与容器:从概念到实践,彻底搞懂容器化交付

Docker镜像与容器:从概念到实践,彻底搞懂容器化交付

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 在背后依次做了这些事:

  1. 检查本地有没有nginx:1.25这个镜像。没有的话,去配置的镜像仓库拉取。注意:1.25是标签(Tag),指定了版本;如果不写标签,默认是latest,这在生产环境是大忌,后面会专门讲。
  2. 在镜像的只读层之上,创建一个可写层,形成一个全新的容器文件系统。
  3. 创建一对虚拟网络接口,把容器接入默认的桥接网络,并按照-p 8080:80做端口映射,把宿主机的 8080 端口转发到容器的 80 端口。
  4. 设置隔离的命名空间,让容器拥有独立的进程号、网络、挂载点视图。
  5. 执行镜像里预设的启动命令(nginx 镜像里预设的是启动 nginx 前台进程),容器进入运行状态。

-d表示后台运行(detach),--name给容器起个固定名字方便后续操作,-p是端口映射。这几个参数是日常使用频率最高的,先把它们用熟。

跑起来之后,浏览器访问宿主机的 8080 端口,应该能看到 nginx 的欢迎页。这时候你敲docker ps,能看到这个容器的 ID、镜像、状态、端口映射;敲docker logs my-web,能看到 nginx 的访问日志。这两个命令是你日后排查问题的左膀右臂。

3.2 进容器里看看,验证“隔离”不是玄学

光看欢迎页还不够,得进容器内部感受一下隔离。执行:

docker exec -it my-web /bin/bash

exec是在一个正在运行的容器里再启动一个进程,-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、看编排工具,都是水到渠成的事。真正值得反复琢磨的不是命令本身,而是“为什么要把环境打包”“为什么数据不能放容器里”这些设计背后的取舍,想通了这些,工具换了、平台变了,思路依然管用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进