docker如何导出某个镜像增量部分

问题

离线生产环境下需要 docker save 镜像,然后 dock load 导入。

问题是 docker save 导出的是个完整的镜像,当有变动时,每次都传输完整镜像特别浪费时间,在现场环境执行 load 时可以看出来 docker 只会导入变动的 layer

docker镜像构建原理

背景

1.体验了官方推荐的镜像制作方案,执行docker history命令观察镜像内部,发现是由多个layer组成的,如下图:=

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件


2.问题来了:搞这么多layer干啥?接下来以图文方式,您一起理解docker镜像layer对java开发者的的作用;

声明

本文的目标是通过图文帮助java开发者理解docker镜像的layer作用,内容和实际情况并未完全保持一致,例如基础镜像的layer没有提到,而且java镜像的layer可能不止业务镜像、配置文件、依赖库这三层;

常见角色

使用docker时,有三个常见角色:

1.镜像制作者:本文中就是SpringBoot应用开发者,写完代码把应用做成docker镜像;
2.docker公共镜像仓库:镜像制作者将镜像推送到仓库给大家使用;
3.镜像使用者:从镜像仓库将镜像下载到本地使用;

接下来的故事围绕上述三个角色展开;

从制作到使用的过程

1.如下图,SpringBoot应用开发者,写完代码把应用做成docker镜像,该镜像的TAG是1.0,此时开发者将镜像推送到公共仓库时,一共要推送三个layer:

centos导出docker镜像文件 如何导出docker镜像_java_02

2.接下来,使用者要下载镜像,就从镜像仓库下载三个layer:

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_03

3.此时,三个角色拥有的内容都是一样,都是三个layer:

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_04

4.这时候SpringBoot开发者修改了业务代码,于是做了个新的镜像(TAG是2.0),然后推送到镜像仓库;
5.重点来了:因为只改了业务代码,因此只有业务class的layer是新的,只有这个layer会被推送到仓库,如下图:

centos导出docker镜像文件 如何导出docker镜像_jenkins_05

6.对镜像使用者来说,如果之前下载过1.0的镜像,此时要用2.0镜像的话,只要从仓库下载最新的业务class的layer即可:

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_06

7.最终结果如下,公共仓库和镜像使用者都已最小的代价得到了2.0镜像:

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_07

可见,使用多个layer的镜像,在镜像的分发过程中,相比单一layer的镜像会更加高效,尤其是使用

springboot 2.0.8 分层打包

2.0.8 官网介绍demo地址

可执行 Jar 文件结构

example.jar
 |
 +-META-INF
 |  +-MANIFEST.MF
 +-org
 |  +-springframework
 |     +-boot
 |        +-loader
 |           +-<spring boot loader classes>
 +-BOOT-INF
    +-classes
    |  +-mycompany
    |     +-project
    |        +-YourClasses.class
    +-lib
       +-dependency1.jar
       +-dependency2.jar

应用程序类应放置在嵌套的“BOOT-INF/classes”目录中。依赖项应该放在嵌套的“BOOT-INF/lib”目录中。

Spring Boot 的“JarFile”类

用于支持加载嵌套 jar 的核心类是 org.springframework.boot.loader.jar.JarFile. 它允许您从标准 jar 文件或嵌套的子 jar 数据加载 jar 内容。首次加载时,每个位置都JarEntry映射到外部 jar 的物理文件偏移量,如下例所示:

myapp.jar
+-------------------+-------------------------+
| /BOOT-INF/classes | /BOOT-INF/lib/mylib.jar |
|+-----------------+||+-----------+----------+|
||     A.class      |||  B.class  |  C.class ||
|+-----------------+||+-----------+----------+|
+-------------------+-------------------------+
 ^                    ^           ^
 0063                 3452        3980

前面的示例显示了如何在 at 位置A.class找到。实际上可以从嵌套的 jar 中找到 at position和is at position 。/BOOT-INF/classes``myapp.jar``0063``B.class``myapp.jar``3452``C.class``3980

有了这些信息,我们就可以通过寻找外部 jar 的适当部分来加载特定的嵌套条目。我们不需要解压存档,也不需要将所有入口数据读入内存。

jar zip打包执行

某些 PaaS 实现可能会选择在运行之前解压缩存档。例如,Cloud Foundry 就是这样运作的。您可以通过启动适当的启动程序来运行解压的存档,如下所示:

$ unzip -q myapp.jar 
$ java org.springframework.boot.loader.JarLauncher

demo操作过程

pom
<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.0.8.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <layout>ZIP</layout>
                </configuration>
            </plugin>
        </plugins>
    </build>
打包后

windows本地运行解压jar

java org.springframework.boot.loader.JarLauncher
docker

Dockerfile 文件官网介绍

FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=target
COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY ${DEPENDENCY}/META-INF /app/META-INF
COPY ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","com.liuhm.SpringbootdemoApplication"]

文件目录:

centos导出docker镜像文件 如何导出docker镜像_java_08

优化后的Dockerfile

# 指定基础镜像,这是分阶段构建的前期阶段
FROM openjdk:8-jdk-alpine as builder
# 执行工作目录
WORKDIR target
# 配置参数
ARG JAR_FILE=target/*.jar
# 将编译构建得到的jar文件复制到镜像空间中
COPY ${JAR_FILE} application.jar 
RUN unzip application.jar


FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=target
COPY --from=builder ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=builder ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=builder ${DEPENDENCY}/BOOT-INF/classes /app
ENTRYPOINT ["java","-cp","app:app/lib/*","com.liuhm.SpringbootdemoApplication"]

报错

centos导出docker镜像文件 如何导出docker镜像_java_09

改成

ENTRYPOINT java ${JAVA_OPTS} -cp app:app/lib/* com.liuhm.SpringbootdemoApplication
打包使用 --no-cache

在服务器上

unzip -q app.jar
docker build --no-cache -t 192.168.0.88/magic/test:1 .
docker build -t 192.168.0.88/magic/test:1 .
docker login 192.168.0.88  -u admin -p hcloud1234!
docker push 192.168.0.88/magic/test:1
下载日志

centos导出docker镜像文件 如何导出docker镜像_jar_10

历史记录

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_11

打包不用 --no-cache

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_12

拉取对比

docker pull 192.168.0.88/magic/test:1

有三层layer存在,是基础镜像

docker pull 192.168.0.88/magic/test:2

有四层layer存在,是基础镜像和test:1的lib是一样的

centos导出docker镜像文件 如何导出docker镜像_jar_13

导入导出对比

docker save -o ./apollo.tar 192.168.0.88/magic/test:2 192.168.0.88/magic/test:1

docker load - i apollo.tar

centos导出docker镜像文件 如何导出docker镜像_jar_14

springboot 2.3 分层打包

springboot 2.3以前的可以按照springboot 2.0.8的方式进行分层

demo地址

2.3.0官网介绍

可执行 Jar 文件结构

example.jar
 |
 +-META-INF
 |  +-MANIFEST.MF
 +-org
 |  +-springframework
 |     +-boot
 |        +-loader
 |           +-<spring boot loader classes>
 +-BOOT-INF
    +-classes
    |  +-mycompany
    |     +-project
    |        +-YourClasses.class
    +-lib
       +-dependency1.jar
       +-dependency2.jar

应用程序类应放置在嵌套的“BOOT-INF/classes”目录中。依赖项应该放在嵌套的“BOOT-INF/lib”目录中。

Spring Boot 的“JarFile”类

用于支持加载嵌套 jar 的核心类是 org.springframework.boot.loader.jar.JarFile. 它允许您从标准 jar 文件或嵌套的子 jar 数据加载 jar 内容。首次加载时,每个位置都JarEntry映射到外部 jar 的物理文件偏移量,如下例所示:

myapp.jar
+-------------------+-------------------------+
| /BOOT-INF/classes | /BOOT-INF/lib/mylib.jar |
|+-----------------+||+-----------+----------+|
||     A.class      |||  B.class  |  C.class ||
|+-----------------+||+-----------+----------+|
+-------------------+-------------------------+
 ^                    ^           ^
 0063                 3452        3980

前面的示例显示了如何在 at 位置A.class找到。实际上可以从嵌套的 jar 中找到 at position和is at position 。/BOOT-INF/classes``myapp.jar``0063``B.class``myapp.jar``3452``C.class``3980

有了这些信息,我们就可以通过寻找外部 jar 的适当部分来加载特定的嵌套条目。我们不需要解压存档,也不需要将所有入口数据读入内存。

jar zip打包执行

某些 PaaS 实现可能会选择在运行之前解压缩存档。例如,Cloud Foundry 就是这样运作的。您可以通过启动适当的启动程序来运行解压的存档,如下所示:

$ unzip -q myapp.jar 
$ java org.springframework.boot.loader.JarLauncher

demo操作过程

pom
<parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.3.0.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>2.3.0.RELEASE</version>
                <configuration>
                    <layers>
                        <enabled>true</enabled>
                    </layers>
                </configuration>
            </plugin>
        </plugins>
    </build>
pom.xml中spring-boot-maven-plugin插件新增的参数
  1. pring-boot-maven-plugin插件新增参数如下图所示:

2.上述参数有啥用?我这边编译构建了两次jar,第一次有上述参数,第二次没有,将两次生成的jar解压后对比,发现用了上述参数后,生成的jar会多出下图红框中的两个文件:

centos导出docker镜像文件 如何导出docker镜像_jar_15

3.看看layers.idx文件的内容,如下图:

centos导出docker镜像文件 如何导出docker镜像_java_16

4.上图中的内容分别是什么意思呢?官方已给出了详细解释,如下图红框:

centos导出docker镜像文件 如何导出docker镜像_docker_17

5.综上所述,layers.idx文件是个清单,里面记录了所有要被复制到镜像中的信息,接下来看看如何使用layers.idx文件,这就涉及到jar包中新增的另一个文件:spring-boot-jarmode-layertools-2.3.0.RELEASE.jar

spring-boot-jarmode-layertools工具

1.前面已经介绍过jar中除了layers.idx,还多了个文件:spring-boot-jarmode-layertools-2.3.0.RELEASE.jar ,来看看这个文件的用处;

2.进入工程的target目录,这里面是编译后的jar文件(我这里文件名为dockerlayerdemo-0.0.1-SNAPSHOT.jar),注意此时的spring-boot-maven-plugin插件是带上了下图红框中的参数的:

centos导出docker镜像文件 如何导出docker镜像_jenkins_18

3.执行以下命令:

java -Djarmode=layertools -jar springboot2_3_0-1.jar list

4.得到结果如下图所示,是layers.idx文件的内容:

centos导出docker镜像文件 如何导出docker镜像_jenkins_19

5.来看看官方对这个layertools的解释,list参数的作用上面我们已经体验过了,重点是红框中的extract参数,它的作用是从jar中提取构建镜像所需的内容:

centos导出docker镜像文件 如何导出docker镜像_java_20

6.看到这里,jar构建生成清单layers.idx,Dockerfile中根据清单从jar提取文件放入镜像:

打包后
docker

Dockerfile

# 指定基础镜像,这是分阶段构建的前期阶段
FROM openjdk:8-jdk-alpine as builder
# 执行工作目录
WORKDIR application
# 配置参数
ARG JAR_FILE=target/*.jar
# 将编译构建得到的jar文件复制到镜像空间中
COPY ${JAR_FILE} application.jar
# 通过工具spring-boot-jarmode-layertools从application.jar中提取拆分后的构建结果
RUN java -Djarmode=layertools -jar application.jar extract

# 正式构建镜像
FROM openjdk:8-jdk-alpine
WORKDIR application
# 前一阶段从jar中提取除了多个文件,这里分别执行COPY命令复制到镜像空间中,每次COPY都是一个layer
COPY --from=builder application/dependencies/ ./
COPY --from=builder application/spring-boot-loader/ ./
COPY --from=builder application/snapshot-dependencies/ ./
COPY --from=builder application/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]
打包使用 --no-cache

在服务器上

unzip -q app.jar
docker build -t 192.168.0.88/magic/test:1 .
docker login 192.168.0.88  -u admin -p hcloud1234!
docker push 192.168.0.88/magic/test:1

SpringBoot-2.3.0.RELEASE推荐的镜像构建方案和旧版本相比有什么不同

1.pom.xml中的spring-boot-maven-plugin插件增加一个配置项;
2.构建好jar后,旧版本要自己解压jar,新版不需要;
3.新版本的jar中,多了个文件清单layers.idx和镜像文件处理工具spring-boot-jarmode-layertools-2.3.0.RELEASE.jar;
4.旧版的Dockefile内容:因为前面解压好了,所有在Dockerfile里直接复制前面解压的内容,这里就有个风险:前一步解压和当前复制的文件位置要保证一致;
5.新版的Dockerfile内容:使用工具spring-boot-jarmode-layertools-2.3.0.RELEASE.jar,根据的layers.idx内容从jar中提取文件,复制到镜像中;
6.新版的Dockerfile中,由于使用了分阶段构建,因此从jar提取文件的操作不会保存到镜像的layer中;

pom.xml中spring-boot-maven-plugin插件新增的参数,到底做了什么

spring-boot-maven-plugin插件新增的参数,使得编译构建得到jar中多了两个文件,如下图所示:

centos导出docker镜像文件 如何导出docker镜像_java_21

Dockerfile中,java -Djarmode=layertools -jar application.jar extract这个操作啥意思

  1. java -Djarmode=layertools -jar application.jar extract的作用是从jar中提取文件,这些文件是docker镜像的一部分;
  2. 上述操作的参数是extract,另外还有两个参数,官方解释它们的作用如下:

至此,问题已全部澄清,大致流程图,帮助您快速理解整个构建流程:

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_22

(重点)shell脚本获取增量的docker镜像

观察不同

两个不同版本的镜像,发现有4个相同的包,其他的就是不同的,所以去除相同的,打包不同即可完成效果

centos导出docker镜像文件 如何导出docker镜像_centos导出docker镜像文件_23

shell

实现过程

1、定义需要区分的两个版本的所有镜像名字

2、拉取两个版本的所有镜像

3、分别打包两个版本的镜像,并且解压到对应的文件夹下

4、读取里面的文件找出不同的,删除相同的

5、打包删除后的文件

6、增量包很小,导入测试成功!

#!/bin/sh
set -e

# 当前目录
CURRENT_DIR=$(
   cd "$(dirname "$0")"
   pwd
)
nowDate=$(date +'%Y%m%d%H%M')




# 旧版本镜像 中间空格分割
oldImages=("192.168.0.88/magic/test:1")
# 新版本镜像 中间空格分割
newImages=("192.168.0.88/magic/test:2")
# 导出包的名字
outPutName="test2.tar.gz"

# 是否拉取镜像
isPullImages=false

# 拉取镜像
pullImages(){
	if [[ $isPullImages == "true" ]]; then
		echo "拉取旧版本镜像"
		for oldImage in ${oldImages[*]}
		do
			docker pull ${oldImage}
		done
		
		echo "拉取新版本镜像"
		for newImage in ${newImages[*]}
		do
			docker pull ${newImage}
		done
		
		echo "拉取镜像结束"
	fi
}

packageImage(){
	path=$1
	flag=$2

	images=()
	mkdir -p $path && cd $path
	if [[ $flag == "old" ]];then
		images=${oldImages[*]}
	else
		images=${newImages[*]}
	fi
	echo "打包 镜像${images[*]}" 
	docker save -o  $path/images.tar ${images[*]} 
	tar -xf images.tar  -C . && ls -l |grep ^d  > /dev/null && rm -rf images.tar
}

checkFile(){
	oldPath=$1
	newPath=$2
	oldFileNams=$(ls $oldPath)
	newFileNams=$(ls $newPath)
	
	for newImage in $newFileNams
	do
		if [[ "${oldFileNams[@]}"  =~ "${newImage}" ]] && [[ "$newImage" != "repositories" ]]&& [[ "$newImage" != "manifest.json" ]] && [[ "$newImage" !=  *.json ]]
		then
			rm -rf $newPath/$newImage
			echo "相同 $newPath/$newImage"
		else
			echo "不相同 $newImage"
		fi
	done
	cd $newPath && tar -zvcf $CURRENT_DIR/$outPutName *
	rm -rf $newPath
	rm -rf $oldPath
}

main(){
	# 拉取镜像
	pullImages
	
	oldPath=$CURRENT_DIR/oldImages
	newPath=$CURRENT_DIR/newImages
	
	# 打包旧的
	packageImage $oldPath old
	# 打包新的
	packageImage $newPath new
	# 检查不同的,删除相同的,打包新的
	checkFile $oldPath $newPath
	
}

main

注意事项

1、Dockerfile中的层变换位置后,就不会使用缓存,会变成新的构建了

2、当发现有三层怎么操作都没有用缓存,如图

centos导出docker镜像文件 如何导出docker镜像_docker_24


说明该lib一直在变,每打一次jar,jar就会变,所以将META-INF移到上面一层,

将lib变化的jar移到另一个文件夹

Dockerfile如下

# 指定基础镜像,这是分阶段构建的前期阶段
FROM openjdk:8-jdk-alpine as builder
# 执行工作目录
WORKDIR target
# 配置参数
ARG JAR_FILE=target/*.jar
# 将编译构建得到的jar文件复制到镜像空间中
COPY ${JAR_FILE} application.jar
# 将企业jar,多模块的其他依赖jar 每次重新打包的jar移动到另外一个目录
RUN unzip application.jar && mkdir -p BOOT-INF/lib2 && mv BOOT-INF/lib/scs-*.jar BOOT-INF/lib2


FROM openjdk:8-jdk-alpine
VOLUME /tmp
ARG DEPENDENCY=target
COPY --from=builder ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=builder ${DEPENDENCY}/BOOT-INF/lib /app/lib
#每次都不一样的jar
COPY --from=builder ${DEPENDENCY}/BOOT-INF/lib2 /app/lib
COPY --from=builder ${DEPENDENCY}/BOOT-INF/classes /app
COPY java_agent-1.jar /app/java_agent-1.jar

ENTRYPOINT java $JAVA_OPTS -verbose:gc -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/opt/logs/jvm/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/logs/jvm/dump.hprof -Djava.security.egd=file:/dev/./urandom -Denv=dev -Duser.timezone=GMT+08 -cp $AGENT_AFTER_JAR app:app/lib/* com.liuhm.SpringbootdemoApplication

dockerfile-maven支持cache-from

dockerfile-maven 目前的官方版本说是1.4.5以后都支持cacheFrom,实际操作不支持

centos导出docker镜像文件 如何导出docker镜像_docker_25

下载源码进行修改

更新 BuildMojo.java 文件

if (!cacheFromExistLocally.isEmpty()) { 
	buildParameters.add(new DockerClient.BuildParam("cache-from", encodeBuildParam(cacheFromExistLocally))); 
}

修改为

if (!cacheFromExistLocally.isEmpty()) { 
	buildParameters.add(new DockerClient.BuildParam("cachefrom", new Gson().toJson(cacheFromExistLocally).toString())); 
}

注释plugin里面的pom

<!--<plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-invoker-plugin</artifactId>
        <version>1.9</version>
        <dependencies>
          <dependency>
            <groupId>com.spotify</groupId>
            <artifactId>docker-client</artifactId>
            <version>${docker-client.version}</version>
          </dependency>
        </dependencies>
        <configuration>
          <cloneProjectsTo>${project.build.directory}/it</cloneProjectsTo>
          <pomIncludes>
            <pomInclude>*/pom.xml</pomInclude>
          </pomIncludes>
          <postBuildHookScript>verify</postBuildHookScript>
          <localRepositoryPath>${project.build.directory}/local-repo</localRepositoryPath>
          <settingsFile>src/it/settings.xml</settingsFile>
          <streamLogs>true</streamLogs>
          <goals>
            <goal>clean</goal>
            <goal>verify</goal>
          </goals>
        </configuration>
        <executions>
          <execution>
            <id>integration-test</id>
            <goals>
              <goal>install</goal>
              <goal>integration-test</goal>
              <goal>verify</goal>
            </goals>
          </execution>
        </executions>
      </plugin>-->

打包上传私库

测试

说明成功

centos导出docker镜像文件 如何导出docker镜像_jenkins_26