1. 一次真实拉取带来的疑问镜像到底是什么前阵子帮团队搭一套演示环境一个刚转岗过来的同事在旁边敲docker pull nginx看到下载进度条跑完冒出一句“这不就跟下载了个安装包差不多吗”我当时愣了一下因为这句判断看似合理实际上从根上就错了。如果你也以为 Docker 镜像就是一坨“压缩过的程序文件”那这篇内容很适合你。我会拿 Nginx 这个最有名也最小的 Web 服务器镜像作为解剖样本把 Docker 镜像的构成、分层机制、常见玩法、以及我在实际使用中踩过的坑讲清楚。适合刚接触 Docker 的人也适合那些知道命令但一直没想明白“容器里文件的来龙去脉”的后端和运维同学。1.1 从 docker pull nginx 说起下载的不是“一个文件”docker pull nginx执行的时候你会发现终端里先出现一行Using default tag: latest然后是一大串Pull complete最后还有一行Digest: sha256:...。注意这里的节奏它不是像wget那样下载了一个 nginx.tar.gz而是一层一层地往下拉每一层都有一个 digest 做完整性校验。这层“一层一层”的感觉就是理解 Docker 镜像的第一把钥匙。镜像实际由多个只读层layer叠加而成每一层代表文件系统的一次变更可能是往根文件系统里丢了一个基础系统文件可能是执行了包管理器安装 Nginx也可能只是写入了几行环境变量。我把镜像理解成“带启动参数的文件系统快照”。它里面的内容不是一个安装包而是 Nginx 程序文件、依赖库、/etc/nginx/nginx.conf配置、/usr/share/nginx/html/index.html静态页甚至还有默认的启动命令全都已经按 Linux 根目录的布局摆好了。容器一启动Docker 就把这套文件系统挂载为只读根在上面跑 Nginx 进程。普通软件安装包解决的是“把程序装到机器上”的问题Docker 镜像解决的是“把程序连同整个运行环境一起端走”的问题。一个是把零件装进电脑一个是把已经装好的整台电脑桌面直接接管过来。1.2 镜像、容器、宿主机三者的边界很多人把“镜像”和“容器”混为一谈其实一句话就能划开镜像是模板容器是模板的运行实例。用一个大概不太严谨但很好记的类比镜像是“精装房的户型与所有家具的规格说明”容器是你实际入住后的房间。你可以往房间加桌子、撕掉墙纸但图纸本身不会变你住腻了把房间里自己加的东西扔了重新按图纸再“住进去”一间所有改动归零。容器运行时Docker 会在镜像的只读层之上再叠一个可写层容器层。你在容器里敲apt install vim文件会写进这个可写层你rm /etc/nginx/nginx.conf也只是在这个可写层上做标记。容器删掉后这个可写层一并消失。镜像始终是那套没被碰过的图纸。宿主机和容器之间还有一类数据叫做绑定挂载或卷volume它不属于镜像层也不默认属于容器层。挂载进来的宿主机目录可以被容器直接读写容器删了挂载目录还在。这个边界如果搞不清很容易出现“我在容器里改了配置重启容器怎么没了”的经典疑问。1.3 为什么非得拿 Nginx 当样本市面上镜像五花八门我选 Nginx 当解剖对象是因为它几乎是为讲解镜像而生。第一它够小。nginx:latest大概一两百兆nginx:alpine只有几十兆拉取快、启动快反复做实验几乎没成本。第二它的结构足够典型。有基础系统层、有环境变量层、有真正的安装层、还有EXPOSE和CMD这种元数据层几乎涵盖了你能在镜像里看到的全部层类型。第三它的配置集中。主配置、站点配置、静态目录、日志目录都放在几个固定位置改动之后影响明显特别适合用来验证“镜像里的文件到底能不能改、改了之后去哪了”这类问题。我一直觉得与其翻各种半懂不懂的文章不如把 Nginx 镜像下载到本地亲手拉一层、看一层、改一层。弄明白它Docker 镜像就再也不神秘了。2. 拆开 Nginx 镜像用 inspect 和 history 看它的“成分表”如果说镜像是一个洋葱docker history就是让你一层一层剥开它docker inspect则是把每一层的属性贴到化验单上。2.1 docker history 显示的每一行都对应一层执行docker history nginx:1.25-alpine你会看到从近到远倒序排列的历史记录每一行基本对应镜像构建时的某一步。典型输出长这样IMAGE CREATED CREATED BY SIZE b6c4e3e9a2f1 3 months ago /bin/sh -c #(nop) CMD [nginx -g daemon off;] 0B missing 3 months ago /bin/sh -c #(nop) STOPSIGNAL SIGQUIT 0B missing 3 months ago /bin/sh -c #(nop) EXPOSE 80 443 0B missing 3 months ago /bin/sh -c set -x ; addgroup -g 101 -S nginx ; ... 8.2MB missing 3 months ago /bin/sh -c #(nop) ENV NGINIX_VERSION1.25.3 0B missing 3 months ago /bin/sh -c #(nop) ADD file:... in / 7.8MB每一行都值得读一遍。底部那行ADD file:... in /是基础根文件系统层相当于 Alpine Linux 的整个小系统中间那行 8.2MB 的set -x ; addgroup ...真正执行了包管理器的安装、创建了 nginx 用户而EXPOSE、CMD、STOPSIGNAL这些行虽然写着 0B不是文件系统变化但它们是镜像配置的元数据决定容器启动时跑什么、暴露哪些端口、收到信号时怎么做。我建议你加--no-trunc参数再看一次docker history --no-trunc nginx:1.25-alpine加了之后CREATED BY一列会显示完整命令你能看到创建 nginx 用户的具体指令、编译参数、清理步骤。这就跟拿到一张菜谱一样官方镜像怎么做的全都摆在眼前。2.2 docker inspect 里最值得看的几个字段docker history看的是“构建过程”docker inspect看的是“成品属性”。虽然输出又长又绕但挑几个字段就够了。docker inspect nginx:1.25-alpine重点看RootFS、Config两块RootFS.Layers这个镜像包含的所有分层的 SHA256 引用。数一数有几条再对比docker history的行数你会发现数量可能不完全一致因为带#(nop)的元数据层不一定都产生文件系统层这个细节理解了分层的概念就通了。Config.Entrypoint和Config.Cmd容器启动时实际执行的命令拼起来是什么。Nginx 镜像里通常没有 Entrypoint靠Cmd里的nginx -g daemon off;启动。Config.ExposedPorts镜像声明开放了哪些端口。注意这只是“声明”不真正做端口映射要对外提供服务还得靠docker run -p。Config.Env镜像内置的环境变量比如PATH、NGINX_VERSION。这些东西会影响运行时行为查“为什么容器里命令找不到”时先看这里。Image如果你 inspect 的是容器而不是镜像这个字段会显示它来自哪个镜像。实际排查问题的时候我几乎天天用docker inspect。可以配合grep快速定位比如docker inspect nginx:1.25-alpine | grep -A 8 RootFS不过真要细看完整 JSON还是把输出存到文件里再用编辑器搜比较舒服。2.3 默认启动命令为什么要 daemon off很多人第一次用 Nginx 是非容器方式装好执行nginx它就后台运行了把请求一接干净利落。但你在容器里如果也这么干会看到容器“秒退”。原因在于 Docker 要求容器内必须有一个前台保持运行的进程。普通模式下nginx启动时会 fork 出 master 和 worker 进程然后原本那条命令执行完就退出了。在宿主机上后台进程还活着没问题在容器里入口命令退出了Docker 就认为容器任务结束直接给你停了。官方镜像的 CMD 写的是nginx -g daemon off;-g是让 Nginx 读一段附加全局配置daemon off;就是明确告诉它不要后台化master 进程老老实实待在前台。这就是容器里 Nginx 能够持续运行的底层原因。这给我们的通用启发很大凡是容器的管理进程都不能以守护进程方式运行。MongoDB 里的 mongod、Redis 里的 redis-server 如果配置成 daemon 模式同样会出现启动即退的问题。3. 分层机制Nginx 镜像如何在成百上千个容器之间共享理解了镜像“是由很多层叠起来的”接下来一个重要问题为什么非得分层一层一坨全塞进去不行吗3.1 联合文件系统一棵不断叠加的树Docker 用的底层存储驱动有很多种比如 overlay2、fuse-overlayfs它们的核心思想都差不多把多个目录“叠”在一起对外呈现成一个合并后的目录。可以把每一层想象成一张透明胶片下面一层有文件上面一层也有文件叠在一起后你看到的是最上面那层的文件。如果下面有/usr/share/nginx/html/index.html上面这一层没有这个文件那访问时还是能看到下面的文件如果上面这一层里也有同名路径则显示的是上面那一层的版本。容器启动时Nginx 镜像的所有只读层会被挂载为底层再在最上面叠一个可写的空层。你在容器里执行docker exec web touch /tmp/test写的就是最顶上的可写层。镜像本身一点没变。这也是为什么在容器里看根文件系统是完整的一大棵树实际却是由几十个目录拼出来的。类比你手机里的“合并文件夹”不同来源的照片、视频放在同一个视图里展示底层数据分开存但浏览起来完全没有区别。3.2 共享层如何省磁盘、省带宽、省启动时间分层最大的红利是“复用”。假设你一台机器上跑了 20 个基于nginx:1.25-alpine的容器按最简单的思路20 份完整镜像 20x 几十兆磁盘就该爆了。但因为有分层共享宿主机上实际只保存一份基础层和 Nginx 层20 个容器共享这些只读层每个容器只额外占用自己的可写层通常也就几 KB 到几 MB。拉镜像的时候同理。如果你本地已经有alpine:3.19再拉nginx:1.25-alpineDocker 会显示Already exists只下载新增的那些层。团队里如果都用同一套基础镜像Docker Hub 的流量、CI 里的缓存都能省很大一块。启动容器为什么快也是因为不需要“复制”镜像文件只要挂载这些层再给新容器建一个薄的可写层就行。几十兆的容器基本秒起一百多兆的 Debian 系 Nginx 镜像通常也是秒级。3.3 最容易误解的一点容器里改文件不影响镜像删除文件也不会让镜像变小一个隔三差五就会有人问的问题“我在容器里删了/usr/share/nginx/html/index.html为什么镜像还是那么大”因为删除动作不会“穿透”到下层文件而是往上可写层写一个删除标记。底层那个文件依然躺在只读层里镜像体积一分都不会少。更实用的反例是有人觉得容器里写了一堆垃圾想通过docker commit把“干净”的容器保存成新镜像来瘦身。结果往往发现新镜像体积比原来还大因为可写层里的新增文件也被提交进去了。而你在容器里“删了”的旧文件还好好地封存在下面的只读层里。如果真想彻底缩小镜像唯一正确的思路是重新构建让有问题的文件压根不要进入这条构建链路或者在新镜像里从零复制出需要的文件。关于这一点后面说 Dockerfile 时我再展开。4. 把镜像“用起来”基于 Nginx 镜像的三种常见玩法前面聊了不少原理现在落到实操。基于 Nginx 镜像我日常用得最多的就是三种方式不改镜像纯挂载、写 Dockerfile 固化配置、换更小的基础镜像瘦身。4.1 不改镜像挂载配置和静态文件最简单的一种用法镜像还是那个官方镜像但把宿主机上的配置和文件“塞”进去。docker run -d --name web \ -p 8080:80 \ -v $PWD/conf.d:/etc/nginx/conf.d:ro \ -v $PWD/html:/usr/share/nginx/html:ro \ nginx:1.25-alpine这样宿主机html/index.html会直接替换镜像里的默认欢迎页conf.d/*.conf会成为新的站点配置。:ro表示只读挂载防止容器进程意外改动宿主文件也提醒自己“这层是外部数据”。很多人第一次修改 Nginx 配置后直接重启容器发现配置生效了但心里没底。更好的习惯是分两步验证docker exec web nginx -t docker exec web nginx -s reloadnginx -t检查配置文件语法通过了再reload平滑重载不需要重启容器。这个流程和宿主机上的运维操作一样但命令要加docker exec因为容器本身没有 sshd。再补充一个非常容易踩的细节单文件挂载。有些 Nginx 镜像里/etc/nginx/nginx.conf本身是符号链接指向另一个文件这时你用-v单文件挂载容器内看到的可能是空目录或挂不上一层。所以我更推荐挂载目录而不是单文件或者先docker cp把默认配置完整复制出来再改成自己想要的。比如在conf.d/default.conf里加一个反向代理呼应一下 Nginx 最常见的生产用途server { listen 80; server_name example.com; location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这一段就能跑反向代理镜像本身完全不用改挂载目录一换等于换了一套配置。4.2 写 Dockerfile把自己的配置固化进镜像挂载适合开发和临时调试但生产发布时我更推荐把配置固化进镜像。这样镜像本身就是一个“不可变”的完整发布物谁拉下来都能跑不会出现“我忘了挂载配置目录”的低级事故。下面是一个很典型的项目型 DockerfileFROM nginx:1.25.3-alpine COPY nginx.conf /etc/nginx/nginx.conf COPY html /usr/share/nginx/html RUN rm -f /usr/share/nginx/html/index.html \ adduser -D -H -u 1000 appuser EXPOSE 80 STOPSIGNAL SIGQUIT HEALTHCHECK --interval30s --timeout5s \ CMD wget -qO- http://127.0.0.1/healthz || exit 1 CMD [nginx, -g, daemon off;]逐行说明一下意图COPY nginx.conf /etc/nginx/nginx.conf把宿主机的自定义配置放进去。推荐把它排在所有命令前面因为配置改动的频率高放在前面能让 Docker 利用缓存改配置时不会把后面所有层都重建。COPY html /usr/share/nginx/html静态站点文件。RUN ... adduser ...创建非 root 用户。官方 Nginx 镜像的主进程默认是 root 跑worker 进程用 nginx 用户跑具体权限模型看官方脚本。如果你要更严格的安全策略可以在入口脚本里su-exec切到普通用户启动但监听 80 端口通常会受限需要搭配CAP_NET_BIND_SERVICE或在镜像内改用非特权文件绑定。这个属于进阶话题先知道有这回事就好。STOPSIGNAL SIGQUIT告诉 Docker停止容器时给 Nginx 发 SIGQUIT 而不是默认的 SIGTERM。Nginx 收到 SIGQUIT 会做平滑退出等当前请求处理完再停。这一点在后面踩坑节还会再讲。HEALTHCHECK让 Docker 定期探测容器健康状态healthz文件你可以放在html里或者用 Nginx 的location生成一个内部接口。构建命令docker build -t my-nginx:v1 .生成的镜像已经包含了自己的配置和静态页。以后部署只需要docker run my-nginx:v1配置和文件全在里面这就是“固化的发布物”的含义。从分层角度看每一个COPY、RUN都会产生新层所以一个显著教训是尽量让最不容易变的步骤放在 Dockerfile 前面最容易变的步骤放在后面。比如COPY html如果放在最前面每次改一个页面文件后面所有 Nginx 配置层和用户层全部要重跑构建速度肉眼可见地变慢。4.3 镜像瘦身从 nginx:latest 到 nginx:alpine很多人一开始图省事直接nginx:latest如果只是学习没问题但要部署到生产我一般建议换成更小的nginx:alpine甚至nginx:alpine-slim。我把几个常见版本拉下来对比过大致情况如下具体体积随版本变化以实际拉取为准镜像标签基础系统大致体积适合场景nginx:latestDebian较大图省事、需要完整工具链调试nginx:1.25.3Debian较大固定版本、习惯 Debian 生态nginx:alpineAlpine Linux较小生产最常见体积和兼容性平衡nginx:alpine-slimAlpine Linux最小极致精简、能接受功能裁剪切换基础镜像之后写法要跟着变。Debian 系用apt-getAlpine 用apk包名也不一样。我做瘦身时常用三板斧选择 Alpine 或者 Distroless 这类最小基础镜像。把多个 RUN 合并成一条减少中间层的数量。每多一个RUN就多一层层多了构建和拉取都会变慢。清理包管理器缓存。Alpine 的 apk 缓存默认放在/var/cache/apk装完包直接删掉。拿一个自定义镜像做演示FROM alpine:3.19 RUN apk add --no-cache nginx \ rm -rf /var/cache/apk/*--no-cache本身就不留缓存rm -rf双保险。合并成一条RUN既减少了层数也避免中间层里留下临时文件。说到层数还有一个容易走火入魔的地方有人为了追求“0 层”把整个镜像弄成一个巨大的 RUN结果缓存命中率极低只要改一个字符全部重跑。瘦身是为了实用不是为了比赛。如果构建时间、可维护性牺牲太大我宁可选一条更合适的路径。5. 拿 Nginx 镜像当样本踩过的坑tag、时间、端口、退出信号说完了正经用法接下来聊聊我在真实环境里踩过、也帮别人填过的一批坑。每一个都和镜像本身脱不开关系。5.1 别迷信 latest用固定版本和 digestnginx:latest最初看起来人畜无害但某天你重新拉镜像latest可能已经从 1.22 悄悄跳到 1.25。Nginx 默认配置变动、TLS 版本策略调整都可能让线上行为发生变化。这不是危言耸听团队里真出过一次问题一个脚本写死了nginx:latest第二天重新部署后默认的 TLS 配置和之前的 1.22 行为不一样某个老客户端连不上。排查半天最后发现是镜像变了。我现在固定写版本号docker pull nginx:1.25.3-alpine要求更高的时候直接写 digest把拉取结果里那串sha256:...一并锁定docker pull nginx:1.25.3-alpinesha256:xxxxxxxx这样哪怕远程仓库的标签被覆盖只要 digest 不变拉下来的内容就必然一致。这才是真正意义的“不可变”。5.2 容器内 UTC 导致日志时间错乱又一个高频问题容器里 Nginx 的访问日志时间比本地时间晚 8 小时。因为基础镜像默认时区是 UTC/var/log/nginx/access.log里的时间戳自然跟着 UTC 走。有人直接在运行命令里加-e TZAsia/Shanghai就以为搞定了结果一看日志还是 UTC。原因很简单镜像里没有时区数据。Alpine 镜像默认不带/usr/share/zoneinfo/Asia/Shanghai光设TZ环境变量程序也找不到对应文件。我的做法是建镜像时就把时区打好FROM nginx:1.25-alpine RUN apk add --no-cache tzdata \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone \ apk del tzdata ENV TZAsia/Shanghai装完 tzdata 复制出需要的时区文件后再删掉这样最终镜像既能得到正确时区又不会残留一大堆无用时区数据。Debian 系的 Nginx 镜像也可以挂载宿主机的/etc/localtime但 Alpine 系我比较推荐上面这种 Dockerfile 内置方式可移植性更好不依赖宿主机。5.3 端口映射没效果的排查思路“我已经-p 8080:80了为什么访问不到”这是社区里最频繁的问题之一。碰到这种问题我的排查顺序基本固定看容器到底在不在。docker ps -a如果状态是Exited说明是容器启动即退去看docker logs。看端口映射有没有绑定上。docker port web会显示类似80/tcp - 0.0.0.0:8080。如果这里没有输出说明容器启动时根本没有-p或者启动失败。在容器内部自测。docker exec web curl 127.0.0.1如果容器内正常说明 Nginx 本身没问题问题在“容器外”的网络链路上。这时再看宿主机防火墙、端口占用、Docker 网络模式。Nginx 容器通常是 bridge 模式Docker 会通过 NAT 转发端口如果宿主机的 firewall 拦截了对应端口外面照样不通。很多教训其实是第四步踩出来的。不要在容器没起来的时候就怀疑防火墙。先确认镜像本身能跑再谈网络这条经验对任何 Docker 镜像都适用。5.4 让 Nginx 优雅退出而不是被 kill容器停止时Docker 会向主进程发送停止信号。默认是 SIGTERM但因为 Nginx 官方镜像在元数据里设置了STOPSIGNAL SIGQUIT所以容器收到docker stop时Nginx 实际上收到的是 SIGQUIT。SIGQUIT 对 Nginx 意味着“结束主进程但让子进程处理完当前请求后再退出”也就是平滑退出。你在生产环境用docker stop web不会看到连接被硬切断就是这个原因。如果你自己基于 Nginx 镜像做了自定义 Dockerfile默认会继承基础镜像的STOPSIGNAL一般不用改。但有些封装镜像在构建时会覆盖掉导致docker stop变成 SIGTERM。SIGTERM 对 Nginx 也不是立刻粗暴 kill它会尽快退出但长连接场景下可能仍会有个别请求被截断。排查方法也很简单docker inspect web | grep -i stop如果看到StopSignal: SIGQUIT那这个容器用的是平滑退出如果默认 SIGTERM且你的业务对连接中断敏感建议在 Dockerfile 里显式加上STOPSIGNAL SIGQUIT。6. 从 Nginx 镜像总结出的一套 Docker 镜像排查“手筋”到了最后一部分我想把上面这些细碎经验汇总成几个可以复用的方法论。以后你换一个镜像比如 Redis、MySQL照样能套着用。6.1 没有 docker history 的镜像怎么看不是每个镜像都能通过docker history看清构建过程。有些第三方镜像把构建历史打平了或者用了多阶段构建后把中间层清理掉history输出一塌糊涂、全是一堆大块空白层。这时候三个招数轮着来docker inspect 镜像。看RootFS.Layers的层数至少知道这个镜像有多厚。层数特别多 体积特别大往往意味着中间的清理工作没做好。docker save到本地再解开看。把镜像导出成一个 tar 包然后解压你能直接看到每一层的文件内容和目录结构。这相当于绕过了所有封装直接看“成品”docker save nginx:1.25-alpine -o nginx.tar tar -tf nginx.tardocker run --entrypoint sh进入容器交互。如果镜像里带 shell直接进去看文件、看环境变量比什么都直观。很多精简镜像没有 bash但有 sh 就够了。碰到无 history 的镜像不要慌从文件系统下手永远比看构建记录更靠谱。6.2 镜像体积与安全的关键检查点聊完怎么“看”再讲怎么“防”。磁盘占用方面docker system df能一眼看出镜像、容器、卷、构建缓存分别占了多大。很多人不注意构建缓存某个项目反复docker build缓存会越积越厚一个docker system prune能清掉不少临时空间。安全方面有三个点我觉得值得留意非必要不用latest。版本漂移一方面可能带来行为变化另一方面也可能带着已知漏洞的新版本不在团队验证范围内。检查镜像内是否包含多余文件。比如.env、密钥文件、大体积调试包被COPY进镜像是很常见的泄露路径。我每次 build 前都会看.dockerignore有没有把node_modules、.git、.env排除掉。关注基础镜像的更新。Nginx 官方镜像本身更新很勤但如果你自定义了一个 DockerfileFROM nginx:alpine写死的不带版本号下次docker build --pull可能会拉到新的基础版。没有坏处但要确保团队知道基础镜像是会漂移的。最稳妥还是像FROM nginx:1.25.3-alpine这样固定大版本。6.3 我的最终建议把 Nginx 镜像当成年保存的标本如果让我给正在学 Docker 的人一个最朴实的建议那就是把 Nginx 镜像下载到本地当标本一样反复解剖。我自己当年就是把nginx:alpine拉下来开一个容器改配置、删文件、挂载目录、重建镜像来回折腾了一整天才真正把“层”“写层”“挂载”这些事情刻进脑子里。后面再看 Redis、MySQL、Java 应用镜像感觉都只是往同一个框架里添不同的内容罢了。最后再分享一个很顺手的小习惯做镜像实验时多用docker run --rm容器一停就自动删除不会让一堆测试容器堆在你机器上。配合docker diff看看容器里到底改了哪些文件比读任何文档都直观。镜像是 Docker 世界的通货而 Nginx 是理解通货价值的最好样本。花一个晚上把它解剖明白后面所有容器相关的坑你都会有种“我早就知道是怎么回事”的底气。