11573 字
58 分钟
第 2 篇:镜像、容器、仓库与层

第 2 篇:镜像、容器、仓库与层#

0. 本篇定位#

上一篇讲的是后端为什么需要容器化,核心结论是:容器化不是换一种启动命令,而是把后端交付链路里的运行时、应用包、配置边界、发布状态和排障证据显式化。本篇继续往下走,开始拆 Docker 世界最核心的制品模型:镜像、容器、仓库、层、tag 和 digest。

这一篇非常关键,因为后面所有 Dockerfile 优化、镜像瘦身、CI 缓存、仓库治理、Kubernetes 镜像拉取、回滚策略和供应链安全,都建立在这些对象之上。如果镜像和容器分不清,排障时就会把运行时问题误判为构建问题;如果 tag 和 digest 分不清,发布时就会把“名字”误认为“不可变版本”;如果 layer 和业务版本分不清,镜像会越来越大,CI 会越来越慢,节点拉取镜像也会成为发布瓶颈。

读完本篇后,你应该能说清五件事。第一,镜像到底由哪些东西组成,为什么它不是一个简单压缩包。第二,容器和镜像是什么关系,容器可写层为什么不适合保存业务状态。第三,仓库保存的到底是 tag、manifest、config 还是 layer blob。第四,tag 和 digest 在发布、回滚和审计中分别承担什么角色。第五,镜像层顺序为什么会影响构建缓存、分发效率和后端团队的交付速度。

1. 问题背景#

后端团队使用 Docker 一段时间后,最常见的问题不是“不会 build”,而是“构建和运行背后的对象模型不清楚”。例如,本地执行 docker build -t backend-api:latest . 成功了,测试环境也部署了 backend-api:latest,但两边运行出来的内容不同;开发在容器里改了一个文件,以为镜像也被修改了,结果重启后改动消失;CI 每次构建都很慢,明明依赖没变,却总是重新下载 Maven 包;镜像推送仓库后,节点拉取很慢,发布窗口被镜像分发拖住;线上要回滚时,只知道上一个 tag,却无法确认这个 tag 当时指向哪份内容。

这些问题表面上像命令细节,实际上都和制品模型有关。Docker 的镜像不是一个单文件制品,而是由 manifest、config 和多个 layer 组成。容器不是镜像本身,而是以镜像为基础启动出来的运行实例,并叠加一层可写层。仓库不是简单的文件服务器,而是保存 manifest、blob、tag 和 digest 的分发系统。tag 不是不可变版本,它只是一个可读名称;digest 才是内容寻址标识。layer 不是业务模块,也不是版本号,它是文件系统差异层,同时也是缓存和分发复用的基础。

后端工程师如果不理解这些对象,就很容易形成错误直觉。看到容器内文件变化,以为镜像变了;看到 tag 一样,以为内容一样;看到镜像大,以为只是基础镜像问题;看到构建慢,以为只能加机器;看到拉取失败,以为一定是网络问题。实际排障时,这些现象可能分别对应可写层、tag 漂移、layer 设计、构建缓存、仓库鉴权、manifest 架构、节点镜像缓存等不同原因。

这一篇的目标就是把这些对象拆清楚。你不需要背很多底层实现细节,但必须建立一套稳定判断:哪些内容属于镜像,哪些属于容器;哪些标识适合人读,哪些适合机器校验;哪些层会被缓存,哪些操作会破坏缓存;仓库里到底保存了什么;生产环境应该如何记录和引用镜像。

这套判断对 Java 后端尤其重要。Java 服务通常会打成 fat jar,表面看只有一个文件,但这个文件背后包含大量依赖、资源、配置默认值和启动行为。如果每次业务改动都让整个 jar 变化,镜像层也会随之变化;如果 Dockerfile 把依赖下载和源码复制混在一起,CI 缓存就很难命中;如果基础镜像里缺少字体、证书或时区数据,应用可能只有在生成 PDF、调用 HTTPS 或处理定时任务时才暴露问题。镜像制品模型不是平台团队独有的知识,它会直接影响后端服务的构建速度、启动稳定性和发布可回滚性。

还有一个常见误区是把“容器运行成功”和“镜像设计正确”混为一谈。一个镜像可以运行,但仍然可能过大、不可追踪、难以扫描、无法复现、包含敏感文件、缓存命中差、回滚不确定。学习阶段能跑通很重要,工程阶段要继续追问:这个镜像能不能被另一个环境可靠拉取,能不能被另一个人解释,能不能在半年后复现,能不能在安全漏洞出现时快速重建,能不能在事故中证明它的来源。

2. 核心概念#

先用一张表建立边界。后面所有细节都围绕这几个对象展开。

对象负责什么不负责什么后端工程里的关键判断
Image只读制品,包含 manifest、config 和一组文件系统层不保存容器运行后的写入状态生产发布应该引用可追踪镜像,而不是依赖机器目录
Container由镜像创建的运行实例,包含进程、隔离视图和可写层不应该承载长期业务数据容器可删可重建,状态应进入外部存储
Layer镜像文件系统的增量差异,是缓存和分发复用的基础不表达业务语义版本Dockerfile 顺序会影响缓存命中和镜像大小
Registry保存和分发 manifest、config、layer blob、tag、digest不替代测试、安全扫描和发布审批CI 和运行环境通过仓库解耦
Tag指向镜像 manifest 的可读名称默认不保证不可变适合人类识别版本,但不能单独承担审计
Digest对 manifest 或 blob 的内容哈希引用不如 tag 易读适合生产确定性发布、回滚和供应链校验

2.1 Image、Container、Layer、Registry、Digest 的边界#

镜像是只读制品。它可以被推送到仓库,可以被不同机器拉取,可以在不同环境启动多个容器。镜像里包含应用运行需要的文件系统内容、默认启动命令、环境变量默认值、工作目录、暴露端口、label 等元数据。更精确地看,镜像通常由 manifest 指向 config 和一组 layer blob。manifest 描述这份镜像由哪些内容组成,config 记录镜像配置和历史,layer blob 保存文件系统差异。

容器是运行实例。你可以从同一份镜像启动多个容器,每个容器都有自己的进程、网络命名空间、文件系统视图和可写层。容器启动以后,应用写入的临时文件、日志文件、缓存文件如果没有挂载到外部 volume,通常会落在容器可写层里。这个可写层随着容器删除而消失,所以它不适合保存业务状态。后端服务里上传文件、报表文件、任务状态、队列偏移、数据库数据,都不应该依赖容器可写层。

Layer 是镜像构建和分发的核心。Dockerfile 中很多指令都会生成新的层,例如安装系统包、复制依赖、复制应用包。层是增量的,后面的层叠加在前面的层之上,最终组合成容器看到的文件系统。层的好处是缓存和复用:多个镜像可以共享基础层,构建时未变化的步骤可以复用缓存,推送和拉取时已有 blob 不需要重复传输。层的风险是设计不当会造成缓存失效、镜像膨胀和敏感信息残留。

Registry 是镜像分发系统。它不只是保存一个 tar 文件,而是保存 tag 到 manifest 的引用、manifest 到 config 和 layer blob 的引用,以及这些内容对应的 digest。docker push 时,客户端会把本地缺失于仓库的 blob 上传,再上传 manifest 并更新 tag。docker pull 时,客户端先解析 tag 或 digest,再下载 manifest、config 和缺失层。理解这个过程,才能解释为什么某些镜像拉取很快,某些很慢;为什么同一个基础镜像的多个服务可以复用节点缓存;为什么 tag 覆盖会带来发布歧义。

Digest 是内容寻址标识。tag 像“版本名字”,digest 像“内容指纹”。tag 可以被覆盖,digest 由内容计算得出。生产环境如果只记录 tag,事故复盘时可能无法证明当时运行的内容;如果记录 digest,就能确认运行实例拉取的是哪份 manifest。对于多架构镜像,还要注意 manifest list 或 image index 的 digest 和具体平台镜像 manifest 的 digest 可能不是同一个层次。后端团队不一定每天处理这个细节,但在多架构构建、国产化环境或 ARM 节点上,它会影响镜像拉取和运行结果。

可以把镜像理解为一份索引加一组内容块。manifest 像目录,告诉运行时应该拿哪些 config 和 layers;config 像说明书,记录入口命令、环境变量、工作目录、历史指令和默认参数;layer blob 像文件系统差异包,按照顺序叠加后形成最终根文件系统。这个模型解释了很多现象:为什么多个镜像能共享基础层,为什么改一个 jar 只会产生新的上层,为什么相同基础镜像的服务在节点上拉取更快,为什么删除容器不会删除镜像层,为什么修改运行中容器不会反向改变镜像。

容器可写层也要放在这个模型里理解。容器启动时,底下是镜像只读层,上面叠加一层可写层。应用写 /tmp、写本地日志、写上传文件,如果没有挂载外部存储,就会进入这层可写层。可写层属于容器,不属于镜像,也不属于仓库。docker commit 可以把容器状态提交成新镜像,但这不是后端生产交付的正常方式,因为它绕过了 Dockerfile、CI、测试、扫描和审计。生产镜像应该由声明式构建产生,而不是由手工修改容器现场沉淀。

tag 和 digest 的治理也要分场景。开发阶段可以使用 backend-api:devbackend-api:local,因为它们强调快速迭代;测试阶段应该使用唯一 tag,保证测试报告能指向具体构建;预发和生产阶段应该记录 digest,保证发布和回滚能确定内容;安全扫描和供应链系统也应该围绕 digest 记录结果,因为扫描的是某份具体内容,而不是某个可能变化的名字。把所有阶段都用同一个 tag,是把方便留给开发,把风险留给排障。

2.2 层与缓存:为什么 Dockerfile 顺序会影响交付速度#

Dockerfile 的每一步不是随便排列的。构建缓存通常会根据指令内容、构建上下文中文件内容、上一层状态等因素判断能否复用。如果前面的步骤变化,后面的缓存通常也会失效。对于 Java 后端项目,最常见的优化思路是把变化频率低的内容放在前面,把变化频率高的业务代码放在后面。

例如 Maven 项目中,pom.xml 的变化频率通常低于业务源码。如果使用多阶段构建,可以先复制 pom.xml 下载依赖,再复制源码编译。这样只改业务代码时,依赖下载层可以复用。反过来,如果一开始就 COPY . .,任何源码、README、临时文件变化都可能让后续依赖下载缓存失效。构建慢并不总是机器性能问题,很多时候是层顺序和构建上下文设计问题。

层还会影响镜像大小。很多人以为在 Dockerfile 后面执行 rm -rf 就能删除前面层里的大文件,但如果大文件已经进入某一层,后面层的删除只是新增一个“删除标记”,并不一定让历史层体积消失。正确做法是在同一个 RUN 指令中安装和清理,或者使用多阶段构建,让构建工具、源码和中间产物不要进入最终运行镜像。对后端服务来说,最终镜像通常只需要 JRE、应用包、必要证书、字体和运行脚本,不需要 Maven 缓存、源码、测试报告和编译工具。

层设计还会影响安全。把密钥复制进镜像后再删除,不代表密钥没有进入历史层;把内部仓库 token 写进构建参数,也可能进入构建记录或镜像历史。生产构建要避免让敏感内容进入 layer,必要时使用 BuildKit secret、CI 密钥注入和私有依赖代理。镜像层一旦推送到仓库,就很难保证所有副本都被彻底清除,所以不要把“删除文件”当作泄露补救策略。

层还会影响漏洞修复的节奏。假设几十个后端服务都基于同一个 JRE 基础镜像,如果基础镜像出现高危漏洞,团队需要知道哪些业务镜像继承了这层,哪些镜像已经重建,哪些环境还在运行旧 digest。共享基础层让分发更高效,也让治理更集中。平台团队可以维护受信基础镜像,应用团队定期重建业务镜像,这样漏洞修复不需要每个项目各自摸索。

在 Java 场景里,层缓存还有更细的实践。Spring Boot 支持 layered jar,可以把 dependencies、spring-boot-loader、snapshot-dependencies、application 拆成不同层。这样业务代码变化时,不必让所有依赖层都变化。即使不使用 layered jar,也应该通过 Dockerfile 顺序减少无谓变化。比如先复制依赖描述,再下载依赖,最后复制应用包;先固定基础系统依赖,再复制高频变化的业务制品。这样的优化不是为了追求极致性能,而是让团队日常构建更稳定,发布时节点拉取更可控。

2.3 tag 与 digest 决定发布确定性#

本篇所有概念最终会落到发布确定性上:Image 决定制品内容,Container 决定运行实例,Layer 决定缓存与分发,Registry 决定制品流转,Tag 决定人类可读引用,Digest 决定机器可验证内容。只要 tag 和 digest 的边界不清,后续测试、预发、生产和回滚都会缺少共同事实。

3. 运行机制#

3.1 三条路径:构建、分发、运行#

镜像制品模型可以按三条路径理解:构建路径、分发路径、运行路径。

构建路径从 Dockerfile 和构建上下文开始。构建器读取 Dockerfile,每执行一步,就基于上一层状态生成新的文件系统差异和元数据。构建完成后,本地会得到一个镜像引用,例如 backend-api:1.0.0。这个引用指向本地镜像记录,镜像记录再指向 config 和 layers。构建缓存让相同输入可以复用已有层,减少重复工作。

分发路径从 docker push 开始。客户端把镜像需要的 layer blob、config 和 manifest 推送到 registry。仓库如果已经有某些 blob,就不需要重复上传。最后 tag 会指向这份 manifest。运行环境执行 docker pull backend-api:1.0.0 时,会解析 tag,拉取 manifest,下载本地缺失的层,把这些层组合成可运行镜像。节点上如果已经存在相同层,就可以复用缓存。

运行路径从 docker run 或编排平台创建容器开始。运行时基于镜像只读层创建容器文件系统,并叠加一层可写层;根据镜像 config 和运行参数决定入口命令、环境变量、工作目录、端口、挂载、网络、资源限制;最后启动进程。进程运行期间写入容器文件系统的内容进入可写层,除非路径挂载了 volume 或 bind mount。

这三条路径之间有清晰的责任分工。构建路径负责把源码和运行时变成镜像,分发路径负责让不同环境拿到同一份镜像,运行路径负责把镜像变成进程。不要让构建路径读取生产配置,不要让分发路径承担质量验证,不要让运行路径临时补齐构建遗漏的文件。边界越清楚,问题越容易定位;边界越混乱,团队越容易靠“再试一次”来解决本该由流程保证的事情。

运行时参数对镜像 config 有覆盖能力,这一点也很重要。镜像里可以声明默认 entrypoint、cmd、env、workdir,但 docker run、Compose、Kubernetes Pod spec 都可能覆盖它们。排障时不能只看 Dockerfile,也不能只看部署 YAML,而要比较“镜像默认值”和“运行时覆盖值”。例如 Dockerfile 里写了正确的 entrypoint,但部署模板覆盖了 command;镜像里默认 SPRING_PROFILES_ACTIVE=prod,但测试环境运行时注入了 test;镜像暴露 8080,运行时却映射了另一个端口。很多问题就发生在这个合并边界。

3.2 build、push、pull、run 的对象流转#

可以用伪代码把对象流转串起来:

function dockerBuild(dockerfile, context, tag):
baseImage = resolveBaseImage(dockerfile.FROM)
currentRootfs = baseImage.layers
for instruction in dockerfile.instructions:
cacheKey = hash(instruction, selectedContextFiles, currentRootfs)
if cache.exists(cacheKey):
layer = cache.get(cacheKey)
else:
layer = executeInstruction(instruction, currentRootfs)
cache.save(cacheKey, layer)
currentRootfs = currentRootfs + layer
config = createImageConfig(entrypoint, env, workdir, labels, history)
manifest = createManifest(config, currentRootfs.layers)
localStore.save(tag, manifest, config, layers)
return imageRef(tag, digest(manifest))

这段伪代码说明三件事。第一,构建缓存不是魔法,它依赖输入是否稳定。第二,镜像最终不是只有一个文件,而是 manifest、config 和 layers 的组合。第三,tag 只是保存到本地或仓库里的引用名,真正能确定内容的是 manifest digest。

再看推送和拉取:

function dockerPush(imageRef, registry):
manifest = localStore.resolve(imageRef)
for blob in manifest.config + manifest.layers:
if not registry.hasBlob(blob.digest):
registry.uploadBlob(blob)
registry.uploadManifest(manifest)
registry.updateTag(imageRef.tag, manifest.digest)
function dockerPull(reference, registry):
manifestDigest = registry.resolve(reference) // tag or digest
manifest = registry.getManifest(manifestDigest)
for blobDigest in manifest.config + manifest.layers:
if not localStore.hasBlob(blobDigest):
localStore.downloadBlob(blobDigest)
localStore.createImageRecord(reference, manifest)

这里最重要的是 resolve(reference)。如果 reference 是 tag,它需要先解析 tag 当前指向哪个 manifest;如果 reference 是 digest,就直接指向内容。tag 的解析结果可能随时间变化,digest 的解析结果应该由内容决定。这就是为什么生产发布记录最好保存 digest。

推送过程也解释了为什么“仓库里有 tag”不一定意味着节点能正常运行。tag 可能存在,但对应架构不匹配;manifest 可能存在,但某个 blob 上传失败或被清理;仓库可能可访问,但节点没有拉取权限;镜像可以拉取,但基础层里缺少应用运行需要的系统能力。发布系统如果只检查 tag 是否存在,仍然可能在运行阶段失败。更可靠的做法是在 CI 或发布前执行镜像拉取验证、架构验证、签名验证、扫描验证和最小启动验证。

在 Kubernetes 中,imagePullPolicy 还会影响拉取行为。IfNotPresent 会优先使用节点已有镜像,Always 会每次尝试解析远端引用。使用可变 tag 时,节点缓存可能让不同节点运行不同内容;使用不可变 tag 或 digest 时,缓存更容易被正确理解。后端团队不必一开始背所有策略,但要知道:镜像引用方式、节点缓存和拉取策略共同决定了实际运行内容。

3.3 状态和失败证据:不要把所有问题都归因到应用#

镜像拉不下来,不一定是网络问题,可能是仓库鉴权、tag 不存在、manifest 架构不匹配、blob 缺失、节点磁盘满、镜像策略禁止拉取。容器启动失败,不一定是业务代码问题,可能是 entrypoint 写错、运行用户权限不足、工作目录不对、文件不存在、CPU 架构不匹配、基础镜像缺少证书或字体。构建缓存失效,不一定是 CI 差,可能是 Dockerfile 顺序、.dockerignore、构建上下文和依赖下载方式不合理。

排障时应该先确认对象阶段。构建阶段看 build log、Dockerfile、上下文、缓存命中和基础镜像。分发阶段看 registry、tag、digest、manifest、blob、鉴权和网络。运行阶段看容器状态、退出码、entrypoint、环境变量、挂载、可写层和日志。发布阶段看部署声明、镜像引用、拉取策略、节点缓存、rollout 状态和事件。只有先定位阶段,命令才有意义。

这套思路会直接改变后端排障效率。比如用户反馈新版本接口异常,如果镜像 digest 和预发一致,配置也一致,问题更可能在流量、依赖或数据;如果 digest 不一致,说明测试和生产实际运行的不是同一份制品;如果 digest 一致但容器启动命令被部署参数覆盖,问题可能在发布模板;如果镜像一致、命令一致、配置一致,但节点架构不同,问题可能在多架构镜像。对象模型越清楚,假设就越少,证据链就越短。

4. 最小可运行示例#

4.1 示例目标与输入假设#

下面用一个最小 Java 后端镜像展示 build、inspect、history、run、tag、push 的关系。示例假设当前目录已经有 target/backend-api.jar

4.2 最小 Dockerfile#

Dockerfile

FROM eclipse-temurin:21-jre
WORKDIR /app
LABEL org.opencontainers.image.title="backend-api"
LABEL org.opencontainers.image.description="Demo backend API"
COPY target/backend-api.jar /app/backend-api.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/backend-api.jar"]

4.3 构建、运行、打 tag 与推送#

构建镜像:

Terminal window
docker build -t backend-api:1.0.0 .

查看镜像列表:

Terminal window
docker images backend-api

查看镜像元数据:

Terminal window
docker image inspect backend-api:1.0.0

查看镜像历史层:

Terminal window
docker history backend-api:1.0.0

启动容器:

Terminal window
docker run --name backend-api-demo -p 8080:8080 backend-api:1.0.0

查看运行容器:

Terminal window
docker ps

进入容器观察文件系统:

Terminal window
docker exec -it backend-api-demo sh

给镜像打仓库 tag:

Terminal window
docker tag backend-api:1.0.0 registry.example.com/backend/backend-api:1.0.0

推送镜像:

Terminal window
docker push registry.example.com/backend/backend-api:1.0.0

这个示例里有三个容易误解的点。第一,docker build -t 只是给构建结果加了一个名字,并不意味着这个 tag 永远不变。第二,docker run 创建的是容器实例,不是复制一份新镜像。你在容器里写入的文件进入可写层,删除容器后通常就没了。第三,docker push 推送到仓库后,其他环境拉取的是仓库中 tag 当前指向的 manifest,所以生产发布必须有更严格的 tag 和 digest 策略。

如果想观察 digest,可以在推送后查看镜像仓库返回的 digest,或者使用 docker image inspect 查看 RepoDigestsRepoDigests 表示这个本地镜像和某个仓库中的 digest 关联过。注意,本地构建但尚未推送的镜像可能只有本地 image ID,没有仓库 repo digest。这个细节会影响排障:本地 image ID 不能直接证明生产仓库里运行的是哪份内容,发布记录应该保存仓库引用和 digest。

如果想观察可写层,可以启动容器后在容器里创建一个文件,然后删除容器再重新启动同一镜像。你会发现文件不会保留,除非它写在挂载目录里。这不是 Docker 的缺陷,而是容器模型的设计目标。后端服务应该把“可丢弃”和“必须持久”的路径区分清楚。临时解压目录、上传缓存、导出文件、日志缓冲、SQLite 文件、H2 数据库文件,如果没有明确生命周期,都可能在容器重建时变成事故。

5. 配置逐行拆解#

5.1 Dockerfile 字段:镜像内容从哪里来#

FROM eclipse-temurin:21-jre 决定基础层。基础镜像通常占据镜像体积的大头,也决定系统库、证书、字体、时区、包管理器、用户模型和安全更新策略。后端项目不要随意选择基础镜像。JRE 镜像比 JDK 镜像更适合运行阶段,多阶段构建可以把编译工具留在构建阶段。生产环境还要考虑基础镜像漏洞修复和升级节奏。

WORKDIR /app 决定默认工作目录。Java 应用如果使用相对路径读取模板、证书或配置,工作目录变化会直接导致启动失败。容器化后最好减少隐式相对路径,关键路径在启动日志中打印出来。

LABEL 写入镜像元数据。示例只写了 title 和 description,生产中可以加入 org.opencontainers.image.revisioncreatedsourceversion 等信息。label 不是装饰,它能帮助镜像仓库、扫描工具、发布系统和排障人员把镜像和代码仓库、构建任务关联起来。

COPY target/backend-api.jar /app/backend-api.jar 把应用包放进镜像层。这个指令通常变化频率高,因为业务代码经常变化。为了提升缓存命中,依赖下载、基础系统包安装、运行用户创建等低频步骤应该放在它之前。对于 Spring Boot fat jar,每次业务代码变化通常都会改变 jar,导致这一层之后的缓存失效,所以后续指令不要放太多重操作。

EXPOSE 8080 是元数据,不发布端口。它告诉使用者镜像默认监听 8080,但真正的端口映射由 docker run -p、Compose ports、Kubernetes Service 或 Ingress 决定。排障时不要看到 Dockerfile 里有 EXPOSE 就以为外部一定能访问。

ENTRYPOINT ["java", "-jar", "/app/backend-api.jar"] 是默认启动入口。它决定容器启动时运行什么进程。后端服务要注意信号处理,Java 进程作为主进程更容易接收 SIGTERM 并触发优雅停机。如果使用 shell 包一层,要确认信号是否能传递到 Java 进程。后续 Dockerfile 最佳实践篇会展开这个问题。

5.2 build、tag、push、run 的字段含义#

docker build -t backend-api:1.0.0 . 中,. 是构建上下文,Dockerfile 能复制的文件来自这个上下文。上下文越大,构建越慢,也越容易把无关文件带入缓存计算。.dockerignore 应该排除 .git、IDE 文件、日志、临时文件、测试输出和本地密钥。

-t backend-api:1.0.0 是给镜像打 tag。tag 适合人读,但它不是不可变保证。团队可以约定不覆盖发布 tag,例如每个 tag 包含日期和 commit,但更稳妥的是发布记录里同时保存 digest。不要在生产长期使用 latest,它会把确定性发布变成“拉取当前名字指向的内容”。

docker image inspect 可以看到镜像 config、环境变量、入口命令、工作目录、label、层信息等。它适合排查“镜像里到底写了什么”。docker history 可以看到每层由哪个指令生成,适合排查镜像为什么大、敏感信息是否可能进入历史、构建顺序是否合理。

docker run --name backend-api-demo -p 8080:8080 backend-api:1.0.0 中,--name 是容器名,-p 是端口映射,最后的 backend-api:1.0.0 是镜像引用。运行参数可以覆盖镜像 config,例如环境变量、命令、挂载、网络和资源限制。排障时要同时看镜像默认值和运行时覆盖值。

docker push registry.example.com/backend/backend-api:1.0.0 会把镜像内容推送到远端仓库。推送前通常需要 docker login 或 CI 注入仓库凭证。推送成功不代表镜像质量合格,它只代表仓库接收了这份制品。质量门禁应该由测试、扫描、签名、审批和发布策略共同完成。

5.3 pull、history 与供应链观察#

docker pull 虽然没有出现在 Dockerfile 中,但它是运行环境最关键的动作之一。节点拉取镜像时,需要解析仓库地址、完成鉴权、获取 manifest、判断架构、下载缺失 blob、解压层并登记本地镜像。如果节点磁盘不足、仓库证书不受信、代理配置错误、DNS 不稳定、镜像架构不匹配,都会在拉取阶段失败。后端同学看到 ImagePullBackOff 时,不要第一反应去改业务代码。

docker history 的输出也要正确理解。它能看到每层大致来自哪个指令,但不一定完整展示所有构建细节。使用 BuildKit、多阶段构建或压缩层后,历史信息可能和直觉不完全一致。它适合做初步定位:哪一步让镜像变大,是否出现了不该存在的命令,是否复制了过多文件。真正严肃的供应链审计,还需要 SBOM、扫描报告、构建 provenance 和仓库审计记录。

6. 后端工程场景#

6.1 本地、CI、测试与生产的关注点#

本地开发阶段,镜像、容器、层和仓库帮助团队把“怎么跑起来”变成统一入口。开发者可以用同一个镜像启动服务,也可以用 Compose 启动依赖。此时层缓存能显著影响体验。如果每次改一行代码都要重新下载依赖,团队很快会绕开容器化,回到各自本地手工环境。

CI 阶段,镜像成为交付制品。CI 不应该只构建镜像,还应该记录 commit、tag、digest、基础镜像、测试结果和扫描结果。对于后端团队来说,仓库是构建端和运行端的交界点:CI 负责产出可信制品,部署系统负责消费可信制品。不要让开发者在本地构建一个同名 tag 直接推生产仓库,这会破坏制品来源。

测试环境阶段,tag 和 digest 的治理会直接影响测试可信度。如果测试环境和预发环境使用不同镜像内容,却都叫 backend-api:test,测试结论就没有工程意义。更好的方式是每次构建产生唯一 tag,测试环境部署这份 tag,并在测试报告中记录 digest。这样缺陷回放时才能知道当时测的是什么。

生产阶段,容器可写层和镜像层边界尤其重要。业务文件不能写入容器可写层后假设长期存在,日志不应该只写容器内部文件,临时缓存要能承受重启丢失。生产实例应该随时可以被删除和重建,镜像应该随时可以被重新拉取,状态应该由外部系统承担。

6.2 订单 API 的制品治理案例#

假设订单 API 初始阶段使用 backend-api:latest。开发每次构建后推送同一个 tag,测试环境和生产环境也都写这个 tag。短期看很方便,长期会出现三个问题。第一,测试环境今天拉取的 latest 和生产明天拉取的 latest 可能不是同一份内容。第二,回滚时不知道上一个 latest 的 digest。第三,仓库清理或人工覆盖 tag 后,事故复盘无法证明当时运行内容。

更稳妥的方案是:CI 为每次主干构建生成唯一 tag,例如 backend-api:2026.07.05-1432-a1b2c3d,推送后记录 digest;测试、预发和生产都引用这份唯一 tag;发布单记录镜像 digest、配置版本、数据库迁移版本和操作者;回滚时选择上一条成功发布记录,而不是猜一个 tag。这样 tag 负责可读性,digest 负责确定性,发布记录负责把镜像和配置组合起来。

镜像层也要治理。订单 API 的 Dockerfile 如果先复制整个项目再下载依赖,CI 每次都会重新下载 Maven 依赖;如果把依赖解析放在源码复制之前,缓存命中会高很多。最终运行镜像不需要 Maven、本地仓库和源码,只需要 JRE、jar、证书、字体和运行用户。这样镜像更小,扫描面更小,节点拉取更快,发布失败面也更小。

这个案例还可以继续往生产推一步。订单 API 如果要支持多可用区部署,镜像仓库可用性会变成发布依赖;如果要支持 ARM 和 x86 混合节点,多架构 manifest 会变成运行前提;如果要支持严格合规,镜像签名和 SBOM 会进入发布门禁;如果要支持快速回滚,仓库保留策略必须保证上一稳定版本不会被清理。镜像制品看似只是一个后端服务的包装,实际会连接到组织级发布、合规和基础设施能力。

6.3 把制品治理沉淀成模板#

团队也可以把制品治理沉淀成模板。应用仓库只需要提供 Dockerfile、构建参数和服务元数据;CI 模板负责生成唯一 tag、写入 label、推送仓库、输出 digest、触发扫描;部署模板负责引用 digest、注入配置、设置拉取策略和记录发布历史。这样新服务接入时,不需要每个团队重新设计一遍镜像治理,平台也能统一检查风险。

7. 常见错误与排障#

本篇的排障重点是制品链路。很多问题看起来像应用 bug,其实是镜像引用、仓库分发、层缓存或容器可写层导致的。

7.1 tag、体积、缓存和可写层问题#

第一类是“同一个 tag 在不同环境内容不同”。现象是测试通过的版本上线后行为不同,或者两个环境都显示 backend-api:latest,但功能不一致。可能原因是 tag 被覆盖、环境拉取时间不同、节点使用了旧缓存、发布记录没有保存 digest。排查时先比较两边运行实例的 image ID、repo digest、发布单和仓库 tag 当前指向。修复方向是重新发布确定 digest 的镜像。预防方式是禁止覆盖发布 tag,发布记录保存 digest。

第二类是“镜像异常大”。现象是 CI 推送慢、节点拉取慢、扫描慢,甚至节点磁盘压力高。可能原因是使用 JDK 作为运行镜像,复制了源码、测试产物、Maven 缓存、日志或临时文件,或者在不同层中先新增大文件再删除。排查时使用 docker history 查看层大小,检查 .dockerignore 和 Dockerfile。修复方向是多阶段构建、缩小上下文、同层清理缓存、只复制运行所需文件。预防方式是为镜像大小设置 CI 门禁。

第三类是“CI 缓存完全失效”。现象是每次构建都重新下载依赖,构建时间不稳定。可能原因是 COPY . . 放得太早,.dockerignore 缺失,构建上下文包含经常变化的文件,依赖下载步骤放在源码复制之后。排查时看构建日志中的 cache hit/miss,比较 Dockerfile 指令顺序。修复方向是先复制依赖描述文件,再下载依赖,再复制源码。预防方式是建立语言栈 Dockerfile 模板。

第四类是“容器内文件修改后镜像没有变化”。现象是开发进入容器改了配置或文件,当前容器短暂生效,但重建后消失。原因是修改发生在容器可写层,不会反向写入镜像。排查时确认修改路径是否位于 volume,容器是否被删除重建。修复方向是把变更写回 Dockerfile、配置文件、外部配置或 volume。预防方式是禁止把运行中容器当作可维护服务器。

第五类是“节点拉取镜像失败”。现象是 Kubernetes Pod 进入 ImagePullBackOff 或本地 docker pull 报错。可能原因包括仓库地址错误、tag 不存在、鉴权失败、网络阻断、镜像架构不匹配、仓库证书不受信任、节点磁盘满。排查时看事件、拉取错误、仓库权限、manifest 架构和节点磁盘。修复方向根据证据处理,不要只重启 Pod。预防方式是发布前验证镜像存在、权限可用、架构匹配,并监控仓库可用性。

这五类问题都提醒我们:制品链路必须可观察。只要镜像 tag、digest、build id、commit、仓库地址、运行实例、配置版本之间无法关联,排障就会退回猜测。后端团队应该把这些信息写进 CI 输出、发布单、应用启动日志和监控标签。

7.2 架构与漏洞要单独定位#

还有一类隐蔽问题是“镜像内容正确,但运行架构不正确”。例如开发在 x86 机器上构建镜像,生产部分节点是 ARM;或者多架构镜像构建时只推了某个平台的 manifest。现象可能是拉取失败,也可能是容器启动后立即报 exec format error。排查时要看镜像 manifest 支持的平台、节点架构和构建命令。预防方式是在 CI 中明确构建平台,并在发布前验证目标集群架构。

“镜像被扫描出漏洞”也要具体分析。漏洞可能来自基础镜像系统包,也可能来自应用依赖,也可能是扫描器误报或不可触达路径。修复时先定位漏洞来源:如果是基础镜像,重建业务镜像可能就能修复;如果是应用依赖,需要升级 Maven/Gradle 依赖;如果是运行阶段不需要的构建工具,就应该用多阶段构建移出最终镜像。不要把所有漏洞都交给应用代码,也不要把所有漏洞都归因到基础镜像。

7.3 最终回到内容一致性#

排障最终要回到一个问题:现在运行的内容是否和我们以为的一致。如果一致,再查应用逻辑、配置和依赖;如果不一致,先修正制品链路。很多团队在这一步浪费大量时间,因为他们没有保存 digest,只能用 tag 猜测;没有保存构建记录,只能问开发;没有启动日志版本,只能进入容器看文件;没有发布历史,只能翻聊天记录。制品治理的目的,就是让这类问题变成查询,而不是侦探工作。

8. 生产化边界#

8.1 镜像、层和可写层的生产边界#

生产环境的镜像治理至少包含五条边界。第一,版本不可变。发布 tag 不应该覆盖,发布记录必须保存 digest。第二,来源可信。镜像必须由 CI 构建,不能由个人电脑随意推送生产仓库。第三,内容可审计。镜像应该带有 commit、构建时间、源码地址、版本等元数据,并保留测试和扫描结果。第四,权限最小。仓库推送、拉取、删除权限要分离,生产环境只需要拉取权限。第五,生命周期明确。镜像要有保留策略,不能刚发布就被清理,也不能无限堆积。

层治理也有生产边界。基础镜像要定期升级,但升级不能靠“看到漏洞就手工改一下”,而要有重建、测试、扫描、灰度和回滚流程。多服务共享基础镜像时,平台团队可以维护统一基础镜像,应用团队基于它构建业务镜像。这样既能减少重复治理,也能统一安全补丁节奏。

容器可写层在生产中应该被视为临时空间。应用可以写临时文件,但必须能承受容器重建后丢失。业务数据进入数据库、对象存储、消息队列或持久化卷;日志进入标准输出或统一采集;缓存要可重建;上传文件不应该默认留在容器本地。这个边界一旦不清,扩缩容、滚动发布、节点故障都会导致数据不一致。

8.2 制品治理从“能推送”升级到“能证明”#

生产化的核心不是“镜像能推到仓库”,而是“团队能证明这份镜像从哪里来、包含什么、在哪里运行、出了问题怎么回去”。证明来源,需要 CI 记录和镜像 label;证明内容,需要扫描报告、SBOM、基础镜像版本和依赖清单;证明运行,需要部署系统记录 digest、环境、配置版本和实例;证明可回滚,需要保留上一组镜像和配置;证明合规,需要仓库权限、签名、审计和清理策略。

很多团队只做到了第一步:镜像能 build、能 push、能 pull。真正进入生产后,这还远远不够。镜像仓库如果没有权限边界,任何人都能覆盖 tag;没有保留策略,旧版本可能被清理;没有扫描门禁,高危漏洞可能进入生产;没有 digest 记录,回滚会变成猜测;没有基础镜像治理,漏洞修复会在几十个服务中重复劳动。制品治理的价值,是让镜像从“文件”变成“可被组织管理的资产”。

制品治理还要有成本意识。镜像过多会占用仓库存储,镜像过大会拖慢分发,扫描过慢会拖慢发布,保留策略过短会影响回滚,保留策略过长会增加成本。合理做法不是无限保留所有镜像,也不是快速清理所有旧镜像,而是按环境和发布状态分层:生产成功版本保留更久,测试临时版本保留较短,关键回滚点受保护,未被引用的开发镜像定期清理。仓库治理应该服务发布安全,而不是单纯追求空间最小。

8.3 签名、SBOM 与供应链能力#

如果团队已经进入更高要求阶段,还可以引入镜像签名和构建来源证明。签名用于证明镜像确实由可信流程产生,provenance 用于记录构建来源、参数和环境,SBOM 用于描述镜像包含的软件组件。这些能力不是第一天必须全部上,但对象模型要先理解清楚。只有知道 manifest、digest、tag 和 registry 的关系,后续谈签名、准入控制和供应链安全才不会悬空。

9. 什么时候用,什么时候不用#

9.1 必须掌握这些对象的场景#

本篇这些对象是 Docker 使用的基础,几乎所有后端容器化项目都绕不开。只要你要构建镜像、推送仓库、部署容器、排查镜像版本、优化构建速度、控制镜像大小、做生产回滚,就必须理解 image、container、registry、layer、tag、digest 的边界。

9.2 治理能力可以按阶段递进#

但不是所有团队都需要一开始就把治理做满。如果项目还处在个人学习阶段,可以先掌握镜像和容器关系、tag 基本用法、Dockerfile 层缓存。进入团队测试环境后,再加入仓库、唯一 tag、CI 构建和镜像大小控制。进入生产后,必须补 digest、权限、扫描、元数据、保留策略和回滚记录。学习顺序可以渐进,生产边界不能缺失。

9.3 判断重点:不同阶段关注不同对象#

本地阶段最关注 image 和 container:镜像怎么构建,容器怎么启动,文件为什么会丢,端口为什么不通。CI 阶段最关注 layer 和 cache:为什么构建慢,为什么缓存失效,为什么镜像大。测试和预发阶段最关注 registry、tag 和 digest:环境到底拉了哪份内容,是否和测试报告一致。生产阶段最关注治理:谁能推送,谁能拉取,怎么证明内容,怎么回滚,旧版本保留多久。

如果团队只是写一个短期脚本,不需要跨机器分发,不需要复现环境,不需要长期回滚,那么完整镜像治理可能过重。但如果服务要进入多环境、多团队、多版本、多实例的后端交付链路,忽略这些对象迟早会在发布和排障中付出代价。最实用的建议是:从第一天就不要滥用 latest,从第一天就写 .dockerignore,从第一天就区分镜像和容器状态。小习惯会决定后面生产化成本。

10. 下一篇衔接#

本篇拆清了镜像、容器、仓库、层、tag 和 digest 的关系。到这里,你应该能理解为什么容器内改文件不会改变镜像,为什么同一个 tag 不一定代表同一份内容,为什么 Dockerfile 顺序会影响构建缓存,为什么镜像仓库不等于质量门禁,为什么生产回滚要记录 digest。

下一篇会进入 Dockerfile 后端最佳实践。也就是把本篇的对象模型落到具体写法上:如何选择基础镜像,如何做多阶段构建,如何安排层顺序,如何处理 JVM 参数,如何使用非 root 用户,如何避免把密钥写进镜像,如何让 Dockerfile 同时兼顾构建速度、镜像大小、安全和可维护性。

11. 官方参考#

第 2 篇:镜像、容器、仓库与层
https://jupiter-ws.cn/posts/backend/container/02_docker_image_container_registry_layers/
作者
Jupiter
发布于
2026-07-05
许可协议
CC BY-NC-SA 4.0