ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Docker从入门到实战:镜像、容器、数据卷与网络全解析

Docker从入门到实战:镜像、容器、数据卷与网络全解析

1. 为什么说 Docker 不是你想象中的"虚拟机"

很多人第一次接触 Docker,脑子里冒出来的第一个念头是:这不就是个轻量级虚拟机吗?我当初也是这么想的,直到真正用了半年、踩了无数坑之后才明白,这个类比既对又不对,而搞不清这个"对与不对"的边界,恰恰是后续所有困惑的根源。

Docker 和虚拟机最大的分野在于共享内核这个概念。虚拟机需要在你的物理机上再虚拟出一整套硬件,然后在里面跑一个完整的 Guest OS,这意味着每个虚拟机都自带一个操作系统内核,占用的资源那是实打实的。而 Docker 容器不一样,它直接共享宿主机的操作系统内核,只是通过 Namespace 做隔离、通过 Cgroups 做资源限制,再通过 UnionFS 做文件系统层的叠加。简单来说,容器里跑的那个"系统"并不是完整的操作系统,而只是运行所需的最小文件集合加上运行环境。

我习惯用一个类比来讲这件事:虚拟机是搬了一整个房子到你面前,房子里的厨房、卫生间、卧室全都自带了;容器则是只把你要用的那个房间的门牌号挂在你现有的房子里面,要用到什么家具就搬什么进去。前者是物理搬迁,后者是逻辑隔离。

理解了这一点,你会发现 Docker 的很多"怪异"现象都有了答案。比如说,为什么容器里的进程 PID 经常是 1?为什么在容器里执行 systemctl 会报错?为什么在容器里看到的文件系统和宿主机不一样?全都是因为容器共享了宿主机的内核,但又隔离了进程、挂载点和网络等资源视图。

提示:你在容器里跑 systemctl 不是服务真的坏了,而是 PID 1 已经是容器的入口进程,init 系统那套东西根本不在容器内运行。这就是共享内核的直接后果。

这个核心认知决定了后面所有操作的思路。比如你要排查容器内网络问题,就要知道容器和宿主机共享网络协议栈;你要调优容器性能,就要明白 Cgroups 的限制如何生效。没有这层认知,你只会把 Docker 当成一个"用来跑命令的黑盒子",一出问题就懵。

2. 镜像与容器:一个文件系统快照的两种命运

镜像(Image)和容器(Container)是 Docker 世界里最基础也是最容易混淆的两个概念。我见过不少人把"镜像"当成"容器"在用,也有人把容器里的修改直接当成了镜像的修改,结果一删除容器,所有改动灰飞烟灭。

镜像是一个只读的、静态的文件系统快照,它由一层一层的镜像层(Layer)叠加而成。每一层代表 Dockerfile 中的一条指令产生的变更。容器则是这个镜像的一个可写实例,当你用 docker run 启动一个镜像时,Docker 会在镜像的最上层加一个可写层,这个可写层就是容器运行时的所有写入位置。

可以这样理解:镜像是一张已经刻好的光盘,容器是你拿着这张光盘放进光驱里运行起来的那个会话。光盘本身不会因为你的操作而改变,但你在会话里写的所有文件,都存在光驱的缓存区里,一旦会话结束(容器删除),缓存区也就清空了。

这里就引出了 Docker 底层存储驱动的概念。不同的存储驱动对镜像分层和容器可写层的实现方式不同:

存储驱动特点适用场景
overlay2当前主流默认方案,性能好,支持页面缓存共享绝大多数生产环境
aufs老牌驱动,曾被广泛使用,但未进入上游内核旧版本 Docker 遗留环境
devicemapper基于块设备,配置复杂,性能一般已被 overlay2 取代
vfs不依赖特殊文件系统,但空间占用极大测试或特殊环境

我建议大家在 x86_64 的 Linux 发行版上直接使用 overlay2,这是目前 Docker 官方推荐的默认方案。如果你用的是老版本 Docker,升级时存储驱动的迁移要特别小心,因为不同存储驱动下镜像的层结构不兼容,直接改配置可能会导致镜像丢失。

镜像分层带来的最直接好处是共享与复用。比如你写了十个不同的项目 Dockerfile,它们的基础镜像都是同一个 Ubuntu 版本,那么这十个项目在拉取镜像时,公共的基础层只会在本地存储一份,大大节省了磁盘空间和带宽。这也是为什么两个项目明明用了同一个基础镜像,docker pull 的时候发现"Already exists"的原因。

3. Dockerfile 的编写逻辑:把"构建思维"刻进 DNA

Dockerfile 是镜像的构建脚本,但很多人写 Dockerfile 完全靠抄,不知道每一行背后的代价。我曾经接手过一个项目,Dockerfile 里用一个 RUN 命令装了几百个依赖包,结果镜像体积直奔 2 个 G,构建时间动辄二十分钟。后来花了整整一天优化,镜像压到 350M,构建时间降到三分钟,部署效率完全不是一个量级。

写 Dockerfile 的核心原则只有一条:每一行指令都会生成一个镜像层,层越多,镜像越大,构建越慢。所以你的目标就是用最少的层数,构建出最精简的运行环境。

来看一个典型的优化案例:

# 不够好的写法,每一条 RUN 都会产生一个层 FROM ubuntu:22.04 RUN apt-get update RUN apt-get install -y curl RUN apt-get install -y nginx RUN rm -rf /var/lib/apt/lists/*
# 更好的写法,使用 && 连接命令,减少层数 FROM ubuntu:22.04 RUN apt-get update && \ apt-get install -y curl nginx && \ rm -rf /var/lib/apt/lists/*

第一条 RUN 里的清理命令看起来是把包管理器的缓存删掉了,但如果你分成了多个 RUN,前面 RUN 产生的缓存清理在后面的层里已经"固化"了,清理根本不起作用。这个坑非常隐蔽,我用了一次才发现。

还有一个常见问题是.dockerignore文件。这个文件的作用和.gitignore很像,指定构建上下文里哪些文件不发给 Docker 守护进程。很多人不在意这个文件,结果构建上下文动不动几十兆甚至上百兆,每次构建都卡在 "Sending build context to Docker daemon" 这一行半天。

注意:构建上下文指的是你执行 docker build 时,当前目录里所有文件都会被发送到 Docker 守护进程。即使你的 Dockerfile 里没有 COPY 某些文件,它们依然会被网络传输过去。所以.dockerignore不是可选项,是必需品。

写 Dockerfile 时还要想清楚你的进程到底是什么。容器建议只运行一个前台进程,这个进程最好是 PID 1,因为它会直接收到来自 Docker 的信号(比如 docker stop 时发来的 SIGTERM)。如果你用 shell 脚本再去 exec 另一个程序,信号传递就会出问题,软件可能无法优雅退出。这也是为什么官方文档强调要用 exec 形式的 ENTRYPOINT 而不是 shell 形式。

4. 六条你绕不开的日常命令

说实话,Docker 的命令行工具有一百多个子命令,但你日常真正高频使用的,其实就六条。把这六条命令吃透了,你已经能解决 90% 的日常工作。

4.1 镜像操作:拉取、查看、删除

docker pull nginx:1.25 docker images docker rmi nginx:1.25

docker pull 是最基本的拉取镜像命令,但很少有人注意它的输出内容。如果你仔细看,会发现它是一层层拉取的,每一层都有一个唯一的 digest。这个 digest 就是层内容的哈希值,Docker 用它来做内容寻址。所以两个来自不同仓库的镜像,如果有着完全相同的层,理论上可以共享存储。这个机制在清理镜像时特别有用。

值得提醒的是,docker rmi 删除的是镜像本身,但如果你有容器正在使用这个镜像,删除操作会失败。先停掉容器再删镜像,这是很多人第一天就遇到过的问题。

4.2 容器操作:启动、停止、查看

docker run -d --name web-server -p 8080:80 nginx:1.25 docker ps docker ps -a docker stop web-server docker start web-server docker restart web-server

docker run 的常用参数非常多,但有几个你必须先掌握:-d 代表后台运行,-p 做端口映射,--name 给容器命名,--rm 在容器退出时自动删除容器文件。尤其是 --rm,在临时调试的场景下非常实用,不会留下大量僵尸容器占磁盘。

docker ps 只看运行中的容器,docker ps -a 会连已经停止的容器一起列出来。停止的容器并不会自动删除,它的可写层还占着磁盘。所以定期清理已经不再使用的容器是个好习惯:docker container prune一键搞定。

4.3 进入容器与日志查看

docker exec -it web-server bash docker logs -f web-server

docker exec 是进入容器执行命令的入口。但这里我要多说一句:能不进容器就不进容器。原因在于容器是临时性的,你在容器里手工改了什么东西,容器一删全都没了。正确的做法是改配置后重新构建镜像,或者通过环境变量和挂载配置的方式来管理运行时状态。

docker logs 用于查看容器日志,-f 参数表示持续跟随输出。很多人会踩一个坑:容器里的应用如果直接把日志输出到 stdout,你就能用 docker logs 看到;但如果应用把日志写进了文件,docker logs 是看不到的。此时你需要把日志文件映射到宿主机,或者用日志驱动把日志发送到统一收集系统。

5. 数据卷与持久化:不要让容器删除变成数据灾难

容器天然是"一次性"的,但你的数据不能跟着一次性。所以 Docker 引入了**数据卷(Volume)和绑定挂载(Bind Mount)**来解决持久化问题。

数据卷是 Docker 管理的存储空间,它在宿主机上位于 /var/lib/docker/volumes/ 下面,通过卷名来引用。绑定挂载则是把宿主机的某个目录直接挂到容器里,路径是显式的。两者的区别只在于管理的层级:卷由 Docker 管理,迁移和备份更方便;绑定挂载简单直接,宿主机上的文件容器里直接可见。

我自己的经验是:能用卷就用卷,绑定挂载只用于开发调试场景。理由很简单,卷有一套完善的命令行工具(docker volume create、docker volume ls、docker volume inspect),而且在不同宿主机之间迁移时,卷的内容可以通过 tar 打包带走。绑定挂载则完全依赖宿主机的目录结构,一旦路径变了,容器就起不来了。

实操层面,数据卷的常见用法如下:

docker volume create my-data docker run -d --name app -v my-data:/app/data nginx:1.25

-v 参数把名为 my-data 的卷挂载到容器的 /app/data 目录。容器内对这个目录的写入,实际上会落到卷所指向的宿主机目录中。即使你删除了容器,卷依然存在,数据不会丢。这一点一定要在删除容器之前想清楚。

备份卷的操作用一条 docker run 命令就能完成:

docker run --rm -v my-data:/data -v $(pwd):/backup ubuntu:22.04 tar czf /backup/data-backup.tar.gz -C /data .

解释一下这条命令:启动一个临时的 Ubuntu 容器,把 my-data 卷挂载到 /data,把当前目录挂载到 /backup,然后在容器里执行 tar 打包,把 /data 的内容压缩到 /backup 下面。由于 --rm,这个容器执行完就自动清理了,不会留下垃圾。

注意:多个容器可以共享同一个数据卷。但如果你让多个容器同时写同一个卷,要考虑并发写冲突的问题。数据库这类对文件锁要求高的应用,不建议多容器共享卷。

6. Docker 网络模式的本质:从桥接到宿主机

Docker 的网络模型可能是新手最容易一头雾水的地方,但它在实际部署里又非常重要。Docker 默认提供了几种网络模式,理解它们的工作方式,会让你排查网络问题时游刃有余。

bridge 模式是默认网络模式。Docker 会创建一个虚拟网桥(默认叫 docker0),宿主机上每个容器都会分到一个虚拟网卡,容器之间可以通过这个网桥互相通信。当你使用 -p 映射端口时,请求从宿主机到达 docker0,再转给容器内的进程。

host 模式下,容器直接共享宿主机的网络命名空间,不再有独立的 IP 地址和端口系统。此时你不需要做端口映射,容器里的应用可以直接监听宿主机的端口。这种模式的好处是网络性能最好,因为少了一层 NAT;缺点是容器之间没法用隔离的端口空间,端口冲突了就是冲突了。

none 模式就是完全禁用网络,容器只有 loopback 接口。这种模式极少用,但在做离线计算或安全隔离时能派上用场。

还有一个非常实用的网络特性是自定义网络。你可以创建自己的 bridge 网络,并在启动容器时指定加入:

docker network create my-net docker run -d --name app1 --network my-net nginx:1.25 docker run -d --name app2 --network my-net nginx:1.25

在同一个自定义网络里,容器之间可以直接用容器名互相访问,而 Docker 会自动做 DNS 解析。这意味着你的应用代码里不需要写死对方的 IP 地址,只需用服务名。这在微服务架构里太关键了,因为容器的 IP 是动态的,IP 一变代码里的配置就全废了。

排查网络问题时,我最常用的命令是 docker network inspect。它会列出这个网络里所有容器的 IP、网关、DNS 配置等。如果发现容器间网络不通,第一步就是用这个命令确认它们是否在同一个网络里,大多数情况下答案都是"不在"。

7. 实战:从 Dockerfile 到容器编排的最小方案

前面讲了一堆概念,光说不练假把式。我拿一个最常见的场景——部署一个 Nginx 反向代理加一个静态网站——来完整走一遍流程。这个例子简单,但链条上每一个环节都用到了前面讲的知识点。

项目目录结构如下:

project/ ├── Dockerfile ├── .dockerignore ├── nginx.conf └── src/ └── index.html

Dockerfile 内容:

FROM nginx:1.25-alpine COPY nginx.conf /etc/nginx/conf.d/default.conf COPY src/ /usr/share/nginx/html EXPOSE 80

注意这里我选了 alpine 版本。同一个 nginx 镜像,alpine 版本比普通版本小了将近一半。之所以不写多个 RUN,是因为这里根本不需要运行多条命令,COPY 指令本身就是复制文件,层的数量已经最少。

.dockerignore 内容:

.git *.md Dockerfile .dockerignore

nginx.conf 内容:

server { listen 80; server_name localhost; root /usr/share/nginx/html; index index.html; }

构建命令:

docker build -t my-web:v1 .

构建完成后,启动容器:

docker run -d --name web -p 8080:80 my-web:v1

这时候你访问 http://localhost:8080 ,看到的应该是 index.html 的内容。整个流程不到一分钟,但如果中间出了问题,你多半需要检查的是 Dockerfile 的层是否包含了你期望的文件,或者是端口映射是否被其他进程占用了。

停掉并清理:

docker stop web docker rm web

如果你觉得容器多了管理起来麻烦,可以用 docker-compose 把服务定义写进 YAML 文件里。一个最简单的 docker-compose.yml 长这样:

version: "3.9" services: web: build: . ports: - "8080:80" restart: unless-stopped

docker-compose up -d 就能启动,docker-compose down 就能停止并清理。对于单机部署的小项目来说,docker-compose 完全够用,不需要急着上 K8s。

8. 镜像大小优化:从 1.2G 到 300M 的实战记录

镜像大小优化这件事,重要性怎么强调都不为过。镜像越大,拉取时间越长,部署越慢,存储占用越高,而且安全面也越大——每个多余的包都是潜在的漏洞入口。我经历过一次非常典型的优化过程,在这里把思路拆给大家。

8.1 多阶段构建

多阶段构建的核心思想是,在一个 Dockerfile 里用多个 FROM 指令,每个 FROM 是一个独立的构建阶段。你可以在第一个阶段里安装所有构建工具、编译代码,然后在第二个阶段里只拷贝编译产物,基础镜像换成更小的运行时镜像。

# 构建阶段 FROM golang:1.21 AS builder WORKDIR /app COPY . . RUN CGO_ENABLED=0 go build -o myapp # 运行阶段 FROM alpine:3.18 WORKDIR /app COPY --from=builder /app/myapp . CMD ["./myapp"]

这种写法的好处是:构建阶段里所有临时文件和依赖库都不会进入最终镜像。最终镜像只有一个编译好的二进制文件和一个极简运行时环境。我的一个 Go 项目,从 1.2G 直接降到了 180M。

8.2 用最小基础镜像

alpine 是很多人的第一选择,它基于 musl libc 和 BusyBox,体积非常小。但要注意,如果你的代码需要编译原生模块(比如某些 Python 包),在 alpine 上装编译依赖反而会更麻烦。

更激进的做法是使用 scratch 基础镜像,也就是完全没有文件系统的镜像。你的二进制必须静态编译,且不依赖任何系统库。以 Go 项目为例,CGO_ENABLED=0 编译出来的纯静态二进制是可以在 scratch 上跑的。

8.3 清理不必要的文件

apt 安装完包之后要删掉 apt 缓存和临时文件。但就像前面说的,一定要和安装命令放在同一条 RUN 里,否则清理对最终镜像不生效。

RUN apt-get update && \ apt-get install -y --no-install-recommends curl ca-certificates && \ rm -rf /var/lib/apt/lists/*

--no-install-recommends 这个参数也很关键,它告诉 apt 不要安装那些"推荐"但实际上用不到的包,能省不少空间。

8.4 镜像层级合并

如果你在 Dockerfile 里写了很多 COPY 指令,可以考虑先用 tar 打包,然后一次 COPY 进去再解压。这个技巧在文件特别多的小文件场景下效果显著,因为 Docker 对分层存储中有大量小文件的情况处理得并不高效。

9. 现场日记:三小时处理一个隐蔽的容器内 DNS 故障

我不打算在这篇文章里只讲理论,讲一个我真实遇到的故障排查过程,把前面讲到的所有知识点串起来。

现象是这样的:某内网环境里,一个 Java 服务在容器里部署后,启动时报无法解析外网域名。宿主机上 curl 外网域名完全正常,说明网络链路没问题,问题出在容器内部。

第一步,我进入容器检查 DNS 配置:

docker exec -it java-app cat /etc/resolv.conf

发现 DNS 配置指向了内网 DNS 服务器,但这个内网 DNS 只负责解析内网域名,对外网域名做递归解析时失败了。宿主机用的是另一个公共 DNS,所以宿主机正常。

第二步,我检查了网络模式:

docker inspect java-app | grep -A 20 "NetworkSettings"

确认这个容器用的是默认 bridge 网络。问题清楚了:容器里的 DNS 配置来自 Docker 守护进程的默认设置,而 Docker 是从宿主机的 /etc/resolv.conf 继承 DNS 的。宿主机当前的 resolv.conf 经过某种网络管理工具重写后,只指向了内网 DNS。

第三步,解决办法是给容器显式指定 DNS:

docker run -d --name java-app --dns 223.5.5.5 --dns 114.114.114.114 my-image

问题立刻消失。这一步的原理在于容器网络初始化时,Docker 会把 --dns 参数写入容器的 /etc/resolv.conf。

这个案例给我最大的教训是:容器网络并不是"透明"的,它继承了宿主机网络的某些特性,也引入了隔离的副作用。排查问题时要同时考虑容器内外两层因素,不要默认它们完全一致。

10. Docker 部署踩坑清单:十个"早知道就好了"

最后整理一份我在各种环境里踩过的坑清单,每一行都是真金白银换来的经验。

体积相关

  • 构建上下文过大,导致每次 build 都极慢。务必配好 .dockerignore。
  • 分层缓存失效。Dockerfile 里 COPY 的文件如果经常变化,后续所有层都会重新构建。把变化频率低的指令放在前面,比如先拷 package.json,再拷整个源码。

网络相关

  • 容器内 localhost 不代表宿主机。如果在容器里访问宿主机上运行的服务,要用宿主机在 Docker 网络中的 IP,或者用 host.docker.internal(Docker Desktop 环境支持)。
  • 容器间通信用自定义网络,别依赖默认 bridge。默认 bridge 网络没有内置 DNS 服务,用容器名解析会失败。

存储相关

  • 停止的容器不会自动清理可写层。定期 docker container prune,别让磁盘无声地耗尽。
  • 同一个卷同时被多个容器写入,可能破坏数据一致性。特别是数据库,别这么干。

安全相关

  • 容器默认以 root 运行。在 Dockerfile 里加 USER 指令切换到非 root 用户,是降低风险最简单的一步。
  • 不要把 docker.sock 挂载进容器,除非你明确知道后果。挂载 docker.sock 等于把 Docker 的控制权给了这个容器。

运维相关

  • 不要在容器里用 systemctl。容器没有系统管理守护进程,它的 PID 1 是启动命令本身。
  • 关注容器内 stdout 日志。应用日志输出到 stdout 才能被 Docker 日志驱动收集。如果写文件,强烈建议把日志目录挂载出来。

说实话,这些东西单独拎出来都不难,难的是把它们当成一个整体去理解。容器不是把应用扔进去就跑那么简单,它涉及到文件系统、进程、网络、存储等多个层面的重新理解。我见过太多人跳过基础直接硬上 Docker,最后被镜像体积、网络不通、数据丢失这些问题反复折磨,反而更慢。老老实实把这一层的基础知识磨厚实,后面用 K8s、上微服务、做云原生,都会顺很多。

返回列表