☰
从Dockerfile到Compose与私有仓库:容器交付链路避坑指南
2026/10/26 2:43:41 网站建设 项目流程

Dockerfile 自定义镜像、Docker Compose 编排、Docker 镜像仓库这三件事,几乎是每个后端或运维同学绕不开的一组基础能力。单看每一个都不难,难的是把它们串成一条顺滑的交付链路:代码改了,镜像能稳定重建;本地跑通,换台机器也能一键起来;镜像推到仓库,别人拉下来不会因为标签乱飞而拉错版本。我见过太多项目卡在中间某一环——Dockerfile 写得像流水账、Compose 文件复制粘贴改了三个月没人敢动、私有仓库起了一个却因为证书问题全组改用别的方式传镜像。这篇就把这三块按我实际踩过的顺序拆开讲,从分层缓存的底层逻辑到 Compose 的健康检查写法,再到私有仓库的 TLS 配置和存储回收,每个环节都给出能直接抄的参数和命令,也会说明我为什么这么选。刚接触容器的新手可以照着做一遍,已经在用但总觉得别扭的老手,大概率能在避坑那几节找到共鸣。

1. 先把三个东西的边界理清楚

很多人上手就把这三样东西混在一起学,结果脑子里是一锅粥。我用一句话概括它们的分工:Dockerfile 负责"造",Compose 负责"组",仓库负责"存和分发"。造出来的东西要能被组起来,组起来的东西要能被别人拿到,这三步是一条线上的三个节点,任何一环偷懒,后面都会加倍还回来。

1.1 一条镜像从代码到运行的完整链路

我把一次完整的交付拆成六步,你对照自己的项目看看断在哪一步。第一步,开发者本地写代码,改完提交;第二步,CI 或者本机执行docker build,Dockerfile 逐条指令生成一层层文件系统快照,最终产出一个带标签的镜像;第三步,docker push把镜像分层推到仓库,注意只有本地不存在的层才会真正上传,这也是为什么第二次推送往往快得多;第四步,目标机器docker pull,按 manifest 里的 digest 把各层拉下来组装;第五步,Compose 读取 yaml,创建网络、挂载卷、按依赖顺序起容器;第六步,健康检查通过,流量切进来。

这条链路上有三个"时间点"特别关键:构建时的缓存命中率、拉取时的层复用率、启动时的就绪判断。缓存命中率高,构建就从几分钟降到几十秒;层复用率高,一次发布只传改动的那几十兆;就绪判断做对了,滚动更新才不会出现"新容器还没连上数据库,老容器已经被干掉"的尴尬。后面所有优化,本质上都是在抠这三个点。

我见过一个典型的反面案例:某团队把COPY . .放在 Dockerfile 第二行,紧接着才RUN npm install。结果每次改一行 README,整个依赖安装层全部失效,构建时间稳定在四分钟以上。把这两行的顺序调过来,配合.dockerignore,构建时间直接掉到二十几秒。改动量不到五行,收益却是几十倍,这就是理解链路顺序的价值。

1.2 为什么我劝你彻底放弃 docker commit

新手最容易走的捷径是:起一个基础容器,进去apt install、改配置、装依赖,折腾满意了docker commit一下,镜像就有了。第一次这么干会觉得特别爽,但它埋的雷比省的时间多得多。

最直接的问题是不可复现。三个月后你需要在这个镜像上加一个包,谁也不知道当初在容器里手敲过哪些命令,是改了/etc/apt/sources.list还是临时下过一个二进制。第二个问题是体积失控,commit 会把容器运行期间产生的所有临时文件、日志、缓存全部固化进去,一个本该 200MB 的镜像能做到 1.5GB。第三个问题是没有分层,每次 commit 都是一整层新的,推一次仓库传几百兆,团队网速直接被拖垮。

Dockerfile 的价值不在于"能构建镜像",而在于它是一份可审查、可版本控制、可被他人复现的构建说明书。谁改了什么、为什么加这个环境变量,git blame一目了然。所以我的态度很明确:除了临时排查问题,任何时候都不要用 commit 产出要交付的镜像。真要临时进容器调试,用docker exec -it进去看完就退,别把改动当成成果。

1.3 Compose 的适用边界:什么时候该上,什么时候别硬上

Compose 解决的问题是"一台机器上多个容器的协作关系"。典型场景是:应用 + 数据库 + 缓存 + 反向代理,四五个服务,本地开发、测试环境、小规模单机部署都用得上。它的好处是把docker run那一长串参数固化进文件,端口、卷、环境变量、网络别名全部声明式表达,新人克隆仓库就能跑起来,不用问你"那个数据库密码是多少"。

但它有明确的边界。第一,只管单机。多节点调度、跨主机网络、自动扩缩容,这些不是它的职责,硬要往上叠就得引入额外的编排系统。第二,没有内置的滚动更新和回滚,docker compose up -d是停旧起新,中间有短暂中断,对可用性要求高的服务得接受这点。第三,配置漂移难发现,如果有人docker exec进去手改了配置,重启就没了,这既是缺点也是优点。

我的判断标准是:单机、服务数量在十个以内、不需要跨节点容灾,Compose 完全够用,别为了"显得专业"提前上重编排,运维成本会反过来咬你。等真的出现多机需求了再迁移,那时候你对服务间的依赖关系已经摸得很清楚,迁移反而快。

1.4 镜像仓库不只是"存镜像的地方"

很多人对仓库的理解停留在"能 push 能 pull 就行",于是起个最简陋的服务放内网。用着用着就出问题:谁都能推 latest,某天线上拉到的 latest 和昨天的不一样;磁盘涨到 100% 才发现没人清理;出了安全问题想查某个镜像里到底装了什么版本的基础镜像是哪天的,无从下手。

一个真正可用的仓库至少要提供四件事:访问控制(谁能推、谁能拉、按项目隔离)、标签规范(配合 CI 强制约束,不允许裸推 latest)、存储治理(保留策略 + 垃圾回收)、可追溯(镜像 digest、推送人、推送时间)。前两项靠流程和仓库自身能力,后两项靠定时任务。这几件事在单机 registry 上要自己搭,在企业级方案里通常是现成的。选型的时候别只看"能不能跑起来",要看半年后谁来维护它。

2. Dockerfile:把镜像当代码写

写 Dockerfile 的正确心态是写代码——它会被 review、会被 diff、会在半年后被修改。所以可读性、层数控制、缓存友好度,都得在写的时候就考虑进去,而不是等构建慢到受不了再回头重构。

2.1 分层缓存是理解一切优化行为的钥匙

Docker 构建镜像时,每条指令生成一层,每层带一个基于"父层 ID + 指令内容"算出来的哈希。构建时逐层比对,一旦某层的哈希在本地缓存里存在,就直接复用,不再执行。注意这里的关键:哈希只跟指令文本和父层有关,跟指令执行的结果无关。这意味着只要指令字符串变了,哪怕只是多一个空格,这层和它之后的所有层全部失效。

由此推导出的三条铁律:变化频率低的指令放前面,变化频率高的放后面;把"安装依赖"和"拷贝源码"彻底分开;能用一条RUN干完的事别拆成三条。第三条的原理是每条RUN都是一层,层数多了镜像的元数据开销变大,而且中间产物没法在同一条指令内清理。

举个最常见的 Node 项目写法,我会这样排:

FROM node:20-alpine AS base WORKDIR /app # 只拷贝依赖清单,源码变化不影响这一层缓存 COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force # 源码放在最后 COPY . . CMD ["node", "server.js"]

npm ci比npm install更适合构建场景,它严格按 lock 文件安装,结果确定,而且会先清空 node_modules,避免上次残留污染。--omit=dev跳过开发依赖,npm cache clean --force在同一层里清掉缓存,两者加起来通常能省一百多兆。

对比一下 Java 项目,逻辑完全一样,只是清单文件换成pom.xml:

FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn -B dependency:go-offline COPY src ./src RUN mvn -B clean package -DskipTests

dependency:go-offline这一句是精髓,它把依赖提前拉到本地仓库,形成一个独立缓存层。实测下来,没加这句时改一行 Java 代码要重下全部依赖,加了之后增量构建基本在十秒级。

注意:COPY . .之前一定要有.dockerignore,否则node_modules、.git、target这些目录会被打进构建上下文,光是传输上下文就能耗掉几十秒,而且可能覆盖掉容器内刚装好的依赖。

2.2 指令选择的细节账

COPY和ADD的区别被讲烂了,但真正要紧的部分是:ADD会偷偷解压本地 tar 包、会去拉远程 URL,这两个"贴心"功能会让构建行为变得不可预测。远程 URL 的下载不进缓存校验,内容变了也不会触发重建。所以除非你确实需要解压一个本地压缩包,否则一律用COPY。

RUN的写法有个流传很久但其实是错误的做法:把apt-get update和apt-get install分成两条RUN。分开写的话,update 那层会被缓存很久,等你半年后新增一个包时,install 用的是半年前的索引,轻则装到旧版本,重则 404 直接失败。正确写法是合并成一条,并在末尾清理:

RUN apt-get update \ && apt-get install -y --no-install-recommends curl tzdata \ && rm -rf /var/lib/apt/lists/*

--no-install-recommends能挡掉一大批推荐包,rm -rf必须在同一条指令里执行,否则删除动作在下一层,上一层的大小照样算进镜像。

ENV和ARG常被混用。ARG只在构建期有效,构建完就消失,适合传版本号、镜像源地址这类构建参数;ENV会写进镜像的配置里,容器运行时也能读到,适合传时区、语言编码、运行模式。这里有个安全提醒:不要把密钥、令牌通过ARG或ENV传进去,它们会留在镜像历史里,docker history一条命令就能看见。

CMD和ENTRYPOINT的 shell 形式与 exec 形式的差异,是信号处理问题的根源。写成CMD node server.js(shell 形式),实际启动的是/bin/sh -c "node server.js",你的应用是 sh 的子进程,PID 1 是 sh。sh 不会转发 SIGTERM,docker stop发信号后应用收不到,只能等十秒超时被 SIGKILL 强杀,优雅关闭逻辑全部失效。写成CMD ["node", "server.js"](exec 形式),node 直接是 PID 1,信号正常。

EXPOSE只是文档声明,不实际开放端口,真正映射靠-p或 Compose 的ports。但它是很好的自描述信息,建议写上。HEALTHCHECK后面在 Compose 那节细说,它在镜像里定义一次,所有用这个镜像的地方都继承。

2.3 多阶段构建:一个 Java 服务的体积从 800MB 到 180MB

多阶段构建是 Dockerfile 里收益最大的一招。核心思想:编译环境臃肿但只在构建期需要,运行环境要精简。用多个FROM阶段,最后一个阶段只COPY --from前一个阶段的产物。

以 Spring Boot 为例,不用多阶段时通常是把 jar 拷进一个带 JDK 的镜像,动辄七八百兆。改成下面这样:

FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn -B dependency:go-offline COPY src ./src RUN mvn -B clean package -DskipTests FROM eclipse-temurin:17-jre-alpine WORKDIR /app RUN addgroup -S app && adduser -S -G app app COPY --from=builder /build/target/*.jar app.jar USER app EXPOSE 8080 ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "app.jar"]

最终镜像只有 JRE 加一个 jar,实测一般在一百八到两百二十兆之间,取决于依赖多少。里面有几点值得说:MaxRAMPercentage=75让 JVM 按容器内存限制的百分比估算堆大小,比写死-Xmx更适应不同规格的部署;adduser创建非 root 用户,USER切过去,后面第 2.5 节细讲;COPY --from=builder只搬 jar,Maven 的本地仓库、源码、编译中间件一概不带。

前端项目同理,构建阶段用 node 打包出静态文件,运行阶段换成反向代理镜像,只把dist目录拷过去,镜像能从 1GB 降到 50MB 左右。这种"编译环境和运行环境彻底分离"的思路,适用面比想象中广,任何需要编译产物的语言都能用。

2.4 基础镜像选型对照表与 musl 的坑

基础镜像选错,后面要么体积下不来,要么跑起来就崩。我按常见选项列个对照:

基础镜像类型体积量级优点需要注意的地方
alpine5–10MB极小,攻击面小用 musl libc,某些预编译二进制跑不了;装包用 apk,命令和 apt 不同
debian-slim25–40MB兼容性好,glibc 完整比 alpine 大一圈,包管理器是 apt
完整发行版70–120MB工具齐全,调试方便体积大,无用包多
distroless类20–60MB无 shell、无包管理器,安全性高没法进容器排查,日志全靠应用输出
scratch0绝对最小完全空,只适合静态编译的二进制

Alpine 的坑值得单独说,因为我踩过。它的 C 运行库是 musl,不是广泛使用的 glibc。纯 Go 静态编译的程序、Python 的解释器都没问题,但某些依赖 glibc 特性的预编译二进制(比如一些原生扩展、某些数据库客户端)放进去会报not found或者奇怪的符号错误,排查起来非常费劲,因为报错信息完全不指向根因。我的经验是:不确定就先上 debian-slim,等确认没问题再往 alpine 迁。为了省那二十兆,搭进去半天排查时间,不划算。

distroless适合安全要求高的生产环境,但没有 shell 意味着出问题时你没法exec进去看进程、看文件,只能靠完善的日志和监控。团队排查能力没到那个水平之前,别用。

2.5 安全性收尾:非 root、只读、健康检查

镜像默认以 root 运行容器内进程,这在单机开发环境问题不大,但在多租户或者对外服务的场景里是明显风险点。加一个普通用户的成本极低:

RUN useradd -u 1000 -m -s /bin/bash appuser \ && chown -R appuser:appuser /app USER appuser

注意chown要在USER之前完成,顺序反了会因为没有权限而失败。另外,创建用户这一步尽量和后续的chown合并在同一条指令里,避免多一层。

时区是另一个高频问题。容器默认 UTC,日志时间比东八区差八小时,排查问题时对时间戳能对到怀疑人生。做法是装tzdata并设置环境变量:

ENV TZ=Asia/Shanghai LANG=C.UTF-8 RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

LANG也别漏,某些应用在非 UTF-8 环境下处理中文会出乱码,尤其是日志输出和文件读写。

HEALTHCHECK健康检查在镜像里定义一次即可:

HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ CMD curl -fsS http://localhost:8080/actuator/health || exit 1

start-period很关键,它告诉 Docker 在这段时间内的失败不算数,给应用留足启动时间。没有这个参数,启动慢的 Java 服务会被反复判定为不健康。注意检查命令用的工具必须在镜像里存在,alpine 下curl默认没装,要么装要么换成wget。

2.6 .dockerignore 与构建上下文

.dockerignore是被严重低估的文件。它的作用和.gitignore类似,但影响的是"发送给 Docker 守护进程的构建上下文"。这个上下文在构建开始时会被整个打包传输,一个带node_modules的前端项目,上下文轻松几百兆,每次构建光传输就要几十秒。

我常用的模板:

.git .gitignore node_modules target build dist *.log logs .env.local .idea .vscode README.md docker-compose*.yml

两个细节:一是不要忽略package-lock.json或pom.xml,那是构建必需的;二是如果一个文件既在.dockerignore里又被COPY引用,构建会直接失败,报file not found,很多人排查半天以为是路径写错了,其实是忽略了。

3. Docker Compose:把一组容器当一台机器用

Compose 文件写得好不好,看一个标准就够了:新人克隆仓库,docker compose up -d一条命令,所有服务起来并进入可用状态,不需要看任何文档。达不到这个标准,说明还有环境变量、依赖顺序或者初始化数据没声明清楚。

3.1 compose.yaml 的结构骨架

新版 Compose 已经不需要顶层version字段,写了反而会收到一条废弃警告。基本骨架是这样的:

services: api: build: context: . dockerfile: Dockerfile image: registry.internal.example.com/team/api:1.4.2 ports: - "8080:8080" environment: TZ: Asia/Shanghai SPRING_PROFILES_ACTIVE: docker env_file: - .env.docker volumes: - app-logs:/app/logs networks: - backend depends_on: db: condition: service_healthy restart: unless-stopped healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"] interval: 30s timeout: 3s retries: 3 start_period: 40s db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - db-data:/var/lib/postgresql/data networks: - backend healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s timeout: 5s retries: 5 volumes: app-logs: db-data: networks: backend: driver: bridge

几个点展开说。build和image同时存在时,docker compose build会按 Dockerfile 构建并打上image指定的标签,docker compose pull则直接拉这个标签。这个组合在开发和生产之间切换很方便:本地改代码就 build,服务器上直接 pull。

environment和env_file可以并存,前者优先级更高。密码这类敏感信息放env_file里,并把该文件加入.gitignore,操作用${DB_PASSWORD}变量引用,Compose 会自动从同目录的.env里读。

restart: unless-stopped是我最常用的策略。always的问题是手动停了也会被拉起来,调试时很烦;on-failure遇到正常退出就不重启,有些场景不合适。unless-stopped兼顾两者:崩溃自动拉起,手动停止后保持停止。

3.2 depends_on 与 healthcheck 的正确组合

这是 Compose 里被误解最深的一处。很多人以为depends_on: [db]就代表"等 db 启动好了再起 api",实际上它只保证容器启动顺序,db 容器一创建,api 容器立刻开始启动,此时数据库进程可能还在初始化、端口根本没监听。结果 api 连接失败,重试几次后放弃,日志里一堆连接拒绝。

正确做法是配合健康检查:

depends_on: db: condition: service_healthy

配合 db 服务上的healthcheck定义,Compose 会等到健康检查通过才启动依赖方。pg_isready这类专用探针比curl更准确,因为它检查的是数据库真实可接受连接的状态,不只是端口开着。

健康检查的参数需要根据服务特性调。数据库这种启动快但初始化慢的,interval: 10s、retries: 5大概够;Java 应用启动慢,start_period: 40s甚至更长。start_period内的失败不计数,这是给慢启动服务的保护期,设短了会误判。

还有一个常见误解:健康检查通过只代表进程能响应探针,不代表业务逻辑完全就绪。比如应用可能探针通了但缓存还没预热完。这种场景下更靠谱的做法是在应用里实现真正的就绪检查接口,把关键依赖(数据库连接池、配置加载、缓存连接)都纳入判断,探针打这个接口。健康检查的质量,直接决定编排的可靠性。

3.3 网络、卷与环境变量的三层隔离

Compose 默认会创建一个以项目名命名的网络,所有服务自动加入,服务之间可以直接用服务名当主机名互访。这个默认行为很方便,但也容易造成"全都通"的混乱。我的习惯是显式定义网络并分组:

networks: frontend: backend: internal: true

把数据库和缓存放backend,应用同时加入两个网络,反向代理只加入frontend。internal: true让这个网络无法访问外网,数据库完全没有对外出口,安全性上一个台阶。

卷分两类:命名卷和绑定挂载。命名卷由 Docker 管理,存在/var/lib/docker/volumes/下,适合数据库数据、应用日志这类需要持久化但不方便直接编辑的内容。绑定挂载直接把宿主机目录映射进去,适合开发时挂载源码实现热更新:

volumes: - ./src:/app/src - ./config:/app/config:ro

:ro只读挂载配置目录,防止容器内误改宿主机文件。这个细节在多人共用一台开发机时尤其重要。

环境变量我按三层管理:镜像内置默认值(ENV写在不敏感的配置上)、Compose 声明的环境变量(区分不同环境的差异项)、运行时注入(密码密钥通过外部文件或编排平台注入,绝不进仓库)。三层各司其职,不会出现"改个数据库地址要重新构建镜像"这种事。

3.4 多环境覆盖:override 文件与 profiles

Compose 支持文件合并,compose.yaml加上compose.override.yaml会自动叠加,后者优先级更高。这个机制天然适合做环境差异化:基础文件写通用配置,override 写本机特定的端口、卷、调试参数。

# compose.override.yaml(本地开发用,加入 .gitignore) services: api: volumes: - ./src:/app/src environment: LOG_LEVEL: debug ports: - "8080:8080" - "5005:5005" # 远程调试端口

服务器上不放这个文件,就是生产配置;本地开发放进去,自动获得热更新和调试端口。也可以显式指定多个文件:docker compose -f compose.yaml -f compose.prod.yaml up -d,后者覆盖前者。

profiles解决的是"可选服务"的问题。比如监控组件、数据库管理工具这些平时不需要的服务:

services: adminer: image: adminer:latest profiles: ["tools"] ports: - "8081:8080"

不带--profile tools启动时,这个服务完全不会被创建,不会浪费资源。需要时docker compose --profile tools up -d临时拉起来。比起注释掉再取消注释,这种方式干净太多,也不会因为忘记改回来而出事。

3.5 我常用的命令清单与排查路径

命令不在多,在于用对时机。下面这几个是我每天都会碰的:

命令用途使用时机
docker compose config展开并校验最终配置改完 yaml 先跑一遍,变量没解析、语法错都能立刻发现
docker compose up -d --build重建镜像并后台启动改了代码或 Dockerfile 后
docker compose ps查看服务状态和健康情况起完之后确认是否全部 healthy
docker compose logs -f --tail=100 api跟踪某个服务日志排查启动失败
docker compose exec api sh进入运行中的容器需要看文件、验证网络连通性
docker compose down -v停止并删除容器与卷需要彻底清空环境重来
docker compose up -d --force-recreate强制重建容器配置改了但没生效时

docker compose config这个命令被严重低估。它把变量替换后的最终配置整个打印出来,很多时候"配置明明改了为什么不生效",跑一下就能看到真相——可能是变量名拼错、可能是 override 文件覆盖了、可能是.env文件位置不对。

排查起不来的服务,我的固定顺序是:先docker compose ps看状态(是 Exit 还是 Restarting),再docker compose logs --tail=200 服务名看报错,然后docker compose config确认配置没被覆盖,最后才考虑进容器。绝大多数问题在前两步就能定位,直接钻容器里翻往往是浪费时间。

4. 私有镜像仓库:从单机 registry 到团队级方案

镜像仓库这块,很多团队吃过"起步太简陋、后来迁移成本高"的亏。我的建议是:先想清楚使用规模和治理需求,再决定投入多少。

4.1 先想清楚仓库要解决什么问题

三类需求对应三种方案。个人或小团队内网共享,几十个镜像,两三个人推拉,一个单机 registry 就够了,十分钟能跑起来,维护成本几乎为零。团队级使用,需要按项目隔离权限、需要知道谁推了什么、需要定期清理,这时候得上带管理界面的方案,配置项多但也省心。有合规或审计要求,需要镜像签名、漏洞扫描、拉取审计日志,那就得选企业级套件,前期投入大但后面省事。

选型时我最看重的是存储后端是否可替换。早期的仓库把数据存在本地磁盘,用着用着磁盘满了,扩容就得停机迁移。支持对象存储的仓库,扩容只是改个配置。这一点在起步时容易被忽略,等数据涨到几百 GB 才发现,迁移就是一次大工程。

4.2 十分钟起一个可用的私有仓库

单机方案起步极快:

services: registry: image: registry:2 ports: - "5000:5000" environment: REGISTRY_STORAGE_DELETE_ENABLED: "true" volumes: - ./registry-data:/var/lib/registry - ./config.yml:/etc/docker/registry/config.yml:ro restart: unless-stopped

REGISTRY_STORAGE_DELETE_ENABLED这个环境变量一定要开,否则后面想删镜像标签会直接被拒绝。配置文件里可以追加保留策略和存储后端:

storage: filesystem: rootdirectory: /var/lib/registry delete: enabled: true cache: blobdescriptor: inmemory http: addr: :5000

起来之后,先给镜像打上带仓库地址的完整标签再推:

docker tag myapp:1.0.0 192.168.10.20:5000/team/myapp:1.0.0 docker push 192.168.10.20:5000/team/myapp:1.0.0

标签的完整格式是<仓库地址>/<命名空间>/<镜像名>:<版本>。这里最容易出的错是忘了加仓库地址前缀,直接docker push myapp:1.0.0会被推到默认的公共仓库,本地私有仓库里什么都没有。

4.3 TLS 与 insecure-registries 的正确姿势

默认情况下,Docker 客户端要求仓库必须走 HTTPS。你用 HTTP 起在 5000 端口,push 时会看到http: server gave HTTP response to HTTPS client,意思很直白:客户端发的是 HTTPS 请求,服务端回的是 HTTP 明文。

两种解法。生产环境必须用证书,把 CA 证书放到/etc/docker/certs.d/<仓库地址>:<端口>/ca.crt,客户端就会信任。自签证书的关键是 SAN 字段必须包含你实际访问用的地址——很多人签证书时只填了域名,实际访问用的是 IP,照样报x509: certificate is valid for ...。

开发和测试环境可以临时把仓库标记为不安全仓库,在/etc/docker/daemon.json里加:

{ "insecure-registries": ["192.168.10.20:5000"] }

改完必须重启 Docker 服务生效。这里有个坑:insecure-registries只对 HTTP 仓库有效,如果你的仓库确实是 HTTPS 但证书不被信任,加这个配置也没用,得老老实实放 CA 证书。我见过有人在这上面绕了两小时,一直在反复重启 Docker 服务。

注意:不要为了图省事,把内网所有地址都塞进insecure-registries。一旦形成习惯,某天真的需要走加密传输时,排查范围会大得离谱。

4.4 命名、打标签与保留策略

标签策略是我见过最混乱的一环。典型现场是:仓库里每个镜像都有latest、test、v1、v1-new、v1-最终版、v1-真最终版,没人敢删任何一个。半年后想回滚某个版本,发现根本对不上哪次发布。

我推行的规则只有三条。第一,latest只在一个地方使用:本地开发随手构建时用,CI 和发布流程严禁推latest。第二,正式版本用语义化版本号,1.4.2这种,配合 git tag,能精确对应到某次提交。第三,额外打一个 git 短哈希标签,方便排查:

docker tag myapp:1.4.2 192.168.10.20:5000/team/myapp:1.4.2 docker tag myapp:1.4.2 192.168.10.20:5000/team/myapp:sha-a1b2c3d

版本号给人看,哈希给机器查。生产环境部署时用 digest 而不是标签,这是最稳的方式:

docker pull 192.168.10.20:5000/team/myapp@sha256:xxxx

因为标签可以被移动,同一个1.4.2今天推的和明天推的可能是两个不同的镜像;digest 是内容哈希,永远指向同一份内容。这点在出故障回滚时特别关键——你确定拉回来的就是当初那个。

4.5 存储回收:为什么删了 tag 磁盘没变小

这个问题几乎每个自建仓库的人都会遇到。执行了删除标签的命令,curl也返回成功,但du -sh一看磁盘占用纹丝不动。原因是 Registry 的删除是两段式的:删标签只是删掉了 manifest 的引用,底层的 layer 文件(blob)还在,因为可能被其他标签共享。

要真正回收,得跑垃圾回收。Registry 自带了一个命令,在容器里执行:

docker exec registry /bin/registry garbage-collect \ --delete-untagged /etc/docker/registry/config.yml

在这之前,必须先删干净所有指向无用 manifest 的标签,而且回收过程最好停写或只读,因为并发推送可能导致新镜像的层被误判为无引用而清掉。我的做法是每天凌晨跑一次定时任务,先停掉对外推送权限,再执行回收,最后恢复。听起来麻烦,但比起某天磁盘满了导致所有构建失败,这点麻烦值。

保留策略建议按"数量 + 时间"双维度控制:每个仓库保留最近 10 个版本,同时删除所有超过 90 天且非正式版本的标签。只按数量控制,低频更新的仓库会保留很久;只按时间控制,高频更新的仓库会积攒太多。

5. 实战问题速查与避坑记录

下面这些几乎都是我或者身边同事真实撞上过的,按阶段整理,出问题时可以先扫一眼表格。

5.1 构建阶段高频故障表

现象根因处理方式
COPY failed: file not found文件被.dockerignore排除,或路径超出构建上下文检查.dockerignore,确认context目录包含该文件
构建越来越慢,改一行代码就要几分钟COPY . .在依赖安装之前,缓存全失效调整指令顺序,依赖清单先拷,源码后拷
镜像体积远超预期缓存文件、包管理器索引没在同一层清理RUN合并清理命令,用多阶段构建
not found或符号相关错误,程序跑不起来Alpine 的 musl libc 与预编译二进制不兼容换成debian-slim基础镜像
时区不对,日志差八小时容器默认 UTC,没设TZ装tzdata,设ENV TZ
构建时报no space left on device本地镜像和构建缓存占满磁盘docker system prune -af,配合--filter until=72h

磁盘那条多说一句,docker system prune默认不清理正在被容器使用的镜像,加-a才会清掉所有未使用镜像,加-f免确认。执行前确认没有正在跑的重要容器,用docker ps看一眼再动手。

5.2 运行与编排阶段高频故障表

现象根因处理方式
应用启动就连不上数据库depends_on只保证启动顺序,不等就绪加healthcheck+condition: service_healthy
docker stop要等十秒才停CMD用了 shell 形式,PID 1 是 sh,信号没转发改成 exec 形式["node","server.js"]
非 root 用户写卷报permission denied宿主机目录属主与容器内 UID 不匹配构建时chown,或启动时用user: "1000:1000"
容器内访问localhost连不上另一个服务每个容器有独立网络栈用服务名当主机名,服务需在同一网络
exec format error镜像架构与宿主机不一致构建时指定--platform linux/amd64
改了配置重启不生效容器被复用,新配置没进容器docker compose up -d --force-recreate
port is already allocated端口被占用ss -lntp查占用进程,或换端口

exec format error这个在 M 系列芯片的机器上构建、部署到 x86 服务器时特别常见,报错信息很短,容易误判成镜像损坏。构建时加--platform linux/amd64,或者用buildx同时产出多架构镜像,能一次性解决。

5.3 仓库阶段的三个经典报错

http: server gave HTTP response to HTTPS client——前面说过,客户端用 HTTPS 请求,服务端是明文 HTTP。要么给仓库配证书,要么把地址加进insecure-registries并重启服务。

x509: certificate signed by unknown authority——客户端不信任仓库证书。把 CA 证书放到/etc/docker/certs.d/<地址>/ca.crt。注意目录名要和访问用的地址完全一致,带端口的话端口不能少,192.168.10.20:5000和192.168.10.20是两个不同的目录。

manifest unknown或not found——拉取时找不到对应的标签。要么是标签打错了,要么是推送失败了但命令返回被忽略。用仓库的 API 列一下现有标签确认:curl http://<地址>:5000/v2/<命名空间>/<镜像名>/tags/list,能直接看到实际存在哪些标签,比猜测快得多。

5.4 几条踩出来的经验

关于调试信息:镜像里装curl和netstat这类工具,体积会增加十几兆,但排查网络问题时能省下大量时间。如果实在不想装进生产镜像,可以在构建时开一个带工具的 debug 阶段,出问题时临时构建那个阶段进去看。

关于构建速度:如果依赖下载慢,是网络层面的问题,加构建参数换源比优化 Dockerfile 更有效。另外,--cache-from配合 CI 缓存能显著加速,把上一次构建的镜像拉下来当缓存源,远程构建时效果明显。

关于Compose 文件的可维护性:超过五个服务之后,尽量按职责拆成多个文件,用-f组合。一个两百行的 yaml 没人愿意改,改成三个七十行的人人敢动。另外把公共部分(网络、时区、日志配置)用x-开头的锚点抽出来复用,能减少大量重复。

关于仓库的可用性:私有仓库挂掉的时候,如果所有机器都依赖它拉镜像,整个发布流程就停了。至少保证每台机器本地缓存了最近几个可用版本,docker image ls里能看到,紧急时可以直接用本地镜像启动,不必等仓库恢复。

关于版本管理的心态:我见过太多人觉得"镜像仓库反正内网,标签随便打没关系",等到要回滚某个两周前的版本,发现latest已经更新了七次,根本找不回来。标签规范这件事,在只有三个人的时候建立成本几乎为零,在三十个人的时候推行的阻力大得离谱。早做,比什么都强。

最后分享一个我自己一直在用的小习惯:每写完一个 Dockerfile,先用docker build跑两遍。第一遍看不到什么特别,第二遍观察哪些层显示CACHED,如果只有最后一两层命中,说明指令顺序还有优化空间,把变化最频繁的内容再往后挪。这个动作花不到一分钟,但能让你对每一层的边界有肌肉记忆,写第三个 Dockerfile 的时候,顺序自然就对了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询