资讯详情

资讯详情

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 在背后依次做了这些事检查本地有没有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_PASSWORDchange-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 --omitdev 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 --frombuilder /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日志时间和你本地差八小时排查问题时对时间线对到怀疑人生。解决办法是在运行或构建时设置环境变量TZAsia/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、看编排工具都是水到渠成的事。真正值得反复琢磨的不是命令本身而是“为什么要把环境打包”“为什么数据不能放容器里”这些设计背后的取舍想通了这些工具换了、平台变了思路依然管用。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →