SpringBoot系列: 制作Docker镜像的全过程
本文主要参考了 https://segmentfault.com/a/1190000016449865 , 感谢作者的付出. 另外, 在本文中, 演示了Windows+Maven+Docker Toolbox环境下的制作全过程.
和 CI 工具的集成, 可以参考下面文章:
https://spring.io/guides/topicals/spring-boot-docker/
https://spring.io/guides/gs/spring-boot-docker/
=======================================
Demo 性质的 Dockerfile 文件
=======================================
本 Dockerfile 仅仅适合简单的测试. 它不满足下面提及生产环境的几个要求.
FROM openjdk:8-jdk-alpine ARG JAR_FILE COPY target/${JAR_FILE} app.jar ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"] # 指定暴露端口, 这样在容器运行时可以知道应该映射哪些端口 EXPOSE 8080
=======================================
生产环境对 Docker 容器的要求
=======================================
1. 容器的时区应该是东 8 区
2. 容器中的程序不应该以 root 账号启动
3. 能传递 JVM 参数、Java System Properties、程序自定义参数等
=======================================
Docker 容器的规划
=======================================
默认情况下, docker 容器中的用户是 root, 该 root 就是 HostOS 的 root, 应用程序直接使用 root 账号存在较大的安全风险, 所以容器用户应该采用非 root 用户. 在我们的规划中, 容器将使用 java-app 用户, 对应的用户组为 java-app.
另外, 如果在 Dockerfile 中需要使用 sudo 命令, 推荐使用 gosu 而不是 sudo, sudo 会引起 TTY 和信号转发异常.
如果使用的是 openjdk:<version>-alpine, Dockerfile 新建用户的指令为:
RUN set -eux; \ addgroup --gid 1000 java-app; \ adduser -S -u 1000 -g java-app -h /home/java-app/ -s /bin/sh -D java-app;
如果使用的是 openjdk:<version>-slim 和标准 openjdk:<version>, Dockerfile 新建用户的指令为:
RUN set -eux; \ addgroup --gid 1000 java-app; \ adduser --system --uid 1000 --gid 1000 --home=/home/java-app/ --shell=/bin/sh --disabled-password java-app;
在创建用户 java-app 后, Dockerfile 可以使用 USER java-app 指令明确运行的用户.
容器中的目录规范如下:
/home/java-app
├── docker-entrypoint.sh
├── lib
│ └── app.jar
├── etc
├── logs
└── tmp
=======================================
功能完备 Dockerfile 文件
=======================================
-------------------------
Dockerfile 文件
-------------------------
存放位置: Dockerfile 文件应和 pom.xml 放在同一个目录下.
源码参考: https://github.com/chanjarster/dockerfile-examples/blob/master/Dockerfile
修改点有:
1. 增加了 VOLUME /tmp 指令, /tmp 目录是 Tomcat 的缺省工作目录, 加上 VOLUME /tmp 指令容器会自动映射一个目录到 Host OS 的 /var/lib/docker 下.
2. base 镜像从 openjdk:8-alpine 修改为 openjdk:8-jdk-alpine, 貌似后者是正式名称.
3. 增加 docker-entrypoint.sh 赋予执行权限, 不然会报 permission denied 错误.
FROM openjdk:8-jdk-alpine ARG NAME ARG VERSION ARG JAR_FILE LABEL name=$NAME \ version=$VERSION # 设定时区 ENV TZ=Asia/Shanghai RUN set -eux; \ ln -snf /usr/share/zoneinfo/$TZ /etc/localtime; \ echo $TZ > /etc/timezone # 新建用户 java-app RUN set -eux; \ addgroup --gid 1000 java-app; \ adduser -S -u 1000 -g java-app -h /home/java-app/ -s /bin/sh -D java-app; \ mkdir -p /home/java-app/lib /home/java-app/etc /home/java-app/jmx-ssl /home/java-app/logs /home/java-app/tmp /home/java-app/jmx-exporter/lib /home/java-app/jmx-exporter/etc; \ chown -R java-app:java-app /home/java-app # 导入启动脚本 COPY --chown=java-app:java-app docker-entrypoint.sh /home/java-app/docker-entrypoint.sh
# 赋执行权限
RUN ["chmod", "+x", "/home/java-app/docker-entrypoint.sh"]
# 导入 JAR COPY --chown=java-app:java-app target/${JAR_FILE} /home/java-app/lib/app.jar USER java-app # 增加 sh 前导命令, 避免出现权限不足问题 ENTRYPOINT ["/home/java-app/docker-entrypoint.sh"] # 指定暴露端口, 这样在容器运行时可以知道应该映射哪些端口 EXPOSE 8080 #在容器运行时声明一个 volume, 在容器中的目录为 /tmp VOLUME /tmp
-------------------------
docker-entrypoint.sh
-------------------------
存放位置: docker-entrypoint.sh 文件应和 pom.xml 放在同一个目录下.
源码参考: https://github.com/chanjarster/dockerfile-examples/blob/master/docker-entrypoint.sh
修改点有:
1. 为了减少 Tomcat 启动时间, java 启动参数中增加 /dev/urandom 作为随机数的熵.
2. 在 java 命令之前加上 exec 命令, 这样确保 pid 1是java , 而不是 sh .
#!/bin/sh set -ex; exec /usr/bin/java \ $JAVA_OPTS \ -Djava.io.tmpdir="/home/java-app/tmp" \ -Djava.security.egd=file:/dev/./urandom \ -jar \ /home/java-app/lib/app.jar \ "$@"
=======================================
pom.xml 增加 dockerfile-maven-plugin 插件
=======================================
Spotify 开源的 dockerfile-maven-plugin 插件, 可以在 maven build 的时候基于 Dockerfile 生成 docker 镜像, 需要说明的是, 该插件不是帮助我们生成 Dockerfile 文件的. 使用该插件的好处主要好处有:
1. 直接和 maven 集成;
2. 我们可以在 pom.xml 定义参数, 然后很方便第通过该插件将参数传到 Dockerfile 中.
注意:
pom.xml 目标的 artifactId 必须是全部为小写字母, 否则后续制作 docker 镜像会报网络错误, 错误内容为: Connection reset by peer: socket write error
设定 docker 镜像名的前缀和 registry 地址:
<properties> <!--docker 镜像的组织名 --> <docker.image.prefix>myorg</docker.image.prefix> <!--docker registry 的路径, 如果是本地 registry, 取值为空 --> <docker.registry>localhost:5000/</docker.registry> </properties>
指定最终 jar 的生成规则, 并启用 dockerfile-maven-plugin 插件:
<build> <finalName>${project.artifactId}-${project.version}</finalName> <plugins> <plugin> <groupId>com.spotify</groupId> <artifactId>dockerfile-maven-plugin</artifactId> <version>1.4.8</version> <configuration> <repository>${docker.registry}${docker.image.prefix}/${project.artifactId}</repository> <!--指定 registry 服务器的用户和密码 --> <!-- <username>repoUserName</username> <password>repoPassword</password> --> <useMavenSettingsForAuth>true</useMavenSettingsForAuth> <tag>${project.version}</tag> <buildArgs> <JAR_FILE>${project.build.finalName}.${project.packaging}</JAR_FILE> <VERSION>${project.version}</VERSION> <NAME>${project.artifactId}</NAME> </buildArgs> </configuration> </plugin> </plugins> </build>
=======================================
准备 Windows 的镜像编译环境
=======================================
docker 镜像编译需要连接一个 docker daemon, 我使用 Docker Toolbox for windows 准备环境, 下面是准备步骤:
(1) 创建一个 Docker2Boot 虚机, 名称为 vm1
docker-machine create --driver virtualbox vm1
(2) 检查所有 Docker2Boot 虚机, 会显示每个虚机是否有证书问题
docker-machine.exe ls
(3) 如果 vm1 证书有问题, 修复它
docker-machine.exe regenerate-certs vm1
(4) 设置 vm1 为缺省的 Docker2Boot 虚机
docker-machine.exe env vm1
然后照着该命令的输出, 将它们都增加 Windows 的环境变量中, 并重启机器.
(5) 验证 vm1 应该是当前 active 的 vm
docker-machine.exe active
镜像编译需要连接 docker daemon, 到底要连接哪一台机器上的 docker daemon, dockerfile-maven-plugin 插件是按下面的顺序确定目标 docker daemon 的:
1. 如果配置了 DOCKER_HOST 等一系列环境变量, 按照环境变量为准.
2. 如果没有设定环境变量, 会在本机的 ~/.docker/ 配置目录找相应的连接信息.
3. 如果是 jenkins 服务器的话, 配置目录应该是 C:\Windows\System32\config\systemprofile\.docker
因为我们已经设置了 Windows 环境变量, 不需要再关心 ~/.docker/ 目录中的配置.
=======================================
docker 镜像编译
=======================================
---------------------------------------
推荐: 使用 dockerfile-maven-plugin 插件
---------------------------------------
我是在 Windows Eclipse 中完成 maven 编译过程的.
构建 docker 镜像的 maven 命令为:
mvn clean package dockerfile:build -DskipTests
push 镜像到 docker 私服
mvn clean package dockerfile:push -DskipTests
---------------------------------------
使用 docker 命令直接编译
---------------------------------------
用 maven package 后, 会在 target 目录下生成最终项目 jar, 然后用下面命令制作 docker image
$ docker build --build-args=target/*.jar -t myorg/myapp:v1 .
docker build 的重要参数:
--build-args list , 如果 Dockerfile 中设定了 ARG, 用这个参数传入变量值
-t 设定镜像的 tag, 格式为 reps/name:version
-f 指定 Dockerfile 名称, 如果缺省, 文件名为 Dockerfile
=======================================
运行容器
=======================================
docker run -init -p 8080:8080 myorg/java-examples-1:1.0-SNAPSHOT
docker run -init -p 8080:8080 -e JAVA_OPTS='-Xmx128M -Xms128M -Dabc=xyz -Ddef=uvw' myorg/java-examples-1:1.0-SNAPSHOT
docker run -init -p 8080:8080 myorg/java-examples-1:1.0-SNAPSHOT --debug
对于 Java 8, 推荐增加下面的 JVM 参数, 用来开启容器内存使用的 hint, 防止 SpringBoot 超用内存, Java 11 之后会自动开启该选项.
-XX:+UnlockExperimentalVMOptions -XX:+UseCGroupMemoryLimitForHeap
============================
docker 微服务的优雅关闭
============================
使用 docker stop 关闭容器时, 只有 init(pid 1)进程能收到中断信号, 如果容器的pid 1 进程是 sh 进程, 它不具备转发结束信号到它的子进程的能力, 所以我们真正的java程序得不到中断信号, 也就不能实现优雅关闭. 解决思路是: 让pid 1 进程具备转发终止信号, 或者将 java 程序配成 pid 1 进程.
需要说明的是, docker stop 默认是等待10秒钟, 这个时间有点太短了, 可以加 -t 参数, 比如 -t 30 等待30秒钟.
----------------------------------
背景知识
----------------------------------
上面的 Dockerfile 的pid 1是一个 sh 命令,并不能实现优雅关闭, 需要再改进.
ENTRYPOINT/CMD 的几种写法, 会影响 pid 1 进程的产生:
写法1:
"shell" format 的 ENTRYPOINT/CMD, 不带方括号:
ENTRYPOINT top -b
#PID 1 是 /bin/sh -c shell top -b
#另外有个 pid 7 是 top -b
写法2:
"shell" format 的 ENTRYPOINT/CMD, 不带方括号, 但这次ENTRYPOINT后紧跟了一个 exec :
ENTRYPOINT exec top -b
#PID 1 是 top -b
写法3:
"exec" form 的 ENTRYPOINT/CMD, 方括号括着, 每个部分都是json字符串.
ENTRYPOINT ["top","-b"]
pid 1 进程就是 top -b
所以推荐使用"exec" form的命令, 而不是 "shell" 形式的命令.
----------------------------------
init 进程调整方案
----------------------------------
方案1: 自行确保 pid 1 是我们的java程序.
上面的 Dockerfile 可以确保 java 程序作为 pid 1进程.
方案评价: 有时候不太容易将我们的主程序调整为 pid 1 进程, 另外虽然 docker 容器推荐是单进程, 但实际情形往往不是这么理想. 本方案仅仅适合单进程容器.
方案2: 适合于 Docker 1.13 以上.
Docker 1.13以上的docker run 命令新增了 --init 参数, 加了该参数后, docker 会启用 tini 作为 init (pid 1) 进程, 该 tini 进程能够将终止信号转发给其子进程, 同时能reap 子进程, 不会出现因孤儿进程导致的线程句柄无法回收情形.
详见: https://github.com/krallin/tini
方案3(推荐): 在docker镜像中强制 tini 作为 init(pid 1) 进程, 该方案使用范围广, ENTRYPOINT 可以是任意sh脚本文件.
改造之前的 Dockerfile
ENTRYPOINT ["/docker-entrypoint.sh"]
改造后的 Dockerfile
# Add Tini ENV TINI_VERSION v0.18.0 ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini RUN chmod +x /tini ENTRYPOINT ["/usr/local/bin/tini", "--", "/docker-entrypoint.sh"]
tini 文档:
https://github.com/krallin/tini
有关 docker run --init 参数的说明
http://stackoverflow.com/a/39593409/6309
===============================
更多推荐
===============================
https://efekahraman.github.io/2018/04/docker-awareness-in-java