Docker容器化:镜像、Dockerfile与Compose部署实践
Docker 容器化技术这套东西我第一次真正用起来是被逼的——项目要交付到另一台机器上跑配置改了四五遍还是起不来日志里报的错在开发机上压根复现不了。那天晚上我从头翻文档才想明白容器化解决的从来不只是“部署”这一个环节它把“环境”本身变成了一件可以被版本管理、被复制、被回滚的产物。这篇就围绕 Docker 这个基础聊聊它到底在解决什么问题、镜像容器仓库这几个概念该怎么理解、一次docker run背后发生了什么以及怎么用它搭出一套高效、可移植的开发环境和部署流程。如果你现在还在用“把依赖清单发群里让对方自己装”的方式协作或者每次上线前都要手工改一遍配置那这套东西值得花一个下午认真过一遍。下面全是我自己踩过、改过、又重新梳理过的内容能直接抄作业的部分我都标出来了。1. 环境漂移这件事Docker 到底治的是什么病1.1 从一句“我本地跑得好好的”说起做过协作开发的人对这句话都不陌生。同一份代码A 同学机器上跑得飞起B 同学 clone 下来就报模块找不到到测试机上又是另一个错。大家第一反应往往是“你依赖装全了吗”“你 Node 版本多少”“你系统是不是 Windows”——问到最后发现是某个底层库的编译选项不一样。这类问题的学名可以叫环境漂移但更准确的说法是运行环境从来没有被当作代码来管理过。代码有 Git有提交记录有 diff能回滚环境呢它散落在每个人的机器里散落在运维手工敲过的几十条命令里散落在“上次装的时候好像还装了那个什么”的模糊记忆里。环境一旦出问题你没有任何办法去比对“上次好的时候是什么样”。Docker 治的就是这个病。它把应用运行所需要的一切——操作系统基础文件、运行时、依赖库、配置、启动命令——打包进一个镜像Image这个镜像有唯一的哈希值可以被推送、被拉取、被版本标记。任何一台装了容器运行时的机器拿到同一个镜像跑出来的东西就是一样的。这里的“一样”不是“差不多”是字节级别的一致。我印象最深的一次是排查一个字符编码问题。同一个服务在开发机上处理中文正常到了另一台机器上就出现乱码。查了两天最后发现是那台机器的系统 locale 没配好。自从把服务容器化、把LANG写进镜像之后这类问题就再也没出现过——因为它不属于“应用代码”它属于“环境”而环境现在被固化下来了。1.2 容器不是虚拟机这个区别决定了后面所有用法很多人第一次听到“容器”脑子里对应的是虚拟机。这两个东西的定位确实像但底层机制完全不同搞清楚区别你后面用 Docker 的思路才不会跑偏。虚拟机是靠虚拟化硬件实现的宿主机上跑一个 Hypervisor模拟出 CPU、内存、磁盘、网卡然后在上面装一整套完整的操作系统再在系统里跑你的应用。一台虚拟机启动要几十秒镜像动辄几个 G因为它里面装了一整个操作系统。容器的做法是共享宿主机内核它不模拟硬件也不装第二套操作系统而是利用 Linux 内核提供的命名空间Namespace做隔离、利用控制组Cgroups做资源限制让一组进程“以为自己独占了一台机器”。所以容器启动是秒级甚至毫秒级的镜像可以做到几十兆。对比维度虚拟机容器隔离级别硬件级虚拟化隔离彻底进程级隔离共享内核启动速度数十秒通常 1 秒内镜像体积数 GB 起几十 MB 到几百 MB单机密度个位数到几十几十到上百安全边界强适合多租户强隔离相对弱内核共享典型用途跑不同操作系统的负载同一内核下的应用交付这张表最关键的一行是**“共享内核”**。它带来两个直接后果第一容器做不了“在 Linux 上跑一个 Windows 程序”这种事因为内核是同一套第二容器的安全边界比虚拟机弱如果你的场景是多租户强隔离光靠容器不够得配合额外的手段。提示因为容器共享宿主机内核所以基础镜像里的操作系统版本其实只影响用户态的文件和工具链内核版本永远跟着宿主机走。这也是为什么同一个ubuntu:22.04镜像在宿主机内核较老的机器上偶尔会冒出系统调用不支持的问题。理解这一层之后你会自然接受一个结论容器里不该跑 init 系统、不该装一堆和业务无关的东西。它不是一台机器它是一个被隔离起来的进程。2. 镜像、容器、仓库三个词儿得先掰扯清楚2.1 镜像是一份只读的“施工图纸材料清单”镜像Image本质上是一个只读的、分层的文件系统快照外加一份元数据启动命令、暴露端口、环境变量、工作目录等。你可以把它理解成装修用的施工图纸加材料清单它告诉运行时“这个房子里应该有什么文件、应该怎么启动”。“分层”是镜像最核心的设计。每一条会改变文件系统的指令比如RUN、COPY、ADD都会生成一个新层层与层之间是叠加关系。底层是基础镜像上层是你在它之上做的修改。这样做的好处是复用你在本地有十个项目都用node:20-alpine做基础镜像那这十个镜像共享同一份基础层磁盘上只存一份。分层还带来一个非常重要的性质每一层一旦生成就不可变。你在容器里改文件改的不是镜像层而是容器自己那层可写层。这一点很关键很多人以为“我在容器里装了个软件镜像就变了”其实没有容器一删改动全没。用docker history能看到一个镜像的完整分层记录包括每层的大小和对应的指令docker history --no-trunc my-app:1.0这个命令在做镜像瘦身的时候会反复用到因为你能一眼看出哪一层占了大头对应的是哪条指令。2.2 容器是镜像跑起来之后的那层“可写薄壳”容器Container就是在镜像之上加了一层可写层再加上一套隔离的命名空间和资源限制。进程在里面看到的文件系统是“镜像的所有只读层 自己这一层可写层”合并后的结果。这层可写层是临时的。容器删掉可写层就没了。所以但凡需要持久化的东西——数据库数据文件、用户上传的附件、日志——都不能放在可写层里得挂在**数据卷Volume或绑定挂载Bind Mount**上。这块我第一次用的时候吃过亏。当时跑了个数据库容器往里灌了一批测试数据觉得挺好用随手docker rm清了一下环境数据全没了。后来才养成习惯凡是容器里会写东西的路径先想清楚它该不该持久化该持久化的就提前挂卷。容器的生命周期用一张图景来描述比较直观created创建但未启动→running运行中→paused暂停→exited已停止→ 被删除。docker ps只看运行中的docker ps -a才能看到退出的那些。这个区别在排查问题时特别重要因为绝大多数容器启动失败容器本身是存在的只是状态为 exited你不加-a根本看不见。2.3 仓库解决的是“图纸怎么传到别人手里”仓库Registry就是存镜像的地方作用类似代码的远程仓库。你本地构建出来的镜像别人是拿不到的得先push到仓库对方再pull下来。仓库里的镜像靠仓库名 标签Tag定位比如myorg/api:2.3.1。标签这块有个特别容易踩的坑不要用latest做生产环境的版本标识。latest只是一个普通字符串标签不具备“最新”的语义谁都可以往上面推。今天拉到的latest和昨天拉到的可能完全不是同一个镜像出了问题你无从追溯。我现在的习惯是开发阶段随便用一旦要发布必须打语义化版本号或者 commit 短哈希比如myorg/api:2.3.1或myorg/api:sha-9f3c1a2。注意docker pull时如果不指定标签默认拉latest。这行命令在 CI 脚本里出现就是一颗不定时炸弹。3. 一次 docker run 背后究竟发生了哪些事3.1 从镜像到进程的六步链路很多人用 Docker 用得很熟但问到docker run具体干了什么答不上来。搞清楚这条链路排查问题时你才知道该看哪一层。第一步解析镜像引用。Docker 先看本地有没有my-app:1.0这个镜像没有就去配置的仓库拉取拉取时按层下载已存在的层直接跳过。第二步创建可写层。在镜像的所有只读层之上叠加一个空的、可读写的容器层。此时容器状态是created。第三步分配隔离环境。创建独立的命名空间PID、网络、挂载、UTS、IPC、用户给容器分配虚拟网卡和 IP配置 DNS 和 hosts。第四步应用资源限制。根据--memory、--cpus等参数往 Cgroups 里写限制值。没写限制的话容器理论上能吃掉宿主机全部资源。第五步挂载卷和配置。把-v指定的目录挂进去把-e指定的环境变量注入把-p指定的端口做转发规则。第六步执行入口命令。用镜像里ENTRYPOINT和CMD拼出来的命令启动进程进程 PID 为 1容器状态变成running。这条链路里任何一步出问题表现都不一样。镜像拉不到命令会直接报错退出入口命令有问题容器起来了又立刻退出端口冲突会报bind: address already in use。按这个顺序往下捋基本上都能定位。3.2 命名空间与联合文件系统隔离与分层的两块基石前面反复提到命名空间和分层这里稍微展开一点因为很多“莫名其妙”的现象根子都在这。命名空间负责**“看不见”**。PID 命名空间让容器里的进程只能看到自己这一组进程所以容器里的 1 号进程并不是宿主机的 1 号进程网络命名空间让容器有自己独立的网卡、路由表、端口空间所以容器里监听 8080 和宿主机监听 8080 不冲突挂载命名空间让容器看到的是自己那套合并后的文件系统。联合文件系统负责**“叠起来”。它把镜像的多个只读层和容器的可写层合并成一个统一的视图进程按路径访问文件时它会从上往下逐层查找。写文件时用的是写时复制Copy-on-Write**如果这个文件在只读层里存在先复制一份到可写层再改副本只读层不动。写时复制解释了一个很常见的疑惑为什么在容器里改一个几 MB 的文件速度还挺快因为它没真改原文件只是复制了一份。反过来如果容器里频繁写大量文件可写层会迅速膨胀这也是为什么要强调该挂卷的路径一定要挂卷——挂载的目录直接落在宿主机文件系统上不走写时复制。4. 用 Dockerfile 把环境“写死”一份能直接抄的实践4.1 基础镜像选型为什么 Alpine 不是无脑首选新手最常见的操作是“哪个体积小用哪个”于是所有镜像都往 Alpine 上靠。Alpine 确实小基础镜像才几 MB但它的代价是用的是 musl libc 而不是 glibc很多预编译好的二进制、Python 的某些 C 扩展、Node 的原生模块在 Alpine 上装不上或者行为不一致得现编译。我的选型逻辑大致是这样场景推荐基础镜像理由需要编译原生扩展的 Python/Node 项目python:3.12-slim/node:20-slim基于 Debianglibc 完整兼容性好纯静态编译的 Go/Rust 产物alpine或gcr.io/distroless/static产物无动态依赖越小越好需要完整系统工具链的调试环境debian:bookworm工具齐全排查方便生产运行最小攻击面distroless 系列无 shell、无包管理器结论是不要为了省几十兆牺牲构建稳定性。一个镜像从 900MB 压到 300MB 意义不大但一个因为 libc 不兼容导致线上偶发崩溃的问题代价可能是一整个通宵。先保证能跑对再考虑瘦身。4.2 指令顺序与分层缓存把变化频率低的放前面Dockerfile 里的指令顺序直接决定了构建缓存能不能命中。规则很简单Docker 从上往下执行一旦某一层的内容发生变化这一层和它下面所有层的缓存全部失效必须重新构建。所以正确做法是把“几乎不变”的放前面“经常变”的放后面。看一个 Node 项目的标准写法FROM node:20-slim WORKDIR /app # 先只拷贝依赖清单这一层只在依赖变更时才失效 COPY package.json package-lock.json ./ RUN npm ci --omitdev # 最后再拷贝业务代码改代码不会触发重新装依赖 COPY . . EXPOSE 3000 CMD [node, server.js]反例就是把COPY . .写在RUN npm ci前面。这样你每改一行业务代码依赖那一层就失效每次构建都要重新装一遍全量依赖构建时间从十几秒变成好几分钟。这个坑我见过太多次了尤其是从别人那里拷来的模板里就带着。这里有个细节值得说npm ci和npm install的区别。ci严格按package-lock.json安装装出来的版本和锁文件完全一致而且不会修改锁文件适合构建环境install可能会更新锁文件产生不可预期的版本漂移。生产构建一律用ci。4.3 多阶段构建把编译工具链留在构建阶段编译型语言的境遇更尴尬构建需要完整的 SDK、编译器、开发头文件加起来一两个 G但运行时其实只需要一个二进制文件。如果不做处理最终镜像里塞了一大堆永远用不上的东西。多阶段构建就是干这个的。思路是第一个阶段当“构建车间”把东西造出来第二个阶段当“运行现场”只把产物搬过来。# 阶段一构建 FROM golang:1.22-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -trimpath -ldflags-s -w -o /out/app ./cmd/app # 阶段二运行 FROM alpine:3.20 RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --frombuilder /out/app /usr/local/bin/app ENTRYPOINT [app]这份写法的效果非常明显构建阶段的基础镜像可能有一千多兆但最终的运行镜像只有十几兆。里面有几点值得说明CGO_ENABLED0是让 Go 静态编译不依赖目标机的 C 库这样才能塞进一个只有基本文件系统的镜像里。-trimpath去掉编译产物里的本地路径信息既是减小体积也是避免泄露构建目录结构。-ldflags-s -w去掉符号表和调试信息通常能再瘦 25% 左右。ca-certificates是必须装的否则容器里发 HTTPS 请求会因为找不到根证书而失败——这个问题在本地可能不出现因为本地用的是完整的 base 镜像一到生产精简镜像就炸。tzdata同理跑定时任务或者做时间格式化的服务缺了它时间会全部变成 UTC。5. 本地开发环境的实际搭建从零跑通一套前后端5.1 docker-compose 编排的最小可用写法单个容器用docker run还行一旦要同时跑前端、后端、数据库、缓存一条条敲命令就顶不住了。docker compose的作用就是把这些定义写进一个 YAML 文件一条命令拉起整套环境。下面这份是我常用的最小骨架稍微改改就能套到大部分项目上services: api: build: context: . dockerfile: Dockerfile ports: - 8080:3000 environment: - NODE_ENVdevelopment - DATABASE_URLpostgres://postgres:devpassdb:5432/appdb volumes: - ./src:/app/src depends_on: db: condition: service_healthy restart: unless-stopped db: image: postgres:16-alpine environment: POSTGRES_PASSWORD: devpass POSTGRES_DB: appdb volumes: - pgdata:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U postgres] interval: 5s timeout: 3s retries: 10 volumes: pgdata:几个地方值得展开讲。ports的格式是宿主机端口:容器端口8080:3000意思是访问宿主机的 8080 就转到容器里的 3000。DATABASE_URL里写的主机名db不是笔误compose 会自动创建一个网络服务名就是 DNS 名容器之间直接拿服务名当域名用不需要写 IP。depends_on这里用了带condition: service_healthy的写法配合db的healthcheck。这是很多人踩过的坑只写depends_on: [db]只能保证“容器启动了”不能保证“数据库能接受连接了”。数据库进程启动到初始化完成有几百毫秒到几秒的间隔如果 API 启动很快上来就连就会报连接拒绝然后退出。用健康检查做条件启动能避免这个问题。restart: unless-stopped是对开发环境比较友好的策略进程意外退出会自动拉起但你手动stop之后不会自己再复活。生产环境一般用always或者配合编排平台的自愈机制。5.2 数据卷与端口映射的常见配置数据卷分两种用途完全不同不能混着用。**命名卷Named Volume**由 Docker 管理存在 Docker 自己的数据目录下跨平台行为一致性能好适合数据库这种“你不需要直接去宿主机上看文件”的场景。上面例子里的pgdata就是命名卷。它的生命周期独立于容器docker compose down不会删它只有加-v参数才会删。**绑定挂载Bind Mount**直接把宿主机的某个目录映射进容器适合开发时挂源码让容器里的进程实时看到你的修改。比如./src:/app/src你在编辑器里改文件容器里的热重载立刻就生效。这两种混用要注意权限问题。绑定挂载的目录容器内进程的 uid 和宿主机文件的属主对不上时会出现“容器里改不了文件”或者“生成的文件属主是 root”的情况。常见的解决方式是在 Dockerfile 里创建和宿主机同 uid 的非 root 用户或者在 compose 里指定user: ${UID}:${GID}。端口映射还有一个容易混淆的地方EXPOSE指令并不真的开放端口它只是元数据起文档作用。真正让外部能访问的是-p参数或者 compose 里的ports。同一个 network 内的容器互访根本不需要这两者直接连容器端口就行。另外宿主机文件变更在容器里不生效的情况也很常见特别是在挂载大量文件的项目里。原因是文件变更通知的机制在某些挂载场景下不可靠需要在开发服务器配置里打开轮询模式比如前端的WATCHPACK_POLLINGtrue代价是 CPU 占用会上升一点。6. 那些年踩过的坑从“容器起不来”到“镜像莫名变大”6.1 端口占用与容器退出的排查链路容器起不来第一步永远是看状态和日志而不是猜。# 看所有容器包括已退出的 docker ps -a # 看指定容器的日志 docker logs --tail 100 容器名或ID # 看退出码 docker inspect 容器名 --format {{.State.ExitCode}} # 看完整配置包括端口、挂载、环境变量 docker inspect 容器名退出码是最快的信息源。0表示进程正常结束——这往往意味着入口命令跑完就退出了比如把CMD [npm, run, build]当成了启动命令1一般是应用本身报错137是收到了 SIGKILL八成是内存超限被 OOM Killer 干掉143是收到 SIGTERM正常停止。端口占用报错的典型信息是bind: address already in use。这时候先确认宿主机上是不是有进程占了那个端口ss -lntp | grep 8080如果确实是旧容器占着可能是之前docker run起的容器没清干净docker ps -a里面能看到。这里有个细节容器没起来但端口被占也可能是上一次的容器还在运行只是你以为它停了。养成docker ps确认的习惯能省不少时间。还有一种“容器一启动就退出、日志还是空的”的情况通常是入口命令写错了比如路径不存在、脚本没有执行权限。用docker run --rm -it --entrypoint sh 镜像覆盖入口命令进容器里看一眼比对着日志猜快得多。6.2 时区、编码、PID 1 僵尸进程这三类问题共同的特点是本地不出、线上才出因为本地往往用的是完整的开发镜像。时区基础镜像默认是 UTC。如果你的服务要按本地时间做统计、生成报表、跑定时任务全部会差 8 小时。解决方案是在 Dockerfile 里装tzdata并设置ENV TZAsia/Shanghai或者在 compose 里挂载宿主机的时区文件。我倾向于前者因为更显式。编码LANG没设置时某些语言运行时默认用 POSIX locale处理中文、日文会出现截断或乱码。加一行ENV LANGC.UTF-8基本能解决大部分问题。PID 1 问题容器里的 1 号进程有个特殊身份——它是这个命名空间里的 init负责回收孤儿进程。如果你的入口命令是一个 shell 脚本脚本启动的应用就成了子进程它产生的僵尸进程没人回收跑久了会累积。更麻烦的是docker stop发的 SIGTERM 会发给 1 号进程那个 shellshell 未必会转发给应用导致优雅停机失效。解决方案有两个一是用exec形式写入口让应用直接成为 1 号进程比如ENTRYPOINT [app]而不是ENTRYPOINT app二是在docker run时加--init参数让运行时注入一个轻量的 init 进程来转发信号和回收孤儿进程。提示ENTRYPOINT app这种 shell 形式会被包成/bin/sh -c app执行应用就不是 1 号进程了。这个细节在写 Dockerfile 时特别容易忽略。6.3 镜像体积失控的定位方法镜像越做越大通常不是某一次改动导致的而是长期积累。定位方法很直接看每一层的大小。docker history --no-trunc my-app:1.0 docker image ls docker system df常见的原因有这么几类。第一类是构建上下文过大docker build会把当前目录整个发给构建引擎如果你忘了写.dockerignorenode_modules、.git、日志、临时文件全被送进去了COPY . .自然就把这些垃圾全拷进镜像。.dockerignore是必须有的内容至少包含node_modules .git .gitignore *.log tmp dist .env第二类是在同一层里装完又删。因为分层不可变RUN apt-get install xxx rm -rf /var/cache/apt必须写在同一条RUN里分成两条指令的话删除操作只是在新一层里加了个“白文件”标记空间并没有释放。这是分层机制最反直觉的一点。第三类是误把构建依赖带进运行镜像。这个前面说的多阶段构建能彻底解决。第四类是基础镜像选得太重。ubuntu:22.04七十多兆debian:bookworm-slim三十多兆alpine几兆差别不小。但前面也说了不要为了这点体积牺牲兼容性权衡着来。7. 把构建速度和镜像体积再往前推一步7.1 构建缓存挂载与更细的缓存控制Docker 的分层缓存是按指令内容比对的只要COPY的文件内容变了后面所有层都失效。这对依赖安装很不友好——你只改了一行业务代码却要重新下载全部依赖。--mounttypecache是解决这个问题的利器它把包管理器的缓存目录挂出来跨构建复用# syntaxdocker/dockerfile:1 FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN --mounttypecache,target/root/.cache/pip \ pip install -r requirements.txt COPY . . CMD [python, -m, app]target/root/.cache/pip是 pip 的默认缓存目录挂出来之后即使requirements.txt有变动需要重新装已经下载过的包也能直接命中缓存不用重新从网络拉。Go 的GOMODCACHE、Node 的 npm cache 同理。需要注意的是这份写法要求 Dockerfile 开头有# syntaxdocker/dockerfile:1这行——它是告诉构建器启用新语法。另外缓存挂载的内容不会被打进最终镜像它只存在于构建期间这点和COPY完全不同。7.2 构建参数与多环境复用同一份 Dockerfile 要同时服务开发、测试、生产最常见的做法是抽构建参数出来# syntaxdocker/dockerfile:1 FROM node:20-slim AS base WORKDIR /app COPY package*.json ./ RUN --mounttypecache,target/root/.npm \ npm ci FROM base AS dev ENV NODE_ENVdevelopment COPY . . CMD [npm, run, dev] FROM base AS prod ENV NODE_ENVproduction COPY . . RUN npm run build npm prune --omitdev CMD [node, dist/server.js]用--target指定构建到哪一段docker build --target dev -t my-app:dev . docker build --target prod -t my-app:1.0 .这样做的好处是依赖安装那一段base被两个环境共用缓存也共用不会重复下载。开发镜像里保留完整依赖和开发服务器生产镜像里只留编译产物和运行时依赖各取所需。不过有个坑要提醒COPY . .之后如果又RUN npm run build构建产物会留在最终镜像里包括源代码。如果介意源码泄露可以把构建阶段和运行阶段再拆开运行阶段只COPY --frombuild /app/dist ./dist。这和前面讲的多阶段构建是同一个思路只是从语言工具链层面细化到了目录层面。7.3 上线前的一份自查清单我把这些年反复确认的东西整理成了一份清单每次发版前过一遍能挡掉大部分低级问题检查项为什么要看镜像标签是否为具体版本号避免latest导致版本不可追溯是否以非 root 用户运行降低容器逃逸后的影响面是否设置了资源限制防止单个容器吃满宿主机资源日志是否输出到 stdout/stderr容器内写文件日志会撑爆可写层且难以采集健康检查是否配置编排平台靠它判断服务是否真的可用时区、编码是否显式设置前面提过本地难复现敏感配置是否从环境变量或密钥注入绝对不能写死在镜像里镜像层是可以被查看的基础镜像是否有已知漏洞定期重新构建拉取打过补丁的底层镜像最后两条我要多说一句。镜像里的任何内容都是可以被提取出来的包括你在RUN里写的密钥、在ENV里设的密码。就算你在后面的层里删掉了前面的层依然在docker history和镜像导出都能看到。所以敏感信息一律走运行时注入不要碰构建阶段。8. 一些我觉得值得早点知道的事关于容器化我最后悔的是早期把它当成“部署工具”来用只在最后一步才想起来打个镜像。后来才慢慢意识到它的价值其实集中在开发阶段的一致性上新同事入职第一天git clone加一条docker compose up就能把整套服务跑起来不用再花两天配环境换机器、换系统行为完全一致出了环境问题可以用同一份 Dockerfile 在本地复现。还有一点是关于“容器里要不要装调试工具”的。早期我喜欢在镜像里留着curl、vim、netstat这些排查方便。后来发现这些东西在生产镜像里既增加体积又扩大攻击面。现在的做法是运行镜像保持极简需要排查的时候用docker exec进不去就用一个临时的调试容器共享同一个网络命名空间排查完就删宿主环境干干净净。最后分享一个小习惯把 Dockerfile 当成代码来 review。它和业务代码一样会影响线上稳定性一样可能出现“改一行炸一片”的情况。团队里如果有人改动了COPY和RUN的顺序或者悄悄换掉了基础镜像都值得在合并前多问一句为什么。这个习惯建立之后镜像构建相关的线上事故基本就绝迹了。