一、实战:自己做一个 tomcat 镜像
这部分偏开发,要懂点 Java 和 tomcat。运维视角学它主要看命令格式和部署方式,所以我这里只写命令,没装 JDK 演示测试。
准备两样东西:tomcat 压缩包(以及它依赖的 jdk),和一份 Dockerfile。
# Dockerfile 是标准的官方写法,build会自动寻找该文件不需要手动 -f
FROM centos
MAINTAINER zzz<1234@qq.com>
COPY readme.txt /usr/local/readme.txt
# ADD 命令添加的会自动解压,后面跟上解压位置
ADD jdk-8u11-linux-x64.tar.gz /usr/local/
ADD apache-tomcat-9.0.22.tar.gz /usr/local/
RUN yum -y install vim
ENV MYPATH /usr/local
WORKDIR $MYPATH
ENV JAVA_HOME /usr/local/jdk1.8.0_11
ENV CLASSPATH $JAVA_HOME/lib/dt.jar:$JAVA_HOME/lib/tools.jar
ENV CATALINA_HOME /usr/local/apache-tomcat-9.0.22
ENV CATALINA_BASE /usr/local/apache-tomcat-9.0.22
ENV PATH $PATH:$JAVA_HOME/bin:$CATALINA_HOME/lib:$CATALINA_HOME/bin
EXPOSE 8080
CMD /usr/local/apache-tomcat-9.0.22/bin/startup.sh && \
tail -F /usr/local/apache-tomcat-9.0.22/logs/catalina.out
几个点:
COPY和ADD的区别就在自动解压这一条上,ADD压缩包会自动解开,COPY不会- 一堆
ENV是为了让$PATH里带上 jdk 和 tomcat 的 bin,进容器敲startup.sh才不用写全路径 CMD用&&把启动脚本和tail -F串起来,后面这个 tail 才是让容器不退出的关键,不然启动脚本跑完容器就停了
构建和运行:
docker build -t diytomcat .
docker run -d -p 8081:8080 \
-v /home/zzz/build/tomcat/test:/usr/local/apache-tomcat-9.0.22/webapps/test \
-v /home/zzz/build/tomcat/logs:/usr/local/apache-tomcat-9.0.22/logs \
diytomcat
顺便说一句,原笔记这里写的是 /url/local/...(少了 s),而且两行 -v 之间漏了续行符 \、挂载目标也少写了一级,直接复制会拼成一条错命令,上面已经按 /usr/local/apache-tomcat-9.0.22/... 修好了。
接下来去挂载目录看有没有内容、curl 测一下、浏览器访问一下。因为做了挂载,发布项目直接在 Linux 里写就行,不用重新构建镜像:

然后看 catalina.out 的日志(略)。这一块不是运维的内容,看个大概的格式排版和测试方式就够了。
再练一个:把微服务 jar 打成镜像
顺手把后面 compose 要用的那个微服务镜像也构建出来。先把打好的 jar 传上去:

然后写 Dockerfile。这里的 ADD 相当于把 jar 包拷进镜像并顺便改了个名字:

对照着看,ADD 源路径 目标路径 就是这个文件在做的事 —— 一般会把需要的文件都放在同一个目录,所以源路径直接写文件名;里面的 VOLUME /tmp 就是前面说过的"在镜像里声明一个挂载点"。
构建和运行:
docker build -t zzyy_docker:1.6 .
docker run -d -p 6001:6001 zzyy_docker:1.6
如果 docker 被防火墙拦了,先把防火墙关掉、重启 docker 再执行。起来之后本地测一下接口,能看到返回就说明镜像没问题:

这个微服务后面会被写成 compose 项目,一条命令连 Redis、MySQL 一起拉起来 —— Compose 那一页用的就是它。
二、构建优化:缓存、.dockerignore、多阶段构建
前面说过,每条指令都是一层,所以镜像怎么变小、构建怎么变快,全在"层"上做文章。
1. 用好吃构建缓存
Docker 逐层构建,某一层没变就直接复用缓存,一旦某层变了,它后面的所有层全部重建。
所以顺序很讲究:把不常变的东西放前面,经常变的放后面。比如一个 Node 项目:
# 好:依赖没变就不重装
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# 差:改一行源码,上面的 npm ci 也白装了
COPY . .
RUN npm ci
RUN 该合并的也要合并,用 && 串起来能少一层,顺手把包管理器的缓存清掉:
RUN yum makecache && yum install -y vim net-tools && yum clean all
上一页那个 centos 的 Dockerfile 就是反例:每装一个包起一层,层数太多。同样的需求换成 ubuntu 底包、再把几条 RUN 合成一条,层数和体积都能降下来:
FROM ubuntu
LABEL maintainer="zzz<zzz@qq.com>"
# 新版的作者写法,MAINTAINER 已经过时了
ENV MYPATH /usr/local
WORKDIR $MYPATH
RUN apt-get update && apt-get install -y \
net-tools \
iproute2 \
inetutils-ping \
&& rm -rf /var/lib/apt/lists/*
# 清除缓存
EXPOSE 80
CMD ["/bin/bash"]
原来那三条 RUN apt-get install -y xxx 被 && 串成了一条,rm -rf /var/lib/apt/lists/* 也在同一层里把缓存删掉 —— 不删的话缓存会跟着这一层一起被打进镜像。
2. .dockerignore
构建的时候 Docker 会把整个目录(构建上下文)打包发给引擎,构建输出里那句 => [internal] load .dockerignore 就是干这个的:
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
写一个 .dockerignore 把不用进镜像的东西排除掉,.git、node_modules、日志、本地配置都写进去,上下文小了构建会快很多:
.git
node_modules
*.log
.env
3. 那两个构建警告
回到第 7 页那次构建,结尾有三条警告,其实是在提醒两件事:
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 5)
- MultipleInstructionsDisallowed: Multiple CMD instructions should not be used in the same stage because only the last one will be used (line 5)
- JSONArgsRecommended: JSON arguments recommended for CMD to prevent unintended behavior related to OS signals (line 6)
JSONArgsRecommended:CMD echo "---end---"这种 shell 写法,Docker 会套一层/bin/sh -c,进程收不到系统的停止信号;写成CMD ["echo","---end---"]就不会有这个问题。能用 JSON 数组写法就用。MultipleInstructionsDisallowed:一个 stage 里写多个CMD,只有最后一个生效,前面那个纯属白写。
4. 多阶段构建
编译型的项目(Go、Java、前端)最烦的是把整个编译工具链也打进了运行镜像里。多阶段构建就是在一个 Dockerfile 里写多个 FROM,每个 FROM 是一个阶段,用 AS 起名字,最后只把产物拷进运行阶段:
# 构建阶段:带着完整的 Go 工具链,只在这里编译
FROM golang:1.22 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app
# 运行阶段:只拷二进制,工具链一点都进不来
FROM alpine:3.20
COPY --from=builder /out/app /usr/local/bin/app
ENTRYPOINT ["app"]
前端的套路一样:一个阶段 npm run build 出静态文件,另一个阶段 FROM nginx 再把 dist 拷进去。这样最终镜像从几百兆、上 G 掉到几十兆,顺手还少了 COPY . . 把源码带进生产镜像的隐患。
5. 构建失败留下的虚悬镜像
镜像之间是一层层共用的,构建失败、或者构建时忘了写 -t,就会留下仓库名和标签都是 none 的镜像,这种叫虚悬镜像:

留着没用还占地方,直接清掉重新构建:
docker image prune
# prune 译为修剪
三、发布到 Docker Hub
- 先注册一个账号。Hub 需要梯子(没有也能用但是很慢),实在麻烦而且大概率用不到,我这边只是演示。
- 在服务器上先提交登录,否则 push 不上去(毕竟没登录)。
root@VM-0-9-ubuntu:/home/dockerfile# docker login --help
Usage: docker login [OPTIONS] [SERVER]
Authenticate to a registry.
Defaults to Docker Hub if no server is specified.
Options:
-p, --password string Password or Personal Access Token (PAT), or "-" to read from stdin
--password-stdin Take the Password or Personal Access Token (PAT) from stdin
-u, --username string Username
登录命令就长这样,看到 Login Succeeded 就成了:

然后 push:
docker push 作者/镜像名:版本号
最好带上版本号,不带可能被拒绝。另外 push 的镜像必须是本地已经有的,想改版本号得先 tag 再 push。tag 命令是新增一个版本,不删原来的:

发布也是按层级结构上传的,所以只有变化的层才真的传。虚拟机环境下几乎发不上去,必须翻墙。
四、发布到阿里云容器镜像服务
没有阿里云账号,而且用得少,这里只把流程记一下,具体照官方文档走。
- 登录阿里云
- 找到容器镜像服务
- 创建命名空间做隔离,防止命名冲突

- 创建容器镜像

- 浏览页面信息,访问仓库地址

- 按页面上的操作步骤执行,无非还是 login / tag / push 那三条命令

- 成功了就能看到镜像版本

以后发布项目就是发布 Docker 镜像,别人 docker pull 然后 run 就能跑起来自己的项目了。
五、整个流程串一遍


图片是视频里的流程图,配合前面的命令看一遍就顺了。load 和 save 这两个命令自己用 help 学就行,是把镜像导成 tar 包再导回来用的。
到这里为止,Docker 的日常使用就没什么大问题了。想再深入就得看 Docker 网络,那一块才是偏运维的,下一页开始。