03菜鸟:写Dockerfile

第 3 关 写 Dockerfile
本关的产出:一份能交付的 Dockerfile、一个自己造出来的镜像,以及一套读懂别人镜像的本事。
3.1 容器里改好的东西,怎么留下来
第 2 关最后留下一句话:有状态的东西挂出去,没状态的东西写在创建命令里。按那句话,容器可以随手删掉重造,真正值钱的是那条 docker run。
但那句话有个前提:你要的东西必须已经躺在某个镜像里。你能改的只是「怎么跑它」,改不了镜像本身。
现在把问题往前推一步:如果我想改的,是镜像里的内容呢?
举个具体的。第 1 关和第 2 关都用过 nginx,它启动后给你看一个欢迎页。假如想把这个页面换成自己的——进容器里把那个文件改掉,浏览器里立刻就能看到。可容器一删,改动跟着没了,跟第 1 关那句「写进容器自己文件系统里的改动一并消失」一模一样。
有一条路是现成的:把容器当前的样子冻成一个新镜像。
docker commit <容器> mysite:1.0它真的能用,而且看起来很省事——改完就冻上,不用写任何文件。
但它不该当常规手段用。 理由有四条:
- 它记的是结果,不是过程。 你拿到一个新镜像,但它为什么长这样、中间做过什么,只有一叠没有说明的层。三个月后的自己,和任何接手的人,都只能猜。
- 不可复现。 同一步操作换个基础镜像、换个时间做,结果可能不一样。而「再跑一遍得到同一个东西」,本来就是镜像存在的意义。
- 没法被检查。 Dockerfile 是文本,能进版本库、能对比改动、能让人在上面提意见。
commit的产物是二进制,谁也不知道里面夹带了什么。 - 改不动。 想调整一行?只能回到容器里重来一遍。第 2 关那句「环境变量改不了得重建」,在镜像这一层会放大好几倍。
所以这一关的立场是一句话:
镜像应该由一份文件描述出来,而不是由一次操作冻结出来。
那份文件就是 Dockerfile。
3.2 第一份 Dockerfile 与 docker build
要打包的东西先准备好——一个自己的网页:
<!doctype html><html><head><meta charset="utf-8"><title>我的第一个镜像</title></head><body><h1>它跑起来了</h1><p>这个页面是我自己打进镜像里的。</p></body></html>存成 index.html,放在一个空目录里。然后在同一个目录下建一个文件,就叫 Dockerfile,没有后缀名:
FROM nginxWORKDIR /usr/share/nginx/htmlCOPY index.html .三行,逐条看:
FROM nginx:选一个底。不从零开始,而是从已经做好的nginx镜像出发,在它之上做改动。「一台装好了 nginx 的 Linux」这件事已经有人做完了,重造一遍是浪费。WORKDIR /usr/share/nginx/html:把接下来的指令的工作目录切到这里,相当于cd。注意它管的是后面所有指令,不是一次性的。COPY index.html .:把index.html复制进镜像。第二个参数.就是上面WORKDIR定下的那个目录。而/usr/share/nginx/html正是 nginx 官方镜像约定好的网站根目录——这个路径是从文档页查来的(第 2 关的 2.5)。
接着构建:
docker build -t mysite:1.0 .-t 后面那串是给镜像起的名字,由两部分组成,中间用冒号隔开:冒号前面是名字(mysite),冒号后面是标签(1.0)。
标签就是给人看的锚点,用来区分同一个名字下的不同版本。不写冒号和标签时会自动用 latest——这个默认值有个很有名的陷阱,第 10 关专门讲。
可以反复构建,没有次数限制。 改一次 Dockerfile 就重建一次,是开发的正常节奏。每构建一次产出一个新镜像;如果用的是同一个名字,新镜像会接管这个名字,旧的那份仍然留在磁盘上、只是没名字了——第 1 关的 docker system prune 收的就是这一类。
构建完起容器:
docker run -d --name site -p 8080:80 mysite:1.0浏览器打开 http://localhost:8080,看到自己那个页面。
要观察:这个新镜像的行为跟 nginx 一模一样——照样监听 80、照样把页面发出来。因为你只是在它上面盖了一层。
易踩的坑:8080 被占用时换宿主机那一边的端口号会变(-p 8081:80),容器的 80 不用动。
3.3 那条 . 到底是什么
docker build -t mysite:1.0 . 末尾那个孤零零的 .,是用来指定一个目录的:这次构建允许用到的全部材料,都从这个目录里取。. 是「当前目录」的写法——所以整句话的意思是「材料就从我站的这儿取」。
为什么非得交一份材料过去?回到第 1 关那两个角色:真正干活的是服务端,它可能在另一台机器上,看不见你本地的文件。你要用的东西,得先交到它手上。
后果有两条:
COPY只能从当前目录里拿东西。 写成COPY ../other/x .会直接报错,那个路径根本不在交出去的材料里。- 当前目录里的东西,可能被复制进镜像。 这一条比看上去严重。
第二条能亲眼看见。把 COPY 那一行改成 COPY . /src(把整个当前目录都复制进去),再往目录里放两样东西:一个 .git,一个 node_modules。重建之后进去看:
docker run --rm mysite:1.0 ls -a /src.git、node_modules、Dockerfile 全在里头。.git 里可能躺着提交历史和密钥,node_modules 可能几百兆——它们本来都不该出现在镜像里。
挡住它的东西叫 .dockerignore:一个文件,列一堆「不要交出去」的路径,写法跟 .gitignore 一样。
node_modules.git*.log加上它,重建,再 ls 一次,那两样就没了。
易踩的坑:
.dockerignore不会自动排除Dockerfile自己。需要的话得把它也写进去。- 换个目录敲
docker build,交出去的材料就换了一份——跟第 2 关那条挂载的坑是同一类事。 - 这个目录里搁着大东西(数据库文件、模型、日志)时,这一步可能明显变慢。守护进程跑在本机时不一定全传,但「它可能被
COPY进镜像」这一条永远成立,别指望不传就等于没事。
3.4 你刚造出来的东西,其实是一叠层
第 2 关为了让「容器删了数据就没了」站得住,给过一个简化版的说法:
容器里的进程看到的那个
/,不是一个磁盘,是拼出来的:下面那层来自镜像,只读;上面那层是容器自己写的。
「下面那层」——它不是铁板一块,是一叠压在一起的文件层。而 Dockerfile 里的每一条指令产生其中的一层(FROM 除外:它只是选底,不落层)。
看实物:
docker history mysite:1.0输出里最上面两条是刚写的,往下全是基础镜像的:
24.6kB COPY index.html . # buildkit4.1kB WORKDIR /usr/share/nginx/html0B CMD ["nginx" "-g" "daemon off;"]0B STOPSIGNAL SIGQUIT0B EXPOSE map[80/tcp:{}]0B ENTRYPOINT ["/docker-entrypoint.sh"]16.4kB COPY 30-tune-worker-processes.sh …12.3kB COPY 20-envsubst-on-templates.sh …看两个地方。
第一,最左侧一列。 最上面两条不是 0——因为它们真动了文件系统(WORKDIR 建了目录,COPY 放进一个文件)。而基础镜像那一段里,CMD、EXPOSE、ENTRYPOINT、STOPSIGNAL 全是 0B:它们只改配置,不产生文件。所以「一条指令一层」是个简写——准确地说,一条指令一条记录,只有碰了文件系统的那种才真的占一层。
这一列也不等于「你的文件多大」,它有额外开销,别拿它当尺子量文件。
第二,从第三条往下,全是 nginx 官方镜像的东西。 你这份镜像底下那一大截,和官方镜像根本是同一份文件——磁盘上只存一份,两边共用。这次构建真正新增的,只有最上面那两条。
这就是为什么「基于现成镜像造自己的镜像」这么便宜:层是可以被复用的。
回到第 2 关那句简化版,它现在完整了:
容器 = 基础镜像的所有层(共用、只读)+ Dockerfile指令新建的层(共用)+ 容器运行时的可写层(独有,一删就没)
「层能被复用」还有下半场:构建的时候也能复用——两条指令产生的层如果一模一样,第二次就不用重做。怎么让这件事发生,是第 9 关的全部主题。
3.5 镜像怎么启动
回头看那份 Dockerfile:三行里没有一条管「启动」,可容器起来之后 nginx 确实跑了。谁让它跑的?
docker history 里已经写着答案——就是那两行:
CMD ["nginx" "-g" "daemon off;"]ENTRYPOINT ["/docker-entrypoint.sh"]基础镜像替你写了。 这是镜像里两个专门管启动的字段,名字本身就把分工说清楚了:
ENTRYPOINT(入口)—— 「从哪儿进去」。它相对固定,是这个镜像的身份。nginx 镜像让它先跑一段准备脚本,就是第 2 关那条坑里说的「准备工作」。CMD(command 的缩写,「命令」)—— 「默认跑什么」。它是默认值,可以被覆盖。
第 2 关留的那个口子,现在能补全了:
docker run <镜像> <命令>里写在镜像名后面的东西,顶掉的是CMD;ENTRYPOINT不动;- 想连它一起换掉,得显式说:
docker run --entrypoint <程序> <镜像>。
顺带一句:镜像名后面那个位置的含义由镜像决定——有的镜像(如中间件 / 应用镜像nginx 、mysql、redis的entrypoint是shell脚本)把它当「要跑的程序」启动,有的只当「参数」。所以第 2 关那招是个经验、不是保证。想无条件进去,用 docker run -it --entrypoint sh <镜像>:它在 Docker 这一层直接换掉 ENTRYPOINT,不看镜像自己怎么写。
把这两条写进自己的 Dockerfile 看看。 在 COPY 那行下面加两条:
FROM nginxWORKDIR /usr/share/nginx/htmlCOPY index.html .EXPOSE 80CMD ["nginx", "-g", "daemon off;"]重建之后再 docker history mysite:1.0:
0B CMD ["nginx" "-g" "daemon off;"] ← 你写的0B EXPOSE [80/tcp] ← 你写的24.6kB COPY index.html . # buildkit4.1kB WORKDIR /usr/share/nginx/html0B CMD ["nginx" "-g" "daemon off;"] ← 基础镜像的两行一模一样。(history 的排版是它自己的事,跟你 Dockerfile 里的写法不必逐字相同。)
这正说明你抄了一份基础镜像已经做过的事——实际项目里这两行当然可以不写。写出来是为了让你亲手看见「基础镜像替你做了什么」。而当一个镜像要跑的是你自己的程序时,CMD 就必须自己写了,那才是它真正出场的地方。
CMD 有两种写法,上面用的是第一种:
CMD ["nginx", "-g", "daemon off;"] # 逐项写开:程序,以及它的参数CMD nginx -g "daemon off;" # 整句话交给 shell 去解释第二种看着更眼熟,但两者不是一回事。这里先记住有这两种写法、别写混就够了——为什么要在意它们的区别,第 8 关会讲。
EXPOSE 80 是声明「这个镜像会在容器里监听 80」。它只是声明:真正让宿主机能访问到,还是得靠 -p(第 2 关的 2.5 讲过,EXPOSE 就是那份声明的出处)。
3.6 反过来:用这一关的本事读别人的镜像
现在把方向倒过来。手上有个陌生镜像,你不写它、只想看懂它——docker history 就是入口。
其实刚才已经读过一次了:mysite:1.0 输出里从第三条往下那一整段,就是 nginx 官方镜像的层。从上往下读,它做过什么基本能还原出来——装了哪些包、加了什么用户、把自己的脚本复制到了哪、最后用什么启动。
nginx 的 Dockerfile 公开在 Docker Hub 的官方镜像页上,文档页里就链着它。两样对着看,一个镜像就没有秘密了。
这一关的产出:一份能交付的 Dockerfile、一个自己造的镜像,和一套读镜像的本事。下一关镜头转向容器要承载的东西,先解决第 1 关就埋下的那个问题——数据怎么活过容器。
本关命令速查
构建与查看镜像,见 命令速查表 · 构建镜像。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!





