ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Docker镜像创建三方式:commit、Dockerfile与导入导出详解

2026/9/17 14:39:40 拓冰建站 浏览量
Docker镜像创建三方式:commit、Dockerfile与导入导出详解 做Docker这几年被问得最多的问题里“怎么自己做一个镜像”肯定排前三。很多人装完Docker Desktopdocker run hello-world能跑通但下一步想把自己写的服务、自己配好的环境打包成镜像就卡住了——不知道从哪下手也不清楚几种做法之间到底差在哪。其实创建镜像这件事路子就三条一条是手工往容器里装东西然后提交一条是写Dockerfile构建还有一条是从已有镜像或容器导入导出。这三条路我都实打实用过也都因为用错场景吃过亏早年用commit攒出来的镜像谁也不敢动后来靠Dockerfile把构建时间从十几分钟压到两分钟再后来在纯内网机器上靠save/load来回搬镜像救过好几次火。这篇文章就把这三条路彻底拆开讲每一步为什么这么做、参数怎么定、坑在哪都给你说明白新手能照着抄写过一阵Dockerfile的也能找到优化点。1. 先把镜像这件事说透不然三种方法都是死记硬背在讲具体操作前得先把“镜像到底是个什么东西”讲清楚。不然你会觉得三种方法各记一套命令记完就忘。一旦理解了镜像的底层结构你会发现这三种创建方式其实是在同一套机制上从不同角度切入选哪个完全是场景决定的。1.1 镜像的本质是一摞叠起来的只读层Docker镜像不是一个大文件而是一组只读层的集合每一层都是对上一层文件系统的一次改动记录。底层通常是一个精简的操作系统比如Ubuntu、Alpine往上是各种运行时和依赖再往上是你的应用代码。这些层通过联合文件系统UnionFS现在主流是overlay2叠加在一起对外呈现成一个完整的目录树。这样设计的好处很直接层可以被复用和共享。你本地有五个镜像都基于ubuntu:22.04那这个基础层在磁盘上只存一份。同样如果两个镜像只是最后应用代码不同前面所有层都能复用构建时也不用重新下载。容器启动的时候Docker会在镜像的最上层再盖一个可写层。容器里所有的写操作——装软件、改配置、删文件——全都落在这个可写层上底下的镜像层一动都不动。这就是为什么容器删掉之后你在里面装的东西全没了可写层随着容器一起被销毁了。提示理解“镜像只读、容器可写”是理解三种创建方法的关键。docker commit干的事说穿了就是把这个可写层固化成一个新的只读层然后打包进镜像。1.2 三种方法分别动了镜像的哪一部分把上面那套结构记住再看三种方法就通透了。docker commit是把运行中容器的可写层提取出来追加到原镜像后面形成新镜像。你装了什么、改了什么全在那一层里但过程没有记录。docker build是读一份Dockerfile按指令一条条生成新的只读层。每一条会产生文件系统改动的指令主要是RUN、COPY、ADD对应一层层与层之间有明确的先后和依赖关系整个构建过程可复现、可追溯。docker save/docker load/docker export/docker import走的是另一条路——不生成新内容只是把已有镜像或容器在磁盘上的层数据打包、搬运、再还原。它解决的是“怎么把镜像从A机器弄到B机器”的问题而不是“怎么造一个新镜像”。很多人把export/import也当成创建镜像的方法这个说法没错但它的定位是搬运和转换得拎清楚。顺带说一句热词里那些镜像源国内镜像源讲的是另一码事——那是拉取镜像时用的加速地址跟创建镜像是两个阶段的活儿。创建是你自己造镜像源是你从别人那儿拿别混。2. docker commit最直接也最容易挖坑的一条路docker commit是三条路里门槛最低的适合完全没基础的人先感受一下“镜像原来这么来的”。它的工作方式就是起一个容器进去随便折腾折腾满意了一条命令把它固化下来。2.1 从零到一的完整操作链我拿一个最常见的需求举例——做一个自带nginx和vim的Ubuntu镜像。整个流程分四步。第一步基于基础镜像起一个交互式容器并且给它起个名字方便后面引用docker run -it --name mytemp ubuntu:22.04 /bin/bash这条命令里-it是分配交互式终端--name mytemp给容器命名不命名的话后面得用容器ID很麻烦/bin/bash是启动后执行的命令。执行完你就进到容器内部了命令行提示符会变。第二步在容器里装东西。进去之后执行apt-get update apt-get install -y nginx vim curl装完可以用nginx -v验证一下。这时候你所有操作都在容器的可写层里。第三步退出容器但不要用exit把它删掉exit只是停止容器还在exit第四步把停止的容器提交成镜像docker commit -m add nginx and vim based on ubuntu22.04 -a yourname mytemp my-nginx:v1参数拆开看-m是提交信息跟git的commit message一个作用-a是作者mytemp是容器名my-nginx:v1是生成的目标镜像名加标签。执行完用docker images就能看到新镜像了然后docker run -d -p 8080:80 --name web1 my-nginx:v1 nginx -g daemon off;访问localhost:8080nginx页面就出来了。到这儿一个最朴素的镜像就做完了。2.2 什么时候用commit才算合理我得说实话commit不适合做生产镜像但它绝不是废命令有几个场景用它反而最合适。一个是抢救现场。比如某个容器的服务突然挂了你想在它彻底没法启动之前把这个“出问题时的状态”原封不动保存下来方便事后分析。这时候commit是最快的办法一条命令搞定。再一个是做实验。你想快速对比不同配置下的运行效果起一个容器改改commit保存再起一个改改再commit来回切着跑。这种临时性的探索写Dockerfile反而累赘。还有一种是把手工调通的环境固化下来。有时候某个老软件的依赖装起来极其麻烦你在容器里手敲了半天终于跑通了先把这份“成功态”commit存下来保底然后再慢慢把它整理成Dockerfile这是个很务实的做法。注意用commit之前最好先把容器里能清的东西清掉——apt-get clean、删掉/var/lib/apt/lists/*、删掉下载的安装包。不然这些临时文件全被打进镜像里体积白白大一圈。2.3 commit生成的镜像为什么被嫌弃commit最大的问题不是命令本身而是它产出的镜像是“黑盒”。你拿到一个别人commit出来的镜像docker history一看除了最上面那一层底下的操作全是空的你根本不知道中间装了什么、改了什么配置。具体坑有这几个不可复现。同一个镜像你想再做一个一模一样的做不到。因为中间每一步操作都没记录全靠手工回忆。团队里换个人接手只能重新踩一遍坑。层里混进临时垃圾。你在容器里apt-get installapt会缓存一堆deb包你下载的压缩包、编译中间产物全躺在可写层里。这些不会自动清理最终都成了镜像的一部分。没有构建上下文。Dockerfile里你能用ARG传构建参数、能用多阶段构建只保留最终产物commit全没这些能力。层结构混乱。commit出来的镜像往往是“一层里干了十件事”后续想基于它继续优化基本无从下手。所以我的建议是commit当草稿纸用Dockerfile当正式文档用。手工调通之后老老实实把每一步翻译成Dockerfile进版本库这才是能传给团队的资产。3. Dockerfile工程化的标准答案只要这个镜像要重复构建、要交给别人用、要进CI/CD流水线就该写Dockerfile。它是目前绝对的主流也是面试里绕不开的东西。这一节我把指令、缓存、多阶段、优化细节全讲一遍。3.1 Dockerfile核心指令逐个拆解先给一个能跑的最小Dockerfile然后逐条讲FROM ubuntu:22.04 LABEL maintaineryournameexample.com ENV TZAsia/Shanghai WORKDIR /app RUN apt-get update apt-get install -y nginx curl rm -rf /var/lib/apt/lists/* COPY ./html /usr/share/nginx/html EXPOSE 80 CMD [nginx, -g, daemon off;]FROM指定基础镜像必须是第一条有效指令。选基础镜像有个权衡——ubuntu兼容性好但大60MBalpine只有5MB但用的是musl libc某些二进制依赖跑不起来。我一般业务镜像用debian-slim或ubuntu追求极致小的工具类镜像才用alpine。LABEL加元数据比如维护者、版本、来源。这些信息能用docker inspect查到团队协作时很有用。ENV设环境变量构建时和运行时都生效。时区、语言、路径这类配置适合放这儿。注意ENV的值会被写进镜像别塞密码。WORKDIR设工作目录后面的RUN、COPY、CMD都在这个目录下执行。不用它的话默认在根目录容易把文件散得到处都是。它和RUN cd的区别是WORKDIR会持久改变后续指令的目录cd只影响当前那条RUN。RUN构建时执行命令是最容易出问题的指令。每一条RUN会产生一层所以命令要尽量合并具体怎么合并下一节细说。COPY把构建上下文里的文件复制进镜像。这是首选的文件复制方式纯复制行为可预测。ADD功能比COPY多——支持URL下载、支持自动解压tar。但正因为它“偷偷”做了额外的事容易出意料之外的结果所以社区共识是能用COPY就别用ADD除非你确实要自动解压。EXPOSE声明容器监听哪个端口。它只是文档性质的声明不会真的开放端口真正映射端口还是靠docker run -p。但写清楚对使用者友好。CMD容器启动时的默认命令。一个Dockerfile里只有最后一条CMD生效。注意它和ENTRYPOINT的配合ENTRYPOINT定死主命令CMD给默认参数两者分开用最灵活。3.2 Dockerfile构建快在哪分层缓存的计算逻辑这是Dockerfile最值钱的机制值得单独讲。Docker构建时每条指令都会生成一层构建完成后这一层会连同它的“校验和”一起缓存起来。下次构建时如果某条指令本身没变、它依赖的上层也没变就直接复用缓存不再重新执行。校验和怎么算的对RUN它看的是命令字符串对COPY/ADD它看的是被复制文件的内容校验和。这里有个关键点缓存是链式的一旦某一层失效它下面所有层的缓存都跟着失效。举个典型的踩坑例子。很多人这么写COPY . /app RUN npm install COPY . /app # 又复制一遍COPY . /app把整个项目目录包括源码复制进去紧接着npm install。问题在于——你只要改一行源码COPY . /app这一层的校验和就变了缓存失效npm install被迫重跑哪怕依赖根本没变。装一次依赖动辄几分钟改一行代码就要等一遍非常痛苦。正确做法是把变化频率低的放前面变化频率高的放后面COPY package.json package-lock.json /app/ RUN npm install COPY . /app先只复制依赖清单装依赖再复制源码。这样只要你没动package.json改多少源码都不会触发重装依赖。我在实际项目里把构建时间从8分钟压到40秒全靠这个调整。同样的道理适用于Python、Go、Java项目——先把requirements.txt、go.mod、pom.xml单独复制进去装依赖再复制代码。注意RUN命令字符串里如果有变量比如ARG传进来的变化时也会导致缓存失效。这是有意为之防止构建参数变了却复用旧缓存出错。3.3 多阶段构建把编译环境和运行环境彻底分开这是Dockerfile里第二个必须掌握的技巧。编译型语言Go、Java、C的痛点是编译需要一整套SDK和工具链但跑起来只需要一个二进制或jar包。如果编译和运行塞一个镜像里镜像动辄好几个G。多阶段构建的思路是第一个阶段负责编译第二个阶段只从第一个阶段拿走产物工具链全部丢掉。# 阶段一编译 FROM golang:1.21 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 go build -o /out/myapp . # 阶段二运行 FROM alpine:3.19 RUN apk add --no-cache ca-certificates WORKDIR /app COPY --frombuilder /out/myapp /app/myapp EXPOSE 8080 CMD [/app/myapp]关键在COPY --frombuilder它只从名为builder的阶段里拿指定的文件别的什么都不带。最终镜像基于alpine只有应用本体加证书体积能控制在20MB以内。而如果不用多阶段光是golang基础镜像就800MB起。多阶段的阶段数不限你可以一个阶段编译前端、一个阶段编译后端、最后一个阶段把两者合起来。也可以在中间阶段跑测试测试不过就构建失败。这套机制把“构建环境”和“运行环境”的耦合彻底解开了。3.4 那些能让构建提速一半的细节光会用指令还不够下面这些细节是我长年累月攒下来的每一条都省过时间。用.dockerignore砍掉构建上下文。docker build默认会把当前目录整个打包发给Docker守护进程包括.git、node_modules、target、日志文件。上下文一大每次构建光传输就慢得离谱。在项目根目录建一个.dockerignore把不需要的都列进去.git node_modules target *.log .env Dockerfile .dockerignore这一条经常被忽略但它对构建速度的影响比任何优化都直接。合并RUN减少层数。每层都有开销能合并就合并。用串起来行尾用\换行RUN apt-get update \ apt-get install -y nginx curl \ rm -rf /var/lib/apt/lists/*这里注意rm -rf /var/lib/apt/lists/*必须和安装写在**同一条RUN**里如果单独写一条RUN rm前面安装产生的缓存已经固化进上一层了删不掉了。配置国内镜像源加速拉取。在FROM之后、RUN apt-get update之前如果基础镜像支持可以注入加速地址。比如Debian系RUN sed -i s|deb.debian.org|mirrors.aliyun.com|g /etc/apt/sources.listCentOS系改/etc/yum.repos.d/下的repo文件。这对构建速度的影响很直观尤其是apk、apt这类包管理器。开启BuildKit。现代Docker默认已经用上了但在一些老环境里需要手动开export DOCKER_BUILDKIT1BuildKit支持并行构建、更好的缓存管理、还能缓存挂载把包管理器的缓存目录挂出来跨构建复用。在CI里配合--cache-from能大幅提速。基础镜像固定到具体版本甚至digest。别用ubuntu:latest今天构建和三个月后构建可能装出不一样的东西。用ubuntu:22.04起步追求极致可复现就用digest。4. 导入导出离线环境里的搬运工前两种方法解决“怎么造镜像”这一种解决“怎么搬镜像”。热词里出现的“离线安装docker”“内网”这些场景全靠它。4.1 save/load 和 export/import一字之差差在哪这四个命令常被搞混我用一张表说清楚命令作用对象是否保留分层历史是否保留元数据CMD/ENV等典型用途docker save镜像保留所有层保留镜像跨机迁移docker loadtar包恢复所有层恢复接收save的产物docker export容器压平为单层丢弃历史丢弃导出容器文件系统docker importtar包单层需重新指定从文件系统造镜像核心区别是save/load针对镜像保留分层和完整元数据export/import针对容器会把所有层压平成一层历史、ENV、CMD这些全丢导入时得自己重新指定。export/import能做一件save/load做不到的事——给镜像“减肥”。它把多层压成一层那些被上层覆盖的旧文件、被删除但还占着空间的隐藏文件都能被清掉。一个commit了很多次、层多到臃肿的镜像用exportimport走一遍体积能小一截。代价是丢失所有构建历史和配置所以只在确定不需要这些信息时用。4.2 离线环境里的搬运实操假设你在联网机器上搞好了一个镜像my-app:v1要搬到一台没网的服务器上。完整流程两条命令联网机器上导出docker save -o my-app-v1.tar my-app:v1-o指定输出文件。如果想顺便压缩可以管道给gzipdocker save my-app:v1 | gzip my-app-v1.tar.gz把.tar文件用U盘或内网传输工具拷到目标机器然后导入docker load -i my-app-v1.tar # 或者压缩包 gunzip -c my-app-v1.tar.gz | docker load导入完docker images一看镜像名字和标签都在。这里有个我踩过的坑值得说save默认带的是镜像的原始名字但如果镜像有多个标签tag它会一起打包。比如你给一个镜像打了v1和latest两个标签save出来再load进去两个标签都在。要是只想带一个save前先把多余的标签删掉。另一个坑是大镜像传输。几百MB的镜像还好几个G的镜像用save加gzip压缩比通常不错镜像里很多层本身是压缩过的所以未必能再压多少但传输还是慢。这时候可以考虑先export再import压平或者只传增量。不过对绝大多数内网迁移场景savegzipload三连就够了。提示如果是往一批机器上分发别手动一台台拷。要么搭一个内网的镜像仓库比如基于Registry自己拉一个要么写个脚本批量scp加load。一次性的事手动无所谓长期维护一定得上仓库。5. 三种方法横向对比我到底该选哪个讲了这么多落到实操上就一句话按场景选。我把三种方法的适用边界整理成表格你对照自己的情况看。维度docker commitDockerfile buildsave/load、export/import上手难度最低中等低过程可记录无完全可记录不涉及可复现不能能能原样搬运镜像分层加一层较乱每指令一层清晰原样保留/压平是否适合团队不适合最适合适合迁移场景是否适合CI/CD不适合最适合配合仓库使用能否瘦身不能多阶段可大幅瘦身export可压平瘦身典型场景抢救现场、临时实验日常构建、生产交付离线迁移、备份我的实际习惯是分层组合着用日常开发写Dockerfile进版本库临时调试用commit快速验证跨环境交付用save/load甚至镜像仓库。这三者不冲突各自管好自己那一段就行。再补一个选型时容易忽略的点——镜像的“可维护性”比“能不能跑起来”重要得多。一个commit出来能跑的镜像和一个有清晰Dockerfile能跑起来的镜像后者在三个月后别人接手时的价值可能是前者的十倍。所以只要这个镜像有“活过今天”的可能就值得花时间写Dockerfile。6. 那些年踩过的坑排查速查表前面讲原理的时候穿插了一些注意点这里把最常见的问题集中列出来方便你遇到时快速对照。现象可能原因排查与解决构建报错“no space left on device”镜像层和构建缓存占满磁盘docker system df看占用docker system prune -a清理注意会删掉未使用镜像改了代码但构建结果没变分层缓存复用了旧层调整Dockerfile顺序或docker build --no-cache强制重建镜像体积特别大把构建工具、缓存、git目录都打进去了用多阶段构建加.dockerignore合并RUN并同层清理COPY失败提示找不到文件文件在.dockerignore里被排除了或路径写错检查.dockerignore确认COPY的源路径是相对构建上下文的容器启动立刻退出CMD命令是短命进程或者没前台运行检查CMD守护进程类服务要前台运行如nginx加daemon off;import进来的镜像跑不起来export丢了CMD/ENTRYPOINT/ENV用docker run时手动指定命令和-e环境变量load进来镜像没名字none导入的tar包本身就没标签docker tag id name:tag手动打标签端口访问不通只写了EXPOSE没做-p映射EXPOSE只是声明必须docker run -p 主机端口:容器端口Windows上Docker Desktop起不来虚拟化未开启或WSL2没装进BIOS开虚拟化装WSL2检查Docker Desktop设置里的后端选项拉取镜像超时默认源访问慢在Docker Desktop或daemon.json里配置可用的镜像加速地址表格里挑两个高频问题展开说。关于none镜像。用docker images经常看到名字和标签都是none的镜像这叫“悬空镜像”dangling image通常是构建新版本时旧标签被顶掉留下的。它们占空间但不影响使用。清理用docker image prune只删悬空要连未使用的都删就用docker image prune -a。注意后一个命令会把你当前没在用的镜像全删了跑之前确认一下。关于构建缓存失效的排查。当你发现“明明没改什么构建却重跑了一大堆步骤”先别急着重构用docker build的输出看哪一步开始是CACHED、哪一步开始重新执行。从第一个没命中的指令往前看八成是它或者它上面某条指令的输入变了。最常见的就是COPY . .把无关文件的改动带了进来——这也是为什么.dockerignore那么重要。再说一个实测下来的小技巧构建完顺手看一眼docker history。它按层列出每条指令、大小、创建时间。哪一层特别大一眼就能看到通常就是需要优化的地方。我优化镜像体积的第一步永远是跑docker history和dive一个专门分析镜像层的工具先看清楚哪层在吃空间再动手。我自己这些年用下来最深的体会是创建镜像不复杂复杂的是“让这个镜像三个月后还能被人维护”。commit五分钟能出一个镜像Dockerfile半小时能出一个镜像但那半小时换来的是可复现、可优化、可交接这笔账怎么算都值。至于save/load它就是个老实巴交的搬运工平时不显山露水真到了断网的内网环境里它就是唯一能救你的那条路。手里的这三把工具各有各的用处别想着用一把解决所有问题知道什么时候该抄哪一把才是真正把Docker镜像这件事玩明白了。