ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Docker镜像与容器到底有什么区别?从原理到实战全解析

Docker镜像与容器到底有什么区别?从原理到实战全解析 1. 先搞清楚镜像和容器到底差在哪1.1 用类和实例的理解去套 Docker很多人刚开始学 Docker 的时候都会对着docker images和docker ps两个命令发呆一个是堆镜像的仓库一个是跑容器的列表看起来都像装了某个软件的东西但就是说不清本质区别是什么。我打了几年交道的体会是镜像Image就是一个静态的只读模板容器Container是这个模板的可运行实例。这两个词在 Docker 官方文档里的定义是闭环的——镜像定义了容器里的文件系统、默认命令、环境变量、启动参数容器则是镜像真正被内核跑起来之后的那个运行时实体。拿 Java 来类比最直观镜像相当于 Class容器相当于 new 出来的对象。同一个 Class 可以被 new 无数次每次实例化内存里会重新划一块堆空间彼此完全隔离同一个镜像可以docker run出无数个容器它们底层的只读层共用但各自被分配独立的可写层、独立的进程命名空间互不干扰。用做饭来类比也很形象镜像是一个被冷冻好的预制半成品菜容器是你从冰箱里拿出来、放到锅里加热后端上桌的那份菜。预制菜可以反复复制、冷冻保存容器这份吃完了可以再热一份甚至同一张预制菜单可以同时热出麻辣味、藤椒味、番茄味三盘菜——这就对应同一个镜像通过传不同的环境变量、挂载不同的数据目录跑出不同配置的容器。1.2 镜像只读、容器可写这是最核心的边界如果只记住一句话那就是镜像的每一层都是只读的容器追加的可写层才是属于它的草稿纸。我有一段时间一直困惑为什么我在容器里装个 vim、改个配置文件容器停掉之后这些改动就没了一部分其实这个没了不是玄学而是写时复制Copy-on-WriteCoW机制决定的。当你读到容器里某个文件实际是读镜像层里的那个文件当你想在容器里修改它Docker 不会直接改动镜像层而是把文件从镜像层复制到容器的可写层然后在可写层上做修改。从容器视角看文件确实被改了从镜像视角看底层那个文件压根没动过。这种机制带来两个直接结果多个容器共享同一个镜像时只读层只占一份内存和磁盘空间。我在一台 4G 内存的云服务器上直接跑了 6 个 Nginx 容器镜像加起来几百兆但它们共享底层真实占用并没有翻六倍。容器被docker rm删掉后可写层被连锅端。除非你把数据写进卷Volume或者挂载目录里否则容器一删草稿纸上的所有内容就没了。这是初学阶段最容易踩的坑。从命令上可以看得更清楚。docker images里列出的是只读层快照docker ps里列出的是活着的运行时实例docker inspect同一个项目镜像返回的是配置元数据和层 ID容器返回的是 PID、网络、挂载点、可写层状态等运行时信息。形象来说镜像是一个待机文件容器是正在被操作系统加载执行的进程态。2. 从 Dockerfile 到镜像分层构建到底在做什么2.1 每一行指令生成一层缓存由此而来Dockerfile 是构建镜像的菜谱但真正理解它必须理解每一行指令约等于一层这件事。我见过不少新手写 Dockerfile写完就算遇到构建慢也不知道为什么。其实 Docker 构建镜像的过程是把每个指令RUN、COPY、WORKDIR等的结果保存为一个只读层层与层之间只记录差异。docker history可以让你清晰地看到每个层是哪个指令产生的、有多大体积。随便看一个构建输出就能明白FROM ubuntu:20.04 RUN apt-get update apt-get install -y curl RUN curl -o /app.tar.gz https://example.com/app.tar.gz第一条指令生成系统基础层第二条生成安装了 curl 的层第三条生成带着执行痕迹的层。下次构建时只要前面两层没有变化Docker 就会复用缓存直接跳过去执行第三条之后的指令。这就是为什么把频繁变动的代码 COPY放到 Dockerfile 底部能显著提升构建速度——前半段稳定层全部走缓存后半段才重新构建。注意能用一条RUN写完的安装动作尽量用串联成一条减少层数。层数多了不光构建时多几层docker pull也要多传输好几个层文件仓库占用、网络传输都会受影响。2.2 镜像分层不只是为了省空间镜像分层的意义远不止省磁盘。我经历过一个场景生产环境同时要部署 Java 服务、Python 服务和 Node 服务底层都基于同一个仓库里的 Ubuntu 基础镜像。由于它们共用同一批底层只读层服务器拉取第二个、第三个镜像时网络上传送的其实只是各自新增的差异层下载量骤降。这个特性对构建缓存同样重要。CI 流水线里每次跑docker build只要基础镜像和依赖安装指令没变稳定层就会被复用代码更新只需重建最上面几层。如果不用分层每次全量构建一次部署就要拉几百兆甚至上 GB 的完整文件系统效率和体验完全是两个量级。那分层是怎么实现的核心是 UnionFS联合文件系统的思路把多个只读目录叠加起来对外呈现成一个合并后的完整文件树。Docker 在不同平台用的实现不同常见的有 overlay2、aufs新版本基本都转向 overlay2。无论哪种暴露给用户的都是一个看起来普通的文件系统但底层的文件访问多了一层联合挂载逻辑。2.3 构建一个最小镜像的实操示例理论扯多了来一个实际能跑的最小镜像用scratch从零构建一个打印 hello 的二进制。第一步先在宿主环境写一段 C 代码并静态编译$ cat hello.c EOF #include stdio.h int main() { printf(hello docker\n); return 0; } EOF $ gcc -static -o hello hello.c然后写一个极简 DockerfileFROM scratch COPY hello /hello ENTRYPOINT [/hello]构建并运行$ docker build -t hello-min . $ docker run --rm hello-min hello dockerscratch是一个真正的空镜像里面没有 shell、没有 libc、没有任何安装包只有那个静态编译的二进制。跑起来之后容器内就是那个二进制在裸奔。这个东西虽然极端但它能让你彻底理解容器镜像本质是一个经过封装的 rootfs 启动配置不是一个操作系统。见到的 CREATE BLOB、APPLY layer 等输出就是镜像层被下载和解包的过程docker history hello-min会看到只有一层 COPY hello 记录这能直观说明镜像文件系统层的构成。3. 从镜像到容器docker run那一刻发生了什么3.1 六步走流程namespace 与 cgroup 在幕后的组合拳很多人在讲 Docker 时会用轻量级虚拟机来类比但严格来说不准确。容器不是一个虚拟机它是一个被 cgroup 和 namespace 约束了资源视界的宿主进程。当执行docker run时背后大致发生六件事检查本地是否存在对应镜像不存在则尝试从远程仓库拉取基于镜像创建可写容器层分配网络接口与 IP默认接到 bridge 网络docker0根据镜像和启动参数设置环境变量、命令、工作目录创建并启动容器主进程并分配独立 PID 命名空间让容器内的进程看不到宿主机进程根据 Cgroups 配置设定 CPU、内存等资源限制。其中最关键的是 namespace 和 cgroup 这两个内核机制namespace 负责隔离让容器内的进程以为自己是独立的系统有不同的 PID、网络、挂载、用户等视图cgroup 负责限额限制它能吃多少 CPU、多少内存、多少 PID。两者配合才让一个普通进程看起来像一整套隔离系统。生活化地理解这就像在合租公寓里每个房间有独立门锁、独立水表电表namespace 负责独立房间物业管理处给每个房间单独限电限水你开再大功率的暖气也不影响隔壁房间cgroup 负责资源限额。3.2 容器状态机和生命周期容器停掉之后是不是没了这是新手和我经常聊到的一个困惑。其实只要没被docker rm删除容器就会保留在 exited 状态可写层也还在。一个容器大体有几个状态created已创建未启动、running运行中、paused已暂停、exited已停止、dead异常终止。常用命令必须理清楚docker start启动一个处于 stopped 的容器可写层保留之前的文件改动还在docker stop发 SIGTERM 让主进程优雅退出超时再 SIGKILLdocker restart等于 stop startdocker rm才真正销毁容器和它的可写层不可恢复docker run实际上是create start的合成动作。我踩过最典型的一个坑自测时直接docker run一个 MySQL 容器为了临时改配置在容器里安装了 vim后来容器出问题我连官方推荐的 stop rm run 新容器 流程都没走直接删了旧容器。结果整个 MySQL 数据全没了。后来才痛定思痛容器内的所有临时修改都是寿命不保的必须把数据挂出来。这也反过来说明容器是一个状态容易被抛弃的运行时真正需要长期保留的数据一定要落在卷或挂载目录里。3.3 容器也能变回镜像commit、export、save 的差别日常工作中最容易被绕晕的是docker commit、docker export、docker save这三兄弟。docker commit把一个容器的可写层叠加到原镜像只读层上生成一个新镜像。它保留了容器里所有文件改动适合容器里折腾好环境后固化成一个镜像。docker export把容器当时的完整文件系统含可写层导出一个 tar 包不保留镜像层级和历史元数据。docker save把整个镜像含层级结构和元数据导出成一个 tar 包常用于离线迁移。我之前的习惯是临时在容器里调试出了一个不错的配置用docker commit固化下来再在里面挑选要保留的步骤重新写成规范的 Dockerfile。docker export我比较谨慎因为导出的 tar 包导入后镜像历史层信息会丢失后续做版本回溯就不方便了。表格总结一下操作对象产物是否保留层历史典型场景docker commit容器新镜像保留调试后固化环境docker export容器tar 包不保留仅迁移文件系统docker save镜像tar 包保留离线分发镜像4. 实操现场容器里改文件、挂目录、管权限4.1 用docker diff看到可写层的真实变化很多人对容器可写层只有概念没有具象感知。推荐自己动手跑一遍验证$ docker run -d --name nginx-test nginx $ docker exec -it nginx-test bash # 在容器里创建 /tmp/hello.txt 并写入内容 $ echo layer test /tmp/hello.txt $ exit $ docker diff nginx-test输出里会出现C /etc、A /tmp/hello.txt这样的记录A 表示新增了文件C 表示修改了文件。这就把 CoW 机制呈现到眼前容器里发生的任何变化只在最顶层的可写层产生记录。删除容器后这些 A/C/D删除记录连同可写层一起被丢弃。这个命令在生产排查中特别有用。有一次线上容器网络突然不通我先docker diff查看容器内是否有异常改动再docker logs看进程日志最后docker exec检查容器内网络配置层层排除后才发现是有人改了/etc/resolv.conf的权限导致 DNS 解析失败。docker diff能帮你快速缩小排查范围。4.2 目录读写权限与挂载的正确姿势挂载目录是容器化部署里绕不开的坎。最常见的坑是容器内进程以 root 身份运行你从宿主机挂一个目录进去发现里面文件在容器内读不到或者容器内生成的文件在宿主机上没有写权限。本质原因在于 uid/gid 的映射问题。容器内进程的 uid如 root0与宿主机用户的 uid如 1000不是一回事。解决方案一般有三种挂载后 chown先在宿主机上把目录 owner 改成容器内进程期望的 uid再启动容器。指定用户启动容器docker run --user 1000:1000让容器内进程以宿主机的普通用户身份运行配合卷挂载权限更规整。命名卷Named Volume容器首次创建时Docker 会自动初始化卷的所有权为镜像里设置的路径 owner省去手动 chown。我自己在部署 MySQL 8 容器时是这么处理的$ mkdir -p /data/mysql $ docker run -d \ --name mysql8 \ -e MYSQL_ROOT_PASSWORDmysecret \ -v /data/mysql:/var/lib/mysql \ -p 3306:3306 \ mysql:8.0跑起来之后如果发现宿主机/data/mysql下的文件 owner 是 mysqluid 999不用慌这是 MySQL 镜像内部初始化出来的默认 owner。如果你用 bind mount 挂载宿主机目录目录本身要有相应权限才能被容器内进程读取。我建议用-v命名卷让 Docker 管理目录权限必须用 bind mount 时优先保证宿主机目录权限是当前用户可写再配--user参数。4.3 数据持久化Volumes vs Bind Mounts容器的可写层不可靠因此持久化数据要放在卷或挂载里。我来说说两者的取舍。命名卷Named Volume由 Docker 管理存放到/var/lib/docker/volumes下。跨容器共享很方便多个容器挂同一个卷备份时直接用docker run --rm -v 卷名:/volume ... tar打包。Bind Mount绑定挂载直接把宿主机的某个路径比如/data/mysql挂进容器。优点是宿主机上直接可见适合开发场景实时改代码、查看日志缺点是受宿主机目录权限影响大跨机迁移需要单独处理目录。选型上我的原则很朴素日志和临时文件随便但数据库数据目录必须用命名卷。因为命名卷由 Docker 管理容器重建时不易误操作删除备份恢复的命令也更稳定。生产环境我还会搭配--mount typevolume这种更详细的语法把只读选项、子路径都写清楚。5. 部署常见的坑安装、网络、启动失败5.1 Docker Desktop 报Virtualization support not detected怎么办Windows/macOS 用户装上 Docker Desktop 之后最容易在启动阶段被卡一个报错virtualization support not detected docker desktop failed to start because v...这个报错最常见的根因是宿主机虚拟化能力或虚拟化设置没打开。Windows 上Docker Desktop 依赖 Hyper-V 或 WSL2。我处理过几台机器基本按以下顺序排查进入任务管理器性能标签看 CPU 虚拟化是否已启用打开启用或关闭 Windows 功能勾选Hyper-V适用于 Linux 的 Windows 子系统虚拟机平台重启后在 PowerShell 执行wsl --status确认 WSL2 内核正常如果 WSL 版本太旧运行wsl --update顺便升级内核最后再启动 Docker Desktop。其中确认虚拟化已启用是最关键的一步。老款 CPU、某些品牌机的 BIOS 可能默认关掉了 VT-x/AMD-V要去固件设置里打开。在 Linux 发行版上装 Docker Engine有时也会看到一个类似提示但其实那是 Docker 在为嵌套虚拟化做探测通常不影响正常用 docker run 跑容器。5.2 容器网络不通的排查思路容器网络问题几乎人人会遇到。我遇到最多的场景是容器能启动了但宿主机访问不到容器端口或者容器内访问不了外部网络。我的排查顺序是固定的docker ps确认容器处于 Up 状态docker port 容器名看端口映射是否正确docker network ls看容器属于哪个网络docker exec -it 容器名 ping 网关测试容器到宿主机的连通性docker logs结合容器日志判断是操作系统还是业务的问题。常见的根因有三个端口映射没写全docker run -p 3306:3306才会映射端口只写-p 3306有时会随机映射到宿主机某个高位端口导致你按 3306 去访问反而超时。容器间跨网络隔离自定义 bridge 网络与默认 bridge 网络不互通两个容器不在同一网络里要用docker network create把相关容器塞进同一网络。容器内 DNS 失效遇到容器访问外网域名解析失败多半是容器内/etc/resolv.conf出了问题需要确认宿主机 DNS 是否正常或者用--dns参数显式指定。容器网络排障不像传统服务器那样固定是网线断了它往往是一层层虚拟网络的叠加问题。建议养成一个习惯每次启动相关服务就为它单独建一个 Docker network通过容器名互相访问而不是靠 IP。这样既方便容器重启后 IP 变化不影响配置也让网络拓扑清晰。5.3 MySQL、Redis 容器化部署的几个关键点热词里提到docker安装mysql8.0并使用、docker安装redis主从我额外说几句。MySQL 8 容器化部署最关键的是两件事初始化密码与数据卷归属。环境变量MYSQL_ROOT_PASSWORD只在数据目录首次初始化时生效。如果容器已经启动过一次再改这个环境变量不会重置密码。想要数据持久化一定要把/var/lib/mysql挂出来。$ docker run -d --name mysql8 \ -e MYSQL_ROOT_PASSWORDroot123 \ -e MYSQL_DATABASEtestdb \ -v mysql-data:/var/lib/mysql \ -p 3306:3306 \ mysql:8.0跑完之后可以用docker exec -it mysql8 mysql -uroot -p进去验证。Redis 主从容器化部署则要注意网络和连接地址。我习惯将主从节点放进同一个 Docker 网络通过容器名相互感知$ docker network create redis-net $ docker run -d --name redis-master --network redis-net -p 6379:6379 redis:7 $ docker run -d --name redis-slave --network redis-net redis:7 redis-server --slaveof redis-master 6379这里如果从节点写成了--slaveof 127.0.0.1 6379那就只能连到容器自己主从根本搭不起来。网络模型不同localhost的含义也不同这是容器化部署里最常见的思维死角。6. 进阶镜像瘦身与容器资源控制6.1 几种常见的镜像瘦身思路刚用 Docker 时我随手一个ubuntu:latest做底 安装一堆依赖镜像动不动 1GB。后来线上要批量签发部署才认真做瘦身把服务镜像压到了几十兆。常用的瘦身手段选择更小的基础镜像用alpine或distroless替代ubuntu或centos。针对 Java 应用可以选eclipse-temurin:17-jre-alpine只用 JRE 不用完整的 JDK。多阶段构建第一阶段的编译器、源码包只在构建阶段用最终产物复制到第二阶段丢掉所有构建工具。典型做法FROM maven:3.9-eclipse-temurin-17 AS builder COPY . /src RUN mvn -q package -DskipTests FROM eclipse-temurin:17-jre-alpine COPY --frombuilder /src/target/app.jar /app.jar ENTRYPOINT [java,-jar,/app.jar]这样最终的镜像里只有瘦小的 JRE 和你打好的 jarMaven 和源码全都不带进去。合并 RUN 指令把apt-get update和apt-get install写在同一条指令里并顺手rm -rf /var/lib/apt/lists/*减少缓存垃圾和临时文件滞留层里。有一点要提醒镜像瘦身别走极端。完全没有调试工具的 distroless 镜像在生产上排查问题时会很折磨人连ls都没有只能靠 logs。我一般把面向生产的镜像放到 alpine 级别就够用保留基础 shell牺牲小几十MB换排查便利性价比更高。6.2 容器资源隔离的实际控制容器不像虚拟机有独立的完整资源管理但 Docker 可以通过 cgroup 给容器设置资源硬限制。$ docker run -d --name myapp --cpus1 --memory512m --memory-swap512m --pids-limit512 nginx--cpus1限定容器最多使用 1 个 CPU 核心--memory512m限制内存超过阈值可能触发 OOM--pids-limit限制容器内进程数量防止某个进程疯狂 fork 把宿主机拖垮。运行时可以用docker stats实时查看资源占用。我自从把线上所有容器都加上资源限制之后宿主机整体稳定性显著提升——以前某个容器内存暴涨会波及同一台机器上的其他服务现在只会在docker logs里看到 OOM 记录其他容器不受牵连。有一个细节--memory-swap默认等于 memory 值表示不允许容器使用 swap。如果不加这个参数容器实际可用的内存会变成memory swap和预期不符。排查内存问题时别忘了看docker inspect里的MemorySwap字段。6.3 保持容器无状态的最佳实践从镜像与容器的关系出发最后给一条贯穿全局的实践原则容器反复创建镜像不断迭代所有持久数据都放卷。这条原则只要守住容器的生命周期管理就会非常顺手。具体执行上我一般做到这几件事Dockerfile 里只固化代码和运行环境不写任何随环境变化的数据所有配置文件通过环境变量或挂载注入而不是改镜像数据库、消息队列的数据目录一律用命名卷或挂载目录排查问题优先docker logsdocker exec尽量不 commit 临时容器为镜像。守住无状态原则镜像可以随便删了重建容器可以随时启停因为真正有价值的数据已经和容器解耦。拿 MySQL 举个最直接的例子容器需要升级镜像版本时只要数据目录和配置文件都在卷里操作就是停旧容器、启动新镜像的容器、挂同一个卷。镜像还是那个镜像容器还是那个容器但数据稳稳地跟着卷走这就是理解了镜像与容器关系之后带来的工作方式。
返回列表