深入浅出Docker中构建镜像的5种方式

在Docker中构建镜总共有以下5种方式:

  1. docker commit 命令构建,最直观的方式
  2. docker save / docker load 命令用于镜像的导入导出
  3. docker export / docker import 命令用于容器的导出以及把容器导入作为本地镜像
  4. 基于Dockerfile文件构建
  5. 基于docker-compose.yml文件构建

本文章会详细介绍以上每一种方式,并有实操案例。

docker save / docker load

docker save 把镜像导出

docker load 把镜像导入本地

使用场景: 如果某个镜像文件很大,自己的网络不太好的话,就可以通过这种本地方式导入。比如同事主机上已经有对应的镜像,则可以让他通过docker save命令导出发给我们,然后我们再通过docker load导入自己的主机。

bash 复制代码
docker save -o centos.tar centos:latest  
ls
centos.tar
docker load -i centos.tar 

docker export / docker import

docker export 把容器导出

docker import 把容器导入作为本地镜像

bash 复制代码
docker export -o centos7.tar 355e99982248
# ls
centos7.tar

docker import centos7.tar centos7:v1

使用场景: 如果我们当前这个主机已经运行某个容器有一段时间了,产生了一些数据,我们想把这个容器迁移到另外一个主机上运行,就可以使用这对命令,先docker export 将容器导出为 tar 文件,然后在目标机器上使用 docker import 导入为镜像。

docker export 和 docker save的区别

我们发现通过docker export和docker save得到的都是一个tar包,它们的区别是什么呢?在理解这2个命令之间的区别之前需要先对容器中的Image(镜像)和container(容器)有一个基本的理解。

Image(镜像): 包含运行应用程序所需的所有文件,比如操作系统文件、应用程序代码和任何所需的支持库。

container(容器):已启动的 Docker 镜像 。容器本质上是一个正在运行的应用程序。容器像普通进程一样消耗内存和 CPU 资源,还可以访问文件系统,并通过网络协议与其他容器通信。

当我们通过docker save命令把一个镜像打包成一个tar包时,任何人只要它的主机上有基本的Linux环境和docker环境,就可以直接把这个tar包通过load导入到自己的本地作为一个镜像,这个镜像和它被打包成tar文件之前的镜像一模一样 ,拥有完整的文件系统,包含镜像所有的元数据和分层信息。好比一个Java应用程序,如果它的Maven依赖非常多,我本地网络环境并不好,可以直接让别人把这个Java应用程序打包成一个jar包发给我,这个jar包就包含所有的文件,我本地只要有Java运行环境,就可以直接运行该jar包。

而通过docker export打包成一个tar包,它的目标对象是容器,也就是已经基于镜像运行的程序,导出的tar包中既包括镜像文件,也包括容器运行时所作的任何更改。 ,但是不包含镜像的元数据和分层信息

如果上面阅读完之后还是有点似懂非懂,接下来我会通过一个具体实操案例来辅助进一步证明。

案例详细证明

如图,在我的test2目录下有一个jar包和一个Dockerfile文件

jar包里就一个Main程序,它的主要逻辑是不断从控制台获取字符串然后写入/logs中:

Main程序代码:

Dockerfile文件内容:

现在我们基于这个Dockerfile文件来构建镜像:注意观察在基于Dockerfile构建镜像过程中生成的元数据,后面我们将证明在使用docker export /docker impot命令时,这些元数据将丢失

现在我们基于这个镜像运行一个容器制造点数据:

正文开始了,接下来开始准备使用 docker export /docker import命令,如图,我们先通过docker export把镜像导出为一个tar包,然后再通过docker import重新导入为一个镜像 命名为app-export

现在我们开始尝试使用app-export镜像运行一个容器,发现报错,说要指定一个命令,之所以会报这个错误,是因为我们之前在Dockerfile文件中编写的CMD指令(java -jar app.jar)等元数据被擦除了

🆗,既然运行容器的默认命令被擦除了,那我们就直接再运行容器命令后面再写一遍,但发现还是报错,java命令找不到,这进一步证明我们的基础镜像openjdk它的元数据也被擦除了(java环境变量)

那我们就直接运行bash命令进入到容器一探究竟,

(1)之前运行容器产生的一些数据还在,也就是docker export命令会把容器运行过程中产生的数据保留

(2)Java没有配置环境配置,基础镜像openjdk的元数据没有保存

通过运行bash的方式进入容器后,我们已经知道了为什么之前2次运行容器会报错,是因为元数据都被擦除了,现在我们需要以最原始的方式去运行该容器,如图所示,成功运行。

我们通过这个实操案例证明使用docker export导出的tar包中既包括镜像文件,也包括容器运行时所作的任何更改。 ,但是不包含镜像的元数据和分层信息

docker commit构建镜像

docker commit命令是创建新镜像最直观的方法,其过程包含三个步骤:

  1. 运行容器。
  2. 修改容器。
  3. 将容器保存为新的镜像。

使用方式:docker commit 容器ID

虽然我们可以通过docker commit命令自己去构建镜像,但是一般不推荐,因为容易出错,对于底层实现我们并不是很非常清楚,建议使用Dockerfile(推荐方式)去构建新的镜像,它的底层实现其实也是docker commit一样,理解docker commit的方式,有助于我们理解Dockerfile的工作方式

基于Dockerfile构建镜像

在上一小节中我们已经知道了基于Dockerfile文件去构建镜像的本质其实还是通过docker commit命令去构建镜像,包含三个基本步骤:

  1. 运行容器。
  2. 修改容器。
  3. 将容器保存为新的镜像。

Dockerfile文件本质上就是docker专门用来提供编写构建命令的文本文件格式,我们只需要按照docker提供的命令语法在Dockerfile文件中编写上面第二步骤中的修改容器命令即可,剩下的第一步和第二步docker会帮我们完成。

接下来会以一个简单的Java程序详细介绍Dockerfile文件中的各种命令格式。

可以看到就一个Main类,下面是Main类的代码,主要逻辑就是不段的从控制台获取字符串然后写入项目下的data.txt文件,然后再读出来。

java 复制代码
public class Main {  
public static void main(String[] args) {  
    System.out.println("separator " + File.separator);  
    System.out.println("separator " + File.pathSeparator);
    System.out.println("Hello world!");  
    System.out.println("请随意输入一些字符串 以bye结尾");  
    String dst = "." + File.separator + "data.txt";  
    try (FileWriter fileWriter = new FileWriter(dst)) {  
        Scanner scanner = new Scanner(System.in);  
        while (true) {  
            String s = scanner.nextLine();  
            System.out.println("开始将 " + s + "写入 " + dst);  
            fileWriter.write(s + "\n");  
            System.out.println("写入成功");  
            if ("bye".equals(s)) {  
                fileWriter.close();  
                System.out.println("结束 文件保存成功");  
                break;  
            }  
        }  
    } catch (IOException e) {  
        e.printStackTrace();  
    }  
    String d = System.getProperty("user.dir") + File.separator + "data.txt";  
    System.out.println("开始读取 " + d + " 内容");  
    try (FileReader fileReader = new FileReader(d)) {  
        char[] buf = new char[1024];  
        do {  
            int read = fileReader.read(buf);  
            if (read == -1)  
                break;  
            System.out.println(new String(buf, 0, read));  
        } while (true);  
    } catch (IOException e) {  
        e.printStackTrace();  
    }  
    }  
}

小试牛刀

  1. 把java 程序打包成jar包放在某目录下,比如我当前是 /test 目录
  2. 进入该test目录,创建Dockerfile文件
  3. 文件内容如下:
bash 复制代码
FROM openjdk:8  
ADD app-1.0-SNAPSHOT.jar /app/app.jar  
CMD ["java", "-jar", "/app/app.jar"]  

Dockerfile文件内容解释:

(1)从基础镜像openjdk:8开始构建

(2)把build context (也就是当前/test目录)下的文件 app-1.0-SNAPSHOT.jar添加到 容器中 /app/app.jar(如果目录不存在则会自动创建)

(3)在容器启动的时候 执行 java -jar /app/app.jar

运行 docker build -t app .

命令解释:

  • docker build :构建镜像
  • -t app :指定镜像名称为app
  • . : 指定当前目录为 build context

可以通过-f 参数指定Dockerfile的位置,默认是build context/Dockerfile 由于我们指定build context为当前目录,所以默认就使用当前目录下的Dockerfile文件

通过观察输出信息,可以看到整个构建镜像的过程基本是按照以下三个步骤来完成的。

  1. 运行容器。
  2. 修改容器。
  3. 将容器保存为新的镜像。

启动容器:交互终端方式

我们发现当我们输入字符串bye之后,容器运行了一会就直接退出了 ,这是怎么回事? 前面在分析docker export和docker save命令的区别时已经讲过了容器其实就是一个运行的应用程序,当程序运行结束,容器当然也就结束了,而在本案例中,当输入bye字符串之后,Main程序也随之运行结束,所以容器也就退出了。

容器文件系统结构验证

为了进一步看到Dockerfile中的文件系统结构以及验证在启动容器时是否可以覆盖掉Dockerfile中指定的CMD命令, 我们通过如下方式再启动一个容器,在docker run命令后面指定运行命令覆盖掉Dockerfile中CMD指定的命令

docker run -it --name app-v2 app bash

通过交互式方式并运行bash命令直接进入到容器内部

通过观察有以下发现:

  1. docker里面的文件结构和一台虚拟机一样,有bin、etc、lib等文件目录
  2. 在根目录下有app这个文件夹,app文件夹下面有app.jar这个文件 这个正好和我们Dockerfile文件里的 ADD app-1.0-SNAPSHOT.jar /app/app.jar 对应

继续验证在Dockerfile文件中指定的基础镜像openjdk,我们一般习惯在Linux中安装软件时安装在usr/local目录下,我们查看该目录是否有openjdk,一查看果然有,

查看openjdk这个文件夹,可以发现和我们直接从网上下载的jdk目录一致 而且我们发现它已经帮我们配置好了环境变量 ,这也是为什么我们可以直接使用 java -jar命令来启动/app/app.jar

ADD/COPY/RUN

COPY命令:用于把当前build context目录下的某个文件复制到容器中 COPY src dest

ADD命令:也具备COPY的功能,同时还有自动解压的功能以及从指定的url中下载文件,不过一般情况下如果只是复制文件的话,建议直接用COPY命令 ADD src dest

RUN:执行命令行命令的,就像直接在命令行中执行命令一样 RUN 命令

实践演示: 如图在宿主机hello-world目录下

Dockerfile文件内容:

bash 复制代码
FROM nginx  
COPY hello-world.txt /hello-world/hello-world.txt  
ADD hello /hello-world  
RUN echo '666666' > /hello-world/hello-world.txt  
CMD bash  

构建镜像:

创建容器:

可以看到进入容器之后,相关的内容也是和我们Dockerfile中指定的是一样的

CMD和ENTRYPOINT

CMD命令:是用来指定容器启动时的运行命令,虽然说可以把docker看作一个轻量级虚拟机,但是容器其实本身是一个进程 ,既然是进程,那么在启动容器的时候,需要指定所运行的程序及参数。 它和RUN一样,也是2种格式 shell 格式:CMD <命令> exec 格式:CMD ["可执行文件", "参数1", "参数2"...] 还有第三种格式就是: 参数列表格式:CMD ["参数1", "参数2"...]。

ENTRYPOINT 的目的和 CMD 一样,都是在指定容器启动程序及参数。ENTRYPOINT 在运行时也可以替代,不过比 CMD 要略显繁琐,需要通过 docker run 的参数 --entrypoint 来指定。 当指定了 ENTRYPOINT 后,CMD 的含义就发生了改变,不再是直接的运行其命令,而是将 CMD 的内容作为参数传给 ENTRYPOINT 指令,换句话说实际执行时,将变为: ""

那使用ENTRYPOINT有什么好处呢?那就是可以动态的替换启动容器时执行命令的参数,我们知道可以在通过docker run启动容器时指定运行命令,它会替换 CMD中的内容,而使用了ENTRYPOINT后,容器启动后最终执行的命令为 ENTRYPOINT CMD,这样就达到了在启动容器时动态替换命令的参数

实践:如图 在宿主机test目录下

Dockerfile中的内容为:

bash 复制代码
FROM openjdk:8  
ADD app-1.0-SNAPSHOT.jar /app/app.jar  
COPY app2-1.0-SNAPSHOT.jar /app/app2.jar  
ENTRYPOINT ["java", "-jar"]  
CMD ["/app/app.jar"]  

在容器启动时会执行 ENTRYPOINT CMD 也就是 java -jar /app/app.jar

构建镜像

(1)启动一个容器 不指定任何命令参数 可以看到默认是启动了app.jar

(2)启动容器 指定参数为 /app/app2.jar 可以看到启动了 app2.jar

ENV ARG

ENV和ARG都是用来设置环境变量,ENV设置环境变量的格式有2种:

(1)ENV < key > < value >

(2)ENV < key1 >=< value1 > < key2 >=< value2 >...

定义环境变量之后可以在如下Dockerfile指令中使用

ADD、COPY、ENV、EXPOSE、FROM、LABEL、USER、WORKDIR、VOLUME、STOPSIGNAL、ONBUILD、RUN。

实践:

Dockerfile文件内容:

bash 复制代码
FROM openjdk:8  
ENV APP_HOME=/app  
ADD app-1.0-SNAPSHOT.jar $APP_HOME/app.jar  
COPY app2-1.0-SNAPSHOT.jar $APP_HOME/app2.jar  
ENTRYPOINT ["java", "-jar"]  
CMD ["/app/app.jar"]

构建镜像: 可以看到使用到环境变量的地方都成功替换了值

需要注意的是 环境变量的值我们可以在docker run时通过-e 来进行替代 比如下面启动一个mysql 容器 就通过-e参数来指定root用户密码

$ docker run --name some-mysql -e MYSQL_ROOT_PASSWORD=my-secret-pw -d mysql:tag

ARG指的是构建参数和 ENV 的效果一样,都是设置环境变量。所不同的是,ARG 所设置的构建环境的环境变量,在将来容器运行时是不会存在这些环境变量的。但是不要因此就使用 ARG 保存密码之类的信息,因为 docker history 还是可以看到所有值的。 Dockerfile 中的 ARG 指令是定义参数名称,以及定义其默认值。该默认值可以在构建命令 docker build 中用 --build-arg <参数名>=< 值> 来覆盖。

WORKDIR

WORKDIR 指令可以来指定工作目录(或者称为当前目录),以后各层的当前目录就被改为指定的目录 ,如该目录不存在,WORKDIR 会帮你建立目录 在通过WORKDIR指令来指定工作目录之后,后面所有的指令的执行目录都是在WORKDIR下执行的

实践:

Dockerfile文件内容

bash 复制代码
FROM openjdk:8  
WORKDIR /app  
ADD app-1.0-SNAPSHOT.jar app.jar  
CMD ["java", "-jar", "app.jar"]  

解释:

  • WORKDIR /app 指定了工作目录为app
  • ADD app-1.0-SNAPSHOT.jar app.jar 相当于把build context下的app-1.0-SNAPSHOT.jar 添加到了 /app/app.jar
  • CMD ["java", "-jar", "app.jar"] 启动命令执行 其实该命令也是在/app下执行的

可以看到启动容器之后直接进入到了工作目录 /app

EXPOSE 声明服务端口

格式为 EXPOSE <端口1> [<端口2>...]。 EXPOSE 指令是声明容器运行时提供服务的端口,这只是一个声明,在容器运行时并不会因为这个声明应用就会开启这个端口的服务。

在 Dockerfile 中写入这样的声明有两个好处,

  • 一个是帮助镜像使用者理解这个镜像服务的守护端口,以方便配置映射;
  • 另一个用处则是在运行时使用随机端口映射时,也就是 docker run -P 时,会自动随机映射 EXPOSE 的端口。

要将 EXPOSE 和在运行时使用 -p <宿主端口>:<容器端口> 区分开来。-p,是映射宿主端口和容器端口,换句话说,就是将容器的对应端口服务公开给外界访问,而 EXPOSE 仅仅是声明容器打算使用什么端口而已,并不会自动在宿主进行端口映射。

Compose模板文件

通过Dockerfile 模板文件,可以很方便的定义一个单独的应用容器 ,但是在我们的项目中一般会有多个应用,比如现在主流的微服务,不同应用之间如果我们要让多个容器服务相互配合放在一个隔离的环境中呢? 而且每个应用会有依赖关系,启动顺序也不一样,如果自己一个个的去构建然后启动,会很麻烦。

通过Compose可以对多个应用进行编排 Compose定义和运行多个 Docker 容器的应用,允许用户通过一个单独的 docker-compose.yml 模板文件(YAML 格式)来定义一组相关联的应用容器为一个项目(project)。

Compose 中有两个重要的概念:

  • 服务 (service):一个应用的容器,实际上可以包括若干运行相同镜像的容器实例。
  • 项目 (project):由一组关联的应用容器组成的一个完整业务单元,在 docker-compose.yml 文件中定义。

默认的模板文件名称为 docker-compose.yml,格式为 YAML 格式。 对于编写docker-compose.yml文件其实很简单,它就相当于一个脚本文件,可以同时构建多个镜像,创建多个容器,

  1. 我们只需要定义好有哪些服务?
  2. 这些服务的镜像怎么构建?是通过Dockerfile文件还是直接拉取镜像?
  3. 怎么启动容器?

其实就是相当于把之前构建镜像的命令和启动容器的命令在这组合了

下面通过一个简单案例来实践。

如图,在test1目录下 有docker-compose.yml文件,如果我们在该目录下去执行命令 docker-compose up 则docker-compose会把 test1 默认作为我们的项目名称

其中web-service是一个简单的Java web服务,下面是它的一个基本结构

稍微说一下,可能小伙伴觉得我这个配置文件名有点奇怪,怎么不是application.yml,其实我这里没有直接使用SpringBoot来构建web,而是使用了solon-web这个框架

看这个框架介绍好像有点吊的样子,单纯好奇,反正是随便测试下,所以就拿来用一下。

下面是我的TestController,有2个测试接口,一个是/hello,验证项目是否启动成功

另外一个是/mysql接口,用于验证是否可以正确访问mysql-service,使用了最原始的方式来完成mysql的连接,

下面是我docker-compose.yml文件的编写

yaml 复制代码
version: "3"

services:
   web-app:
      build: ./web-service
      depends_on:
         - mysql-service
      ports:
         - "8080:8080"
      container_name: app
      volumes:
         - web-log:/app/logs
      networks:
         - web-net

   mysql-service:
      image: mysql
      container_name: mysql-service
      networks:
         - web-net
      ports:
         - "3306:3306"
      environment:
         MYSQL_ROOT_PASSWORD: root
****

volumes:
   web-log:
networks:
   web-net:
  1. version:表示当前docker-compose文件的版本,可以用来检查语法

  2. 定义了2个服务,分别是web-app 和mysql-service

  3. web-app:

    • build: 表示镜像构建方式是通过Dockerfile Dockerfile文件所在位置是当前目录下的web-service目录
    • depends_on:表示当前服务依赖mysql-service服务,所以会先启动mysql-service,再启动当前服务 后面的所有命令基本就是表示如何启动一个容器
    • ports:端口映射 相当于docker run命令中的-p参数
    • container_name:容器名称 相当于docker run命令中的--name参数
    • volumes:文件挂载 相当于docker run 命令中的-v参数
    • networks:加入的网络 相当于docker run 命令中的--network参数
  4. mysql-service:

    • image: 拉取mysql镜像
    • environment:容器启动时指定环境变量值 相当于docker run中的-e参数

volumes指示数据卷所挂载路径设置。可以设置为宿主机路径(HOST:CONTAINER)或者数据卷名称(VOLUME:CONTAINER),并且可以设置访问模式 (HOST:CONTAINER:ro)。

需要注意的是,对于volume和network需要另外在文件中声明 如果volume和network不存在的话 docker-compose会自动创建,并且默认命名方式是project_name 其中name就是我们在文件中声明的

启动:docker-compose up

成功启动mysql-service和app服务

自动帮我们创建数据卷 test1_web-log 网络 test1_web-net

我们先把demo数据库创建好,因为web-service中连接的就是这个demo库

访问localhost:8080/mysql 并在宿主机上查看日志没有问题

总结:关于docker-compose.yml命令我这里并没有详细的介绍,因为很简单没必要,虽然好像它的命令很多,但是它无外乎就是围绕2个方面进行展开:

  1. 服务的镜像如何构建?
  2. 容器如何启动?

不需要刻意的记忆,不知道的只需要按需直接去官网查即可 ,比如对于web-app服务,我希望容器的启动时映射端口,这时去回想如果你现在直接通过docker run 命令去启动该服务该如何指定映射端口,是不是通过-p参数,所以对应的直接去官网查看docker-compose相关的命令,去看和-p相关的即可。 docs.docker.com/compose/com...

相关推荐
Hui Baby23 分钟前
springAi+MCP三种
java
hsjcjh26 分钟前
【MySQL】C# 连接MySQL
java
敖正炀26 分钟前
LinkedBlockingDeque详解
java
wangyadong31727 分钟前
datagrip 链接mysql 报错
java
untE EADO34 分钟前
Tomcat的server.xml配置详解
xml·java·tomcat
乌托邦的逃亡者40 分钟前
Dockerfile的配置和使用
linux·运维·docker·容器
ictI CABL42 分钟前
Tomcat 乱码问题彻底解决
java·tomcat
敖正炀1 小时前
DelayQueue 详解
java
七七powerful1 小时前
loki监控docker容器&系统&nginx日志的告警规则
nginx·docker·容器
uzong1 小时前
最新:阿里正式发布首款AI开发工具Meoo(秒悟),0门槛、一键部署上线
人工智能·后端