Java服务的Dockerfile演进

服务容器化其中必不可少的一环就是编写的Dockerfile,它包含了创建镜像所需要的全部指令。从传统部署开发模式(这里是指开发写好代码,然后交给运维部署到空闲/指定物理机上)切换到容器化的过程中,由于一些对未知的担忧云云,初始时我司的服务Dockerfile是像这样子的

# 生产环境的服务器镜像,包含了java运行时
FROM IMAGEOS

ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

这个 IMAGEOS 本身就有近600M, 加上打入镜像中的jar包,整个服务镜像足足有650M之多。镜像上传到Harbor扫描后也有诸多安全隐患。这就导致第一次上传下载镜像会比较耗时。所以我的第一版优化的关注点就在于镜像瘦身,其实很简单,就是将基础镜像换成Alpine Linux

Alpine Linux是一个由社区开发的Linux操作系统,该操作系统以安全为理念,面向x86路由器、防火墙、虚拟专用网、IP电话盒及服务器而设计。

它最大的优点是够用且足够小,修改后的Dockerfile如下所示:

FROM openjdk:8-alpine

ARG JAR_FILE
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar"]

服务镜像一下子瘦身,仅有不到200M,Harbor扫描没有安全问题看着舒心多了。当然,还远没有结束,因为公司的打包是由Jenkins完成的,镜像构建过过程有以下几个步骤:

  1. Jenkins任务调度
  2. git pull源码
  3. maven打包
  4. docker build

maven打包这一步是需要依赖于主机的maven配置完成的,并不能做到真正意义上的一次打包,到处运行,因为上述的Dockerfile并没有描述打包过程,这点让我觉得不够好,于就就想着:如何将maven打包过程用Dockerfile来描述呢?

FROM maven:3.6.1-ibmjava-8-alpine AS build
COPY . /usr/src/app
RUN mvn -f clean package 

FROM openjdk:8-alpine
COPY --from=build /usr/src/app/target/some-1.0.0-SNAPSHOT.jar /usr/app/app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/usr/app/app.jar"]

这里运用了Docker的multi-stage打包技能,整个Dockerfile描述了两件事

  1. java源码打包
  2. 服务镜像

最后获得的服务镜像也不过200M左右,并没有增加多少,但是带来的改观我认为是非常大的,因为Dockerfile完整地描述了从源码到服务镜像是如何生成的,也就是说,你只要给了我源码,我就能构建出完全一模一样服务镜像来。但是这么做同时会有一个问题:因为java服务每次打包都需要下载依赖包,在宿主机上打包可以使用缓存,但由于Docker容器的隔离性,上述打包过程没法使用依赖缓存,这就造成了打包时间会稍微有些长,即使是同一套代码在同一宿主机上构建镜像也是如此,那要怎么解决这个问题呢?我们很容易首先想到将宿主机的缓存挂载到容器中即可,但是镜像打包过程如何实现这一设想呢?还好社区已经有人关注到这个问题了,有一套实验证工具叫做buildkit,这套工具完全兼容Dockerfile语法,如下所示

# syntax=docker/dockerfile:experimental
FROM maven:3.6.1-ibmjava-8-alpine AS build
COPY . /usr/src/app
# 挂载宿主机缓存
RUN --mount=type=cache,target=/root/.m2 mvn -f clean package

FROM openjdk:8-alpine
COPY --from=build /usr/src/app/target/some-1.0.0-SNAPSHOT.jar /usr/app/app.jar
ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/usr/app/app.jar"]

这一套特性还不完全成熟,所以在Dockerfile的第一行需要进行使用实验特性的声明,不过官网声明这套工具已集成到docker18.06中了,以后应该会越来越好,值得持续关注。

当然,在演进的过程中还遇到一些问题诸如:tomcat服务、dubbo服务、springboot服务由于某种原因混用同一Dockerfile,这就不得不使用脚本的方式去做兼容性判定,然后容器的一号进程还不是服务本身……,通过针对不同服务拆分、定制不同类别的Dockerfile,使得Dockerfile成为描述该服务镜像的标准入口,并且开箱即用,镜像的tag增加服务版本号及short Git version hash作为标识。

Dockerfile演进的过程中,我始终坚持几个原则

  1. 使用官方镜像,这个好处是显而易见的,在有限资源投入的情况下,使用官方镜像足够安全;
  2. 尽可能小镜像,毕竟实现相同的功能,使用更少的资源,岂不更好,当然前提是在保证1的情况下,镜像安全可靠,不该有功能不需要提供,当然由于历史原因,还做不到像google那样连shell也没有的镜像distroless,适用是最好的,我做过一个简单的测试,同样一个springboot服务使用openjdk:8-alpineopenjdk:8制作的镜像运行起来时,前者使用的内存要比者小5M左右,虽然这5M对于一个java应用来说可能根本微不足道,但至少说明了小镜像的一点点运行时的优势;
  3. 规范很重要,以上的Dockerfile的jar包地址其实都需要在docker build的时候传入,因为不规范的问题,甚至需要外部通过find命令查找jar包,这显然是不合理;
  4. 工具版本保持跟生产环境的一致性(服务容器化上生产环境之前,其实Dockerfile已经保证了这种一致性),比如openjdk版本,maven版本等等;
  5. 紧跟社区动态,比如jib(java服务容器化工具,不过需要代码工程非常规范),镜像的漏洞等问题

参考:

相关

comments powered by Disqus