容器和镜像这两个词这几年在技术圈里几乎被说烂了但真正能把它们讲清楚、用明白的人其实没那么多。我见过不少刚入行的朋友装完 Docker 跑了个 hello-world就觉得自己会容器了结果一到要自己写 Dockerfile、排查容器起不来的问题立刻抓瞎。问题往往不在于操作有多难而在于一开始就没搞明白容器和镜像到底是什么关系、它们各自解决了什么问题。这篇内容我就从一线使用的角度把这两个概念掰开揉碎讲一遍顺带把从装环境到跑起第一个自定义容器的完整流程走一遍。不管你是刚接触容器的新手还是用过几次但一直没理清底层逻辑的开发者看完应该都能对容器化这件事有个踏实、不玄乎的认识。我会尽量少讲空泛的概念多讲为什么这么设计实际用的时候要注意什么让你看完就能上手抄作业。1. 先把概念捋直容器与镜像到底是个什么关系1.1 用类与实例的类比一次讲透很多人第一次听到镜像和容器脑子里是糊的因为这两个词在日常语境里都太常见了。我的建议是直接把它们类比成面向对象里的类Class和实例Instance或者更生活化一点——模具和用模具压出来的糕点。镜像Image就是那个模具它是一个只读的、静态的、分层的文件集合里面打包了运行某个程序所需要的一切操作系统的基础文件、运行时环境、你的应用代码、依赖库、环境变量、启动命令等等。它躺在那里不会动也不会自己运行你改不了它严格说是不能直接改只能基于它再叠一层生成新镜像。容器Container则是用这个模具压出来的、实实在在能跑起来的东西。它是镜像的一个运行实例在镜像的只读层之上Docker 会再加一个可写层容器运行过程中产生的所有变化——写日志、改配置、生成临时文件——都落在这个可写层里。这也解释了一个特别常见的困惑为什么我在容器里改了个文件把容器删了重建改动就没了因为改动只在可写层容器一销毁可写层跟着没了而底下的镜像纹丝不动。这个只读镜像 可写容器层的设计是理解后面所有操作的一把钥匙。它带来的直接好处是同一个镜像可以同时跑出无数个互不干扰的容器就像同一个模具可以压出无数块糕点每块糕点你咬了一口改了数据不影响模具也不影响别的糕点。这就是容器轻量隔离可复制的根源。提示不要把容器理解成轻量级虚拟机。虚拟机是虚拟出一整套硬件再装完整操作系统容器是直接复用宿主机内核、靠内核的隔离机制命名空间、控制组等划出一块独立空间。这个区别决定了容器启动是秒级甚至毫秒级而虚拟机启动往往要几十秒。1.2 镜像的分层结构为什么它能又小又快镜像不是一个大文件而是由一层一层的层Layer叠起来的。每一层代表一次文件系统变更比如装了某个基础系统是一层装了运行时是一层拷贝了你的代码又是一层。这些层是只读且可复用的多个镜像如果共用同一个基础层磁盘上只存一份。这个设计的好处在实际使用中非常明显。举个我自己的例子我本地有十几个基于同一个基础镜像构建的项目镜像那个基础层在磁盘上只占一份空间构建的时候如果基础层没变Docker 直接命中缓存几秒钟就能出结果。这就是为什么 Dockerfile 里指令的顺序特别重要——把不常变的部分装系统、装依赖放前面把常变的部分拷贝业务代码放后面能最大化利用缓存构建速度能差出好几倍。理解分层还有个实际意义当你docker pull一个镜像时看到的那些一行行下载进度其实就是在逐层拉取。如果某一层本地已经有了它会显示 Already exists 直接跳过。所以第一次拉大镜像慢后面拉同系列镜像往往快很多。1.3 容器隔离靠的是什么命名空间与控制组容器看起来像一台独立的小机器靠的是 Linux 内核提供的两组机制这里简单点一下知道原理排查问题时会很有用。**命名空间Namespace**负责看不见——它让容器里的进程看不到宿主机的其他进程、独立的网络栈、独立的挂载点、独立的主机名等。你在容器里ps只能看到容器内的进程就是这个原因。**控制组Cgroup**负责用不超——它限制容器能用多少 CPU、内存、IO。你给容器加--memory512m背后就是 cgroup 在干活。容器内存超限被杀掉OOMKilled也是它干的。这两组机制都是宿主机内核本来就有的能力Docker 只是把它们封装成了好用的一套工具。所以容器不是虚拟化而是隔离。这也解释了一个限制容器里不能跑和宿主机内核不兼容的东西比如你在 Linux 宿主机上跑不了需要 Windows 内核的容器除非用专门的兼容层。这是容器和虚拟机最本质的差别也是选型时要考虑的点。2. 环境准备把 Docker 装好并跑通第一个容器2.1 安装与验证三步确认环境可用装 Docker 本身不复杂主流系统都有官方包。我这里不铺开讲每个系统的安装细节官方文档写得很全重点讲装完之后怎么确认它真的能用这一步很多人会跳过结果后面出问题不知道从哪查。第一步确认版本和服务状态。装完后执行docker version正常会输出客户端和服务端两段信息。如果只显示客户端、服务端报错连不上说明 Docker 守护进程没起来Linux 上一般是systemctl start docker没执行或没设开机自启。第二步跑官方的最小验证容器docker run hello-world这个命令会拉取一个极小的镜像并运行输出一段欢迎信息。它能跑通说明拉取、存储、运行这条链路是通的。第三步看一眼当前镜像和容器列表docker images docker ps -adocker images列出本地镜像docker ps -a列出所有容器包括已停止的。这两个命令后面会天天用建议现在就养成习惯。注意Linux 下普通用户默认没有权限操作 Docker每次都要 sudo 很烦。可以把当前用户加入 docker 用户组usermod -aG docker 你的用户名重新登录后生效。但要清楚这意味着该用户获得了接近 root 的权限生产环境要谨慎评估。2.2 第一个自定义镜像从 Dockerfile 开始跑通 hello-world 只是热身真正有用的是把自己的应用打成镜像。核心工具就是Dockerfile——一个描述如何一层层构建镜像的文本文件。我拿一个最简单的 Python 应用举例结构如下FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 8000 CMD [python, app.py]逐行说一下每行在干什么、为什么这么写FROM python:3.11-slim指定基础镜像。选slim版本而不是完整版是因为完整版带了一堆编译工具体积能差好几百 MB运行时不编译东西根本用不上。WORKDIR /app设定工作目录后续指令都在这个目录下执行相当于cd。COPY requirements.txt .和RUN pip install先只拷依赖清单再装依赖这是利用缓存的关键技巧。只要 requirements.txt 没变这一层就命中缓存改业务代码时不用重装依赖。COPY . .把当前目录代码拷进去。放在依赖安装之后因为代码变得最频繁。EXPOSE 8000声明容器打算用哪个端口主要是文档性质真正映射端口在运行时用-p。CMD [...]容器启动时默认执行的命令用数组形式exec form能保证信号正确传递给进程比 shell 形式更推荐。构建命令docker build -t myapp:1.0 .-t给镜像打标签名字:版本最后的.是构建上下文目录。构建完docker images就能看到myapp:1.0。2.3 运行容器端口、挂载、后台运行三件套镜像有了跑起来docker run -d -p 8000:8000 --name myapp-run myapp:1.0三个参数是日常最常用的组合逐个说清楚-d后台运行detached不加的话容器会占着当前终端。-p 8000:8000端口映射格式是宿主机端口:容器端口。左边是你浏览器访问的端口右边是容器内应用监听的端口别搞反。--name myapp-run给容器起个固定名字方便后续docker logs myapp-run、docker stop myapp-run。不起名的话 Docker 会随机分配一个记起来很痛苦。跑起来后验证docker ps docker logs myapp-rundocker ps看到容器状态是 Up 就说明起来了docker logs看应用日志有没有报错。如果状态是 Exited八成是应用启动就崩了先看日志。3. 核心操作与实操要点把容器用顺手3.1 进入容器排查问题exec 而不是 attach容器跑起来但行为不对第一反应是进去看看。正确姿势是docker exec -it myapp-run /bin/bashexec是在运行中的容器里新开一个进程-it是分配交互式终端。进去之后你可以ls、cat、curl各种排查退出exit不影响容器本身。很多人会用docker attach这个要慎用——attach 是接到容器的主进程上你按 CtrlC 很可能直接把主进程干掉、容器跟着退出。排查问题用 exec 更安全。提示如果容器用的是极简基础镜像比如 alpine 或 distroless里面可能连 bash 都没有这时用/bin/sh代替。distroless 镜像甚至没有 shell那就只能靠日志和外部工具排查了这也是选基础镜像时要权衡的点。3.2 数据持久化卷挂载是绕不开的一课前面说过容器可写层随容器销毁而消失那数据库数据、用户上传的文件怎么办答案是挂载。两种主要方式绑定挂载bind mount把宿主机某个目录直接映射进容器。docker run -d -v /host/data:/app/data myapp:1.0左边宿主机路径右边容器内路径。适合开发时把代码目录挂进去改代码即时生效。命名卷named volume由 Docker 管理的存储卷。docker run -d -v mydata:/app/data myapp:1.0mydata是卷名Docker 会在自己的存储目录里管理它跨容器可复用、可备份。生产环境存数据我更推荐命名卷因为它的生命周期和容器解耦管理起来更清晰。这里有个我踩过的坑挂载会覆盖容器内原目录的内容。比如你镜像里/app/data本来有初始配置文件一挂载宿主机空目录上去容器里看到的就是空的配置全没了。解决办法是首次启动时做初始化拷贝或者干脆别把需要保留的初始文件放在会被挂载的路径下。3.3 镜像与容器的清理别让磁盘悄悄爆掉用久了你会发现磁盘越来越满因为停止的容器、悬空镜像、构建缓存都在占地方。几个清理命令要记牢命令作用风险docker container prune删除所有已停止容器低但会丢容器可写层数据docker image prune删除悬空镜像无标签的中间层低docker image prune -a删除所有未被容器使用的镜像中会删掉你还要用的镜像docker system prune清理停止容器、悬空镜像、无用网络中docker system df查看各类占用无先看再删我的习惯是每周跑一次docker system df看看占用心里有数再决定清不清。-a这类激进参数一定先看清楚再敲删错了重新拉镜像费时间。4. 常见问题与排查技巧实录4.1 容器起不来的排查顺序容器启动失败是最常见的问题我总结了一个固定的排查顺序按这个走基本能定位看状态docker ps -a看是 Exited 还是 Restarting。Exited 通常是应用自己崩了Restarting 是反复重启。看日志docker logs 容器名重点看最后几十行报错。应用启动失败的原因九成在日志里。看退出码docker inspect 容器名 --format {{.State.ExitCode}}。退出码 0 是正常结束1 一般是应用错误137 是被杀常见于内存超限 OOM143 是被正常终止信号杀掉。进容器手动跑如果日志看不出用docker run -it 镜像名 /bin/sh起一个交互容器手动执行启动命令报错会更直观。这套流程我用了很多年比盲目改配置高效得多。4.2 端口冲突与网络连不通docker run报port is already allocated说明宿主机那个端口被占了。先docker ps看是不是有别的容器占了或者用lsof -i:8000查宿主机进程。换个宿主机端口映射即可比如-p 8001:8000。容器之间连不通是另一个高频问题。默认情况下不同网络的容器互相隔离。要让容器互访把它们放进同一个自定义网络docker network create mynet docker run -d --network mynet --name db mysql:8 docker run -d --network mynet --name app myapp:1.0同一个自定义网络里容器可以用容器名当主机名互相访问比如 app 里连数据库直接写db:3306就行不用记 IP。这个特性非常实用比默认 bridge 网络方便太多。4.3 常见问题速查表现象可能原因排查/解决容器状态 Exited(1)应用启动报错docker logs看报错进容器手动跑容器状态 Exited(137)内存超限被杀加大--memory或优化应用内存端口被占用宿主机端口冲突换映射端口或停掉占用进程挂载后文件不见了挂载覆盖了容器内目录检查挂载路径避免覆盖初始文件容器间连不通不在同一网络创建自定义网络并加入镜像构建很慢缓存没命中调整 Dockerfile 指令顺序常变的放后面磁盘占用暴涨停止容器/悬空镜像堆积docker system df查看后清理4.4 几条踩坑换来的经验最后分享几条文档里不太会写、但实际很省心的经验。第一给容器起固定名字别依赖随机名排查时省一半时间。第二日志一定要配大小限制默认日志文件会无限增长跑久了能把磁盘写满启动时加--log-opt max-size10m --log-opt max-file3限制轮转。第三生产环境别用 latest 标签镜像版本要固定否则某天重新拉取 latest 变了行为跟着变排查起来极其痛苦。第四构建时用 .dockerignore 排除无关文件把 node_modules、.git、日志目录排除掉构建上下文小速度快很多。我个人在实际操作中的体会是容器这套东西的难不在命令多而在概念没理顺。一旦你把镜像是只读模板、容器是可写实例、数据要挂载出来这三件事刻进脑子里剩下的命令都是查手册就能解决的。真正拉开差距的是遇到问题时知道先看日志、先看退出码、先确认网络和挂载而不是一通乱改。这套排查习惯比记住多少条命令都值钱。