Docker的深入浅出(五万字长文)

为什么需要Docker

​ 在传统的软件和开发部署中,开发人员的开发环境和软件的部署环境可能是不同的,例如开发人员的操作系统可能是Windows,而部署环境的操作系统是Linux,开发人员使用了Python3.9,而在部署环境中Python的版本为3.8。这就会导致开发的软件在开发人员那里可以正常运行,但是迁移到部署环境中则会报错。想要避免环境不同导致的问题,就必须保证所有的环境是相同的,这导致了配置环境占用了较多的时间和精力。此外,一个服务器中可能会运行多个不同的软件,这可能会需要很多依赖,导致依赖冲突,例如项目A需要某个库的1.0版本,而项目B则需要2.0版本,两个版本可能没有办法共存。

​ 为了解决这些问题,Docker被提出作为一种解决方案。Docker本质上是一种容器化技术,它通过在操作系统级别进行隔离,确保应用及其依赖项能够在任何环境中一致地运行。它创建一个隔离的容器,只包含应用程序运行所需的最小组件,可以认为这个容器是一个很迷你的虚拟机,包含了一个迷你的虚拟系统和程序所需要的库、配置文件等。但这与传统的虚拟机技术有所不同,虚拟机技术需要虚拟化整个操作系统和硬件环境。其中操作系统包括操作系统内核和用户空间,而用户空间相对于内核来说较为庞大,包括了命令行工具,各种应用程序、系统库,守护进程等等。但虚拟机虚拟化整个操作系统的好处在于,每个虚拟化的操作系统都是独立的,不依赖于主机,所以可以是不同的操作系统,即可以是Windows,Mac等。

​ 而Docker共享操作系统的内核,并不虚拟化操作系统内核,并且只包含应用程序运行所需的最小组件,即如果想要在Docker中运行一个mysql服务,那么docker创建的容器通常只会包含mysql相关或mysql依赖的东西,而像一些无关的库或者软件(例如UI界面、游戏程序)则不会包含进去。这就导致Docker包含的内容相对于虚拟机来说很少,能够很快的启动,并且占用资源更少。可以认为Docker将应用程序所需要的一切打包到了一个类似于虚拟机的容器中。但是缺点在于Docker容器共享宿主机的内核,因此容器化应用必须与宿主机的内核兼容,这意味着Docker容器通常只能运行基于Linux内核的应用程序。不过,这似乎会导致另外第一个问题,即开发环境的内核版本和部署环境的内核版本不一定相同。实际上,大部分应用程序问题是由用户空间库(如 glibc、libstdc++ 等)引起的,而不是由内核版本引起的。所以尽管开发环境和部署环境中内核版本不一致,但是Docker可以保证容器内的内容是一致的,即不会导致用户控件库的内容不同。所以,一般情况下,使用Docker在两个主机上部署的应用被认为是环境一致的应用。

在这里插入图片描述

​ 上述只是Docker的一些基本概念和简介,具体Docker是如何实现上述功能的呢。简单来说,Docker是通过镜像和容器来实现的。其中镜像可以看成一个文件夹,这个文件夹中包含了运行程序所需要的组件(通常是最小化的操作系统和相关的应用程序)。例如,如果想要创建一个 Python 环境以运行自己编写的python程序,那么这个镜像通常会包含一个精简的操作系统、文件管理系统、命令行解释器,以及 Python 解释器和相关库等组件。即上述说的将程序所需要的一切打包。

​ 镜像本身就像一个包含了很多文件夹的文件夹,是静态的,它定义了容器的基础环境,但并不能直接执行任何操作。类似于在你的电脑上下载了 Python 安装包,镜像中包含了运行 Python 程序所需的所有文件和配置,但要真正使用 Python,你需要运行这个镜像实例化为一个容器。就像是我们下载好了python解释器后,还需要使用命令行或者其他方式运行python程序一样,将镜像运行起来就可以使用我们的python环境了,运行起来的镜像实例叫作容器。容器是可交互的,就像是在命令行中启动停止程序一样,可以通过和容器的交互来对文件和程序进行操作。进入容器后,可以通过镜像提供的文件管理系统对镜像进行修改,例如添加新的文件、修改配置等。通过容器内的命令行解释器可以实现运行、暂停容器内的程序的行为。

​ 在上述说明中,镜像包含了较多的东西,例如文件管理系统,命令行解释器。但是实际上,在Docker中创建一个镜像是非常方便的,通常只需要编写几行至十几行的配置文件即可,并且镜像是可以直接在Docker中使用的,不需要像传统的方法那样对于不同的操作系统,依赖的下载方式和配置方式是不同的。因此,想要实现不同主机中有着相同环境,只需要拷贝相同的镜像即可,不需要再进行额外的配置。此外,Docker 容器之间是相互独立的,彼此隔离,因此可以在不同的容器中运行相同程序的不同版本,这解决了在同一环境中不同版本程序无法共存的问题。这种隔离性使得 Docker 成为管理复杂应用程序和多版本依赖的理想工具。

Docker的架构

​ Docker使用客户-服务端架构,Docker的客户端可以向Docker守护进程发送命令,而守护进程可以对容器进行构建、运行、分发等操作,也包含了对镜像、卷等操作。几乎所有常用的与Docker交互的操作,都是通过客户端向守护进程发送命令实现的,Docker的客户端和守护进程可以运行在同一台主机上,也可以通过远程交互。可以通过Unix套接字进行交互,也可以通过网络远程通信。
在这里插入图片描述

概念

​ 在正式了解Docker的基本原理和使用方法之前,对一些常用的术语和概念进行介绍对深入理解Docker会更有帮助。

Docker Desktop

​ Docker Desktop 是一个应用程序,专门为 Windows 和 macOS 用户设计,用于提供一个完整的 Docker 开发环境。它包含了 Docker Engine 和 Docker Compose。Docker Desktop 提供了一个统一的界面,以轻松地创建、管理和运行容器。

Docker Engine

​ Docker Engine 是 Docker 的核心部分。包含了docker的守护进程(Docker Daemon;dockerd)用于处理用户发出的命令,创建、管理和运行 Docker 容器以及其它命令。如果是在 Linux 上使用 Docker,可以只安装 Docker Engine,而不是 Docker Desktop。对于 Windows 和 macOS 用户,Docker Engine 是 Docker Desktop 的一部分,自动安装和配置。

​ 在Windows上,不能单独地安装Docker Engine而不安装Docker Desktop,因为Docker Engine是Linux本地的工具,无法直接在Windows上运行。Docker Engine 依赖于 Linux 内核来运行容器。在 Windows 上,没有本地的 Linux 内核,因此需要一个兼容层来支持 Docker Engine 的运行。Docker Desktop 提供了这种兼容层。它通过在 Windows 上使用一个轻量级的虚拟机(通常是基于 Hyper-V 或 WSL 2)来运行 Docker Engine,从而支持 Docker 容器。在 Windows 上,Docker Desktop 是运行 Docker 的唯一官方方式。它包含了 Docker Engine 和 Docker Compose,并通过虚拟化技术(Hyper-V 或 WSL 2)提供一个兼容的 Linux 环境来运行容器。

Docker Compose

​ Docker Compose 是一个工具,它允许你定义和运行多容器的 Docker 应用程序。你可以通过一个 docker-compose.yml 文件来配置多个容器的服务,并使用简单的命令来管理它们。因为Docker中容器的理念是隔离,所以一般一个容器内只会运行一个应用程序,而一个完整的服务通常由多个应用程序组成(例如一个网站服务通常包含了对网页请求处理的程序,数据库服务等)。当你有一个需要多个容器协同工作的应用(比如一个包含数据库、应用服务器和缓存服务器的应用)时,你会使用 Docker Compose 来定义和管理这些容器。Docker Compose 简化了管理多容器应用的过程,你可以通过一条命令启动、停止和管理所有容器。

Docker Images

​ 镜像本质上是一个只读的文件和文件夹的组合,是一个只读的Docker容器模板,包含了启动容器所需要的所有文件系统结构和内容,用于创建容器。这里的所需要的所有文件系统结构和内容正是我们上面提到的应用程序运行所需的最小组件,当然也可以增加额外的一些东西。具体来说,每个Docker镜像都是在其它镜像上构建。在上述我们提到构建一个Python程序需要一个精简的操作系统,其就会在一个操作系统镜像上进行构建,例如ubuntu,不过是一个精简的ubuntu系统,只保留了基础的命令,例如lspwd等。而我们构建的镜像又可以作为其它镜像的基础,即对修改后的镜像进行保存可以得到一个新的镜像。例如我们有一个镜像在ubuntu镜像上安装了python环境,我们可以在这个镜像上添加一些python文件,构建成一个有着具体应用的一个镜像。我们提到过镜像就像一个文件夹,在镜像基础上进行开发,就相当于在一个现有的文件夹上进行开发

Docker Containers

​ 容器是镜像运行的实例,本质上是一个进程,在容器里运行着的就是我们希望运行的程序,其由对应的镜像创建,该镜像包含了运行应用程序所需的一切,包括代码、运行时、库和依赖项。

​ 容器和镜像之间的关系与对象和类之间的关系比较相似,也和程序和进程之间的关系比较相似。在面向对象的语言中,类通常是一个模板,定义了对象能够有的属性和方法,而对象是类的一个实例,创建之后可以通过调用自身不同的方法,或者被其他方法调用改变自身的属性。我们编写的一些程序,例如python代码是文本形式的,以一个静态的文件形式存在。而当我们运行该程序的时候,它就会变成一个进程在内存中,这个进程能够做的事情是由我们编写的程序决定的。

​ 所以总的来说,镜像是静态的,定义了一些属性和能够进行的操作,包含了运行应用程序所需的一切,包括代码、运行时、库和依赖项。镜像是只读的,作为容器的模板。而容器则是镜像的一个运行起来的实例,是动态的,可以在镜像上添加一个可写层以进行修改,并通过不断的运行改变自身的状态并影响其它进程。当容器被保存为成一个镜像时,可写层将会变为只读层,因为镜像是只读的。

Containerd

​ Containerd组件自Docker1.11版本正式从dockerd中剥离出来,完全遵循了OCI标准。它不仅负责容器生命周期的管理,还对镜像进行管理,例如运行前从镜像从库拉取镜像到本地。接收dockerd的请求,通过适当的参数调用runc启动容器等等。containerd包含一个后台常驻进程,接收到请求后负责执行相关的动作并将执行结果返回给dockerd,实际上可以不使用dockerd直接使用containerd管理容器。

Runc

​ Runc是一个标准的OCI容器运行时的实现,它是一个命令行工具,可以用来直接创建和运行容器。

Docker 主要是基于NamespaceCgroups以及UnionFS 实现的,以下是对它们的简介:

Namespace

​ Namespace(命名空间)是Linux内核中的一个特性,可以将系统资源(包括进程ID、网络接口、挂载点、主机名等)隔离到不同的命名空间中取,使得每个命名空间中的进程只能看到并访问属于该命名空间中的资源,而无法干涉其它命名空间中的资源。这种隔离机制为Docker的容器提供了一种独立的、虚拟化的操作环境,让容器感觉到自己是在主机上唯一一个系统上运行的。Linux内核实现了8种不同类型的namespace:
在这里插入图片描述

以PID namespace为例,其在Linux内核中对应着一个数据结构struct pid_namespace,指向进程对应的命名空间。当一个进程视图访问某个资源的时候(PID namespace对应的是进程资源,即当某个进程需要访问进程资源时),内核会根据pid_namespace来决定这个资源的访问权限和可见性。当进程查询另一个进程的 PID 时,内核会检查查询进程和目标进程是否处于相同的 PID 命名空间。如果它们不在同一个 PID 命名空间中,则该 PID 对查询进程不可见。其中pid_namespace有一个变量为parent即父命名空间,并且父命名空间的进程是可以查看到子命名空间的资源的。

​ 总的来说,namespace是给每个进程都定义不同的命名空间,当该进程需要访问某个资源时,就看一下当前的命名空间,并将其相同命名空间和子命名空间的资源显示给它。

​ 下面通过一个例子来验证上述说明:

# 查看当前进程的PID为890221
huang@racknerd-47a0b4f:~$ echo $$
890410
# 查看该进程下各个命名空间的ID,这里主要关注pid的命名空间,为4026531836
huang@racknerd-47a0b4f:~$ sudo ls -l /proc/890221/ns
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 mnt -> 'mnt:[4026531841]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 net -> 'net:[4026531840]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 pid -> 'pid:[4026531836]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 pid_for_children -> 'pid:[4026531836]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 time -> 'time:[4026531834]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 user -> 'user:[4026531837]'
lrwxrwxrwx 1 huang huang 0 Aug 29 07:43 uts -> 'uts:[4026531838]'

# 创建一个新的Pid命名空间,与原始的Pid空间没有任何关系
huang@racknerd-47a0b4f:~$ sudo unshare --fork --pid --mount-proc bash
# 创建一个新的sleep进程
root@racknerd-47a0b4f:/home/huang# sleep 1000 &
[1] 8
# 查看当前进程的Pid
root@racknerd-47a0b4f:/home/huang# echo $$
1
# 查看当前进程对应的命名空间,可以看到pid和之前的不同,其它的相同,这是因为只为Pid创建了新的命名空间
root@racknerd-47a0b4f:/home/huang# ls -l /proc/1/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 29 08:14 cgroup -> 'cgroup:[4026531835]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 ipc -> 'ipc:[4026531839]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 mnt -> 'mnt:[4026532272]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 net -> 'net:[4026531840]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 pid -> 'pid:[4026532273]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 pid_for_children -> 'pid:[4026532273]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 time -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 time_for_children -> 'time:[4026531834]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 user -> 'user:[4026531837]'
lrwxrwxrwx 1 root root 0 Aug 29 08:14 uts -> 'uts:[4026531838]'
# 查看进程,看到只有3个进程,其中包括了sleep的进程,该空间看不到其它进程
root@racknerd-47a0b4f:/home/huang# ps a
    PID TTY      STAT   TIME COMMAND
      1 pts/7    S      0:00 bash
      8 pts/7    S      0:00 sleep 1000
     10 pts/7    R+     0:00 ps a
     
# 在原本的bash中可以看到sleep进程,这是因为在宿主机上新的命名空间是由其创建的,即新的命名空间对于宿主机来说只是一个进程,因此宿主机可以看到子空间的资源。作为系统的控制中心,宿主机拥有更高的特权和更全面的视图。虽然子命名空间中的进程被隔离,宿主机的内核能够追踪和管理系统中的所有进程
huang@racknerd-47a0b4f:~$ ps aux | grep sleep
root      890642  0.0  0.0   5772  1040 pts/7    S    08:13   0:00 sleep 1000
huang     890921  0.0  0.1   6480  2244 pts/8    S+   08:17   0:00 grep --color=auto sleep

Cgroups

​ Cgroups(全称:control groups)是Linux内核的一个功能,可以实现限制进程或者进程组的资源(如CPU、内存、磁盘IO等)。Cgroups有两个版本,不同的版本导致了其管理进程的方式不同。使用stat -fc %T /sys/fs/cgroup/可以查看当前主机使用的是Cgroups的哪个版本,如果输出cgroup2fs则表示使用的是cgroups v2,如果输出的是cgroup则表示使用的是cgroups v1。

​ 在cgroups v2下,运行ls /sys/fs/cgroup/可以得到以下输出:

# 例如cgroup.stat包含了cgroup的一些统计信息
$ ls /sys/fs/cgroup/
cgroup.controllers      cpuset.cpus.effective  io.pressure                    sys-fs-fuse-connections.mount
cgroup.max.depth        cpuset.mems.effective  io.prio.class                  sys-kernel-config.mount
cgroup.max.descendants  cpu.stat               io.stat                        sys-kernel-debug.mount
cgroup.procs            dev-hugepages.mount    memory.numa_stat               sys-kernel-tracing.mount
cgroup.stat             dev-mqueue.mount       memory.pressure                system.slice
cgroup.subtree_control  init.scope             memory.stat                    user.slice
cgroup.threads          io.cost.model          misc.capacity
cpu.pressure            io.cost.qos            proc-sys-fs-binfmt_misc.mount

​ 在/sys/fs/cgroup/创建一个新的目录表示创建新的cgroup,在该目录下创建目录后,系统会自动生成一些控制文件:

huang@racknerd-47a0b4f:/sys/fs/cgroup$ sudo mkdir my_cgroup
huang@racknerd-47a0b4f:/sys/fs/cgroup$ ls my_cgroup/
cgroup.controllers      cpu.max.burst          hugetlb.1GB.events        io.prio.class        memory.stat
cgroup.events           cpu.pressure           hugetlb.1GB.events.local  io.stat              memory.swap.current
cgroup.freeze           cpuset.cpus            hugetlb.1GB.max           io.weight            memory.swap.events
cgroup.kill             cpuset.cpus.effective  hugetlb.1GB.rsvd.current  memory.current       memory.swap.high
cgroup.max.depth        cpuset.cpus.partition  hugetlb.1GB.rsvd.max      memory.events        memory.swap.max
cgroup.max.descendants  cpuset.mems            hugetlb.2MB.current       memory.events.local  misc.current
cgroup.procs            cpuset.mems.effective  hugetlb.2MB.events        memory.high          misc.max
cgroup.stat             cpu.stat               hugetlb.2MB.events.local  memory.low           pids.current
cgroup.subtree_control  cpu.uclamp.max         hugetlb.2MB.max           memory.max           pids.events
cgroup.threads          cpu.uclamp.min         hugetlb.2MB.rsvd.current  memory.min           pids.max
cgroup.type             cpu.weight             hugetlb.2MB.rsvd.max      memory.numa_stat     rdma.current
cpu.idle                cpu.weight.nice        io.max                    memory.oom.group     rdma.max
cpu.max                 hugetlb.1GB.current    io.pressure               memory.pressure

​ 接着验证cgrpup是能够限制进程资源的:

# 首先创建一个bash进程,并挂载到后台
huang@racknerd-47a0b4f:/sys/fs/cgroup$ bash &
[1] 893678
# 得到该进程的pid
huang@racknerd-47a0b4f:/sys/fs/cgroup$ echo $!
893678
# 然后将这个进程加入到新创建的 cgroup 中,即my_cgroup
echo 893678 | sudo tee /sys/fs/cgroup/my_cgroup/cgroup.procs
# 限制该 cgroup 中进程的 CPU 使用。例如,设置一个较低的 CPU 使用比例。
# 20000 表示允许使用的 CPU 时间(单位是微秒)。
# 100000 表示周期(单位是微秒),这意味着你为该 cgroup 分配了 20% 的 CPU 时间。
echo 20000 100000 | sudo tee /sys/fs/cgroup/my_cgroup/cpu.max
# 验证资源限制,将后台的进程拉回前台
huang@racknerd-47a0b4f:/sys/fs/cgroup$ fg
bash
# 查看当前进程pid,确实为893678
huang@racknerd-47a0b4f:/sys/fs/cgroup$ echo $$
893678
# 运行 CPU 密集型任务
huang@racknerd-47a0b4f:/sys/fs/cgroup$ while true; do :; done
# 开启另一个终端,查看该进程占用的资源,确实只占了20%
huang@racknerd-47a0b4f:~$ top -p 893678
top - 12:53:22 up 50 days, 50 min,  6 users,  load average: 0.09, 0.02, 0.01
Tasks:   1 total,   1 running,   0 sleeping,   0 stopped,   0 zombie
%Cpu(s): 22.6 us,  1.0 sy,  0.0 ni, 75.4 id,  0.0 wa,  0.0 hi,  0.7 si,  0.3 st
MiB Mem :   1963.9 total,    144.2 free,    505.7 used,   1314.0 buff/cache
MiB Swap:   1024.0 total,    256.4 free,    767.5 used.   1263.8 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
 893678 huang     20   0    8660   5460   3796 R  20.3   0.3   0:11.25 bash

UnionFS

​ 联合文件系统(Union File System,UnionFS)是一种分层的轻量级文件系统,它可以把多个目录内容联合挂载到同一目录下,形成一个单一的文件系统。前面我们提到过,每个镜像都是以其它镜像作为基础,即构建的一个镜像前可能有多个镜像层,尤其是最底层的镜像层可能被使用多次。联合文件系统就是用来高效管理和存储镜像层的,其通过层的概念实现镜像的共享以节省存储空间。如果不使用联合文件系统的话,那么每有一个使用某一镜像层的容器,就需要将镜像层复制一次,导致存储空间的浪费。而使用联合文件系统,在不复制额外的镜像层的情况下,其它容器在此基础上添加额外的层实现对原始镜像层的修改。即使用另外的目录和其它镜像层的文件目录进行合并实现对原始文件目录的修改。

​ 由于每一层镜像都可能被复用,所有每一层镜像的文件结构都需要被保存,所谓的联合挂载是想实现将多个镜像层合并成一个最终的目录结构:

# 有两个目录A和B,其中A可以看做是一个基础镜像,B是另外的镜像层,B和A可能有相同的文件、也有不同的文件
# 需要将两个镜像层合并来得到最终的目录结构
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x
# 希望将这两个目录下的文件合并到一个目录下,并且能够解决同名文件的冲突,即不同目录下有相同名称的文件应有策略确定应保留的文件内容
# 将A和B目录联合挂载到C目录下,得到了一个统一的视图
./C
├── a
├── b
└── x

​ 联合文件系统只是一个概念,实现这个概念的方式有多种方法,也叫多种文件驱动或存储驱动(storage-driver)。在Docker中最常用的文件驱动有:AUFS,Devicemapper以及OverlayFS。其中OverlayFS是当前Docker默认的文件驱动,因此以它为例进行介绍。

​ 早期的overlayfs并不稳定,不推荐在生产环境中使用,后续的overlayfs2则更加稳定,推荐在生产环境中使用,其对应的Docker系统必须高于17.06.02,内核版本必须高于4.0。此外还有一些其他的限制条件,如果发现不能使用overlayfs2,那么则应该使用AUFS或Devicemapper。

​ overlay2将目录称之为层(layer),把所有层同一展现到同一的目录下的过程称为联合挂载(union mount),这意味着,在最终的文件系统中,用户看到的是所有层的内容,而不是每个层独立的内容。因为不同的镜像层可能有着相同的目录名和文件名,但是内容可能完全不同,不同的镜像层也可能有不同的目录和文件,这些不同的目录和文件都要显示出来。由于要复用镜像层,所以每一层的镜像内容都不能改变。而不同的镜像层又有着目录和文件上的冲突,到底应该显示哪个镜像层的文件内容,哪个镜像层的文件是显示的,哪个是不显示的都需要考虑。overlay的解决方法是:在逻辑上将不同镜像层的目录分为lowerdir和upperdir,lowerdir和upperdir只是目录的角色,并不是要求目录的名字为上述两个中的一个。其中作为lowerdir的目录为只读的基础目录,upperdir的目录为在基础上更改的可写的目录。在分配好了角色后,便有对这些角色的策略,例如如果有相同的文件名称,会显示作为upperdir的文件内容。对lowerdir和upperdir的目录进行联合挂载后的结果为merged,这个目录中展示了所有层的内容,用户通过它来访问文件系统,而不需要关心内容到底来自哪一层。

​ overlay2的合并策略简单来说可以总结为以下三点:

文件覆盖

  • 如果一个文件在多个层中存在,最上层的文件会覆盖下面层中的文件。

文件修改

  • 修改文件时,修改只会发生在可写的容器层中,而不会改变底层的只读镜像层。

文件删除

  • 当你删除一个文件时,OverlayFS 会在可写层中标记这个文件为“已删除”,但底层的只读层中的文件仍然存在。

在这里插入图片描述
下面通过一些简单的例子来更具体地感受overlay是如何合并两个目录的,注意:不要直接修改 upperdir。所有的操作应该通过 merged 目录完成。merged 目录反映了 lowerdirupperdir 的合并视图。所有对文件的修改和新文件的创建都应该通过 merged 目录进行,以确保 upperdir 能够正确地跟踪和应用这些更改:

# 创建文件夹,其中lower和upper为扮演对应角色的文件夹,work为overlay的工作目录,merged为lower和upper合并视图的目录,即展示合并结果的目录
mkdir ./{merged,work,upper,lower}
touch ./upper/{a,b}
touch ./lower/{a,c}

# 目录结构如下所示
.
├── lower
│   ├── a
│   └── c
├── merged
│   ├── a
│   ├── b
│   └── c
├── upper
│   ├── a
│   └── b
└── work
    └── work  [error opening dir]
# 使用overlay将lower和upper进行合并,合并后的文件系统名称为overlay    
sudo mount \
            -t overlay \
            overlay \
            -o lowerdir=./lower,upperdir=./upper,workdir=./work \
            ./merged

# 查看挂载的结果,可以发现已经挂载到了/home/huang/docker_overlay/merged
# 此时所有文件中都没有任何内容
huang@racknerd-47a0b4f:~/docker_overlay$ mount | grep overlay
overlay on /home/huang/docker_overlay/merged type overlay (rw,relatime,lowerdir=./lower,upperdir=./upper,workdir=./work)

# 对merged中的文件进行的修改会体现在upper上,并且不会影响lower中的文件
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./lower/a
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./upper/a
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./merged/a
huang@racknerd-47a0b4f:~/docker_overlay$ echo "merged a" > ./merged/a
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./merged/a
merged a
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./upper/a
merged a
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./lower/a

​ 接着,对merged中的c文件进行修改,其中文件c存在于lower中,不在upper中:

# 接着,对merged中的b和c的内容进行修改,其中b来自于upper,c来自于lower
echo "will-persist"  > ./merged/b
echo "wont-persist"  > ./merged/c

# 可以看到merged中文件的内容发生了改变,由于merged中的b来自于upper中的b,并且upper是可读写的,因此merged中的b的变化会体现在upper上
# 但是lower中的c是只可读的,对merged中的c的变化不能体现在lower中的c上,因此overlay采用了CoW(Copy-on-Write,写时复制),即需要对lower中的文件进行修改时,会将lower中的文件复制到upper,然后对upper中的文件进行修改
# 我们可以看到原本lower中是没有c的,但是对merged中的c进行修改后,upper中也有了c文件
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./merged/b
will-persist
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./merged/c
wont-persist
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./upper/b
will-persist
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./lower/c
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./upper/
a  b  c
huang@racknerd-47a0b4f:~/docker_overlay$ cat ./upper/c
wont-persist

在这里插入图片描述

接着,查看删除文件对文件系统的影响:

# 首先往 lower 目录中写入一个文件 f,并且在merged中可以看到该文件,在upper中看不到该文件,这也符合前面的说明
huang@racknerd-47a0b4f:~/docker_overlay$ echo fff > ./lower/f
huang@racknerd-47a0b4f:~/docker_overlay$ ls merged/
a  b  c  f
huang@racknerd-47a0b4f:~/docker_overlay$ ls upper/
a  b  c

# 那么如果删除掉merged中的f文件会发生什么呢
# 我们发现,lower中的f仍然存在,但是在upper中多出来一个大小为0的c类型文件f,这是为upper中的f创建的一个标记,表示该文件已经被删除了,而不会真正的删除lower中的f文件,因为lower中的文件是只可读的,这里为lower创建文件只是为了演示删除(删除文件或文件夹时,会在 upper 中添加一个同名的 c 标识的文件,这个文件叫 whiteout 文件。当扫描到此文件时,会忽略此文件名。)
huang@racknerd-47a0b4f:~/docker_overlay$ rm merged/f
huang@racknerd-47a0b4f:~/docker_overlay$ ls merged/
a  b  c
huang@racknerd-47a0b4f:~/docker_overlay$ ls lower/
a  c  f
huang@racknerd-47a0b4f:~/docker_overlay$ ls upper/
a  b  c  f
huang@racknerd-47a0b4f:~/docker_overlay$ ls -l upper/f
c--------- 2 root root 0, 0 Sep  4 09:03 upper/f

在这里插入图片描述

接着,再试试创建文件会发生什么

# 在merged中创建文件g,可以发现upper中也出现了文件g,并且内容和merged中的相同,说明 overlay 中添加文件其实就是在 upper 中添加文件
huang@racknerd-47a0b4f:~/docker_overlay$ echo gggg > ./merged/g
huang@racknerd-47a0b4f:~/docker_overlay$ ls merged/
a  b  c  g
huang@racknerd-47a0b4f:~/docker_overlay$ ls upper/
a  b  c  f  g
huang@racknerd-47a0b4f:~/docker_overlay$ cat upper/g
gggg

在这里插入图片描述

Docker的安装

  • 在不同的操作系统下安装Docker的方式不同,如果是要在Windows和Mac下安装Docker,则需要安装Docker Desktop,而在Linux下,可以只安装Docker Engine。在ubuntu版本下安装docker,执行以下命令:

    # 卸载已有的docker,ubuntu官方的docker版本可能和docker官方提供的不兼容,并且名称上也有区别,为了防止出错,先将非docker提供的有关docker的包删除,其中docker.io是Ubuntu 官方仓库中的 Docker 版本,通常较旧,可能与 Docker 官方版本存在兼容性问题
    for pkg in docker.io docker-doc docker-compose podman-docker containerd runc; do sudo apt-get remove $pkg; done
    
    # 更新apt索引,安装下载docker所需要的包
    sudo apt-get update
    # 安装必要的证书和包用于下载docker
    sudo apt-get install ca-certificates curl gnupg
    
    # 创建名为keyrings,权限为755的目录
    sudo install -m 0755 -d /etc/apt/keyrings
    # 添加GPG key到keyrings下
    curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
    sudo chmod a+r /etc/apt/keyrings/docker.gpg
    
    # 设置仓库,会在/etc/apt/sources.list.d/docker.list中添加仓库路径,例如deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu   jammy stable
    echo \
      "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
      "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
      sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
      
    # 更新apt索引
    sudo apt-get update
    docker-ce-cli
    # 下载docker所需要的包,例如docker-ce即docker engine的社区版,docker-ce-cli提供了用于和docker服务进程交互的命令,containerd.io包含了containerd容器运行时等
    sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    
    # 运行hello world,验证是否安装成功,如果成功则会打印一些消息
    sudo docker run hello-world
    
    Hello from Docker!
    This message shows that your installation appears to be working correctly.
    
    To generate this message, Docker took the following steps:
     1. The Docker client contacted the Docker daemon.
     2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
        (amd64)
     3. The Docker daemon created a new container from that image which runs the
        executable that produces the output you are currently reading.
     4. The Docker daemon streamed that output to the Docker client, which sent it
        to your terminal.
    
    To try something more ambitious, you can run an Ubuntu container with:
     $ docker run -it ubuntu bash
    
    Share images, automate workflows, and more with a free Docker ID:
     https://hub.docker.com/
    
    For more examples and ideas, visit:
     https://docs.docker.com/get-started/
    
  • 配置docker镜像加速,由于docker镜像仓库默认使用了国外的库,会导致抓取镜像速度很慢,因此需要配置国内的镜像,步骤如下:

    • 首先登录阿里云官网,进入“控制台”

    • 点击左上角,找到“容器服务”

      在这里插入图片描述

    • 在“镜像工具”下点击“镜像加速器”

    • 在这里插入图片描述

    • 然后按照文档的提示配置镜像

Docker的使用

​ 对docker的使用首先就是抓取docker镜像,上述提到任何镜像都可以成为一个基础镜像,我们可以在这个基础镜像上进行修改,例如可以是一个python的镜像,也可以是一个ubuntu的镜像,前者在有一个精简的操作系统的基础上还包含了python环境,而后者只有一个精简的操作系统。首先还是使用一个最简单的镜像,然后在此基础上构建一个基于Flask的web应用

​ 在抓取镜像之前,我们可能会想知道,自己想要的镜像是否存在,如果存在的话,有哪些版本供自己选择。最简单直接的方式就是进入DockerHub进行搜索然后查看。此外,docker也提供了命令来帮助查找镜像,但是使用命令不能够查询有哪些版本:

# 使用docker search命令可以查看对应的镜像有哪些,其中OFFICIAL下带有OK的表示是官方提供的镜像,此外还有一些私人提供了相关的镜像,但是这些镜像可能不只是单纯的ubuntu镜像,例如ubuntu/python提供了一个在ubuntu上的python环境
$ docker search ubuntu
NAME                    DESCRIPTION                                      STARS     OFFICIAL
ubuntu                  Ubuntu is a Debian-based Linux operating sys…   17240     [OK]
ubuntu/chiselled-jre    [MOVED TO ubuntu/jre] Chiselled JRE: distrol…   3
ubuntu/mimir            Ubuntu ROCK for Mimir, a horizontally scalab…   0
ubuntu/dotnet-deps      Chiselled Ubuntu for self-contained .NET & A…   16
ubuntu/python           A chiselled Ubuntu rock with the Python runt…   8
ubuntu/grafana-agent    Ubuntu ROCK for Grafana Agent, an open-sourc…   0
ubuntu/jre              Distroless Java runtime based on Ubuntu. Lon…   15

​ 接着,抓取镜像:

# 使用docker pull命令可以抓取镜像,后跟想要的镜像+对应的版本,例如22.04,详细的版本信息要在DockerHub上查看
$ docker pull ubuntu:22.04
22.04: Pulling from library/ubuntu
Digest: sha256:adbb90115a21969d2fe6fa7f9af4253e16d45f8d4c1e930182610c4731962658
Status: Image is up to date for ubuntu:22.04
docker.io/library/ubuntu:22.04

# docker images查看所有的镜像,这里可以看到有ubuntu镜像,并且标签为22.04
C:\Users\24981>docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
ubuntu       22.04     53a843653cbc   3 weeks ago   77.9MB

​ 在抓取镜像后,需要将其运行起来作为容器,使用的命令是docker run,其语法格式是docker run [OPTIONS] IMAGE [COMMAND] [ARG...],即要在IMAGE前将所有选项设置完毕。**注意:**docker run是用来从镜像新建容器的命令,而不是启动一个暂停的容器的命令,后续会提到如何停止一个容器和重启一个容器。

​ 在使用docker run新建一个容器时,通常使用docker run -it <container>,其中-i的作用是让容器保持标准输入打开,使得容器能够通过宿主机的终端从用户的键盘接收命令,如果不使用-i,默认情况下,容器的标注输入是关闭的,无法接收来自宿主机的输入。-t的作用是为容器分配一个伪终端(pseudo-TTY)使得容器可以模拟一个终端设备,即为容器准备了一个属于它本身的终端。但是刚开始使用docker run的时候,最容易出现的一个问题就是启动了一个容器之后,发现容器会立刻退出。所以,在我们正式地使用容器之前,先了解一下为什么容器会立刻退出,以及防止这种情况的方法。

Docker 容器启动后立刻退出的原因 & 解决方法

​ 我们先使用docker run配合一些常使用的选项观察一下容器的状态,最后再进行总结

# 首先,不使用任何选项的话,卡顿了一下后就结束了
C:\Users\24981>docker run ubuntu:22.04
C:\Users\24981>
# 这个时候我们通过docker ps可以查看运行的容器,发现没有任何容器
C:\Users\24981>docker ps
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES
# 使用docker ps -a查看所有容器的状况,发现我们确实创建了一个容器,但是立刻退出了
C:\Users\24981>docker ps -a
CONTAINER ID   IMAGE          COMMAND                 CREATED          STATUS                      PORTS     NAMES
2acbeefab6cc   ubuntu:22.04   "/bin/bash"             2 seconds ago    Exited (0) 1 second ago               optimistic_gagarin

# 接着,通过-it新建一个容器,发现提示符变为了root@431ec098c5e6:/#,这说明已经成功的进入了容器,容器的ID为431ec098c5e6
# 并且容器提供了一个/bin/bash的进程,这是因为docker run [OPTIONS] IMAGE [COMMAND] [ARG...]中的[COMMAND] [ARG...]指定了进入容器的第一个进程,即主进程,在ubuntu:22.04中默认是/bin/bash,如果没有提供命令的话,就会按照默认命令执行
# 在容器中,可以执行一些linux命令,发现其和真正的ubuntu几乎相同,这也是上述所说的容器中的一个精简的操作系统

$ docker run -it ubuntu:22.04
root@431ec098c5e6:/#root@431ec098c5e6:/# ls
bin  boot  dev  etc  home  lib  lib32  lib64  libx32  media  mnt  opt  proc  root  run  sbin  srv  sys  tmp  usr  var

# 这个时候我们通过docker ps可以查看运行的容器,我们看到我们的容器的ID,状态,和名字(没有指定名字的话或分配一个随机名字)
# 还有我们可以看到COMMAND对应的是/bin/bash,说明在创建容器时执行的命令是/bin/bash,这也是这个容器的主进程
$ docker ps
CONTAINER ID   IMAGE          COMMAND       CREATED         STATUS         PORTS     NAMES
431ec098c5e6   ubuntu:22.04   "/bin/bash"   7 minutes ago   Up 7 minutes             zealous_solomon

​ 接下来,我们测试一下不使用docker run -it,而是使用一些其它选项,例如docker run -idocker run -t等会对容器产生什么影响:

​ 首先,看一下docker run -i的情况:

# 首先使用dcoker run -i 创建容器,但是执行后会发现没有任何变化,仿佛卡顿了一样
C:\Users\24981>docker run -i ubuntu:22.04

# 但是此时,我们可以在终端中输入一些命令,例如ls,因为-i打开了容器的标准输入,此时可以向容器输入命令
# 但是此时会报错,这是因为我是使用了windows连接的容器,而windows中的换行符是\r\n,linux中的换行符是\n,因此linux无法理解\r,导致错误
# 但是使用docker run -it时则不会出现上述情况,这是因为只使用-i时,只是让容器的标注输入保持打开,而其接受的命令则是来自于宿主机,即windows的终端
# 由于echo可以输出任何内容,所以使用echo命令可以得到正常的结果
C:\Users\24981>docker run -i ubuntu:22.04
ls
/bin/bash: line 1: $'ls\r': command not found
echo hello
hello

# 然后可以在echo的命令前加入一些命令,可以让前面的命令执行,可以看到得到的是容器内的结果,这说明容器真的执行了命令
# 但是因为只有-i,和容器交互时占用了宿主机的终端,并且尽管使用了/bin/bash,但没有任何的命令提示
pwd & echo "good"
good
/

​ 接着,使用docker run -t

# 只使用docker run -t可以直接进入容器,并且有一个独立的终端,但是可以发现,不能输入任何内容,这是因为没有设置-i,容器不接收标准输入流,即不能向容器通过键盘输入内容
C:\Users\24981>docker run -t ubuntu:22.04
root@c4b15ceb9ce7:/#

​ 此外,docker run中还有一个较为常用的选项为-d。使用该选项会以后台进程的方式启动容器,它不会占用终端窗口。实际上在不指定任何选项时,启动的容器会占用宿主机的终端窗口。

# 我们可以看到在不使用-d的情况下,容器会打印good到宿主机的终端上,然后立刻退出
# 如果这是一个长时间运行的程序,就会一直占用宿主机的终端
C:\Users\24981>docker run ubuntu:22.04 echo good
good
C:\Users\24981>

# 使用docker run -d创建容器后,发现打印了容器id后,没有打印good,然后回到winodws的终端环境中,说明-d没有占用当前的终端
C:\Users\24981>docker run -d ubuntu:22.04 echo good
15452562e04b9e600809945f83e0b15169f0d629df4c2bf3ca38ab4745b1f81e

# 但是使用docker ps -a 查看所有容器的状态,发现容器立刻退出了
C:\Users\24981>docker ps -a
CONTAINER ID   IMAGE          COMMAND                 CREATED              STATUS                          PORTS     NAMES
15452562e04b   ubuntu:22.04   "echo good"             38 seconds ago       Exited (0) 37 seconds ago                 eager_mirzakhani

​ 之所以上述命令导致容器立刻退出是因为容器的生命周期和主进程是一致的,主进程即为创建容器时执行的命令,当主进程停止时,容器也会自动退出。上述创建的容器的命令是/bin/bash,由于没有-i以及-t选项,所以/bin/bash不会处于交互模式,在该命令执行完后就会停止,然后导致容器的退出。为了验证这一点,我们可以在创建容器时,使用sleep 20来观察容器是否是在20秒后结束。

# 发现使用该命令后,该容器没有立刻退出
C:\Users\24981>docker run -d ubuntu:22.04 bash -c "sleep 20"
cc4708c060753b02163d19ed20f0926b64c238c98e8b401c66c073ae8eb829e6
C:\Users\24981>docker ps
CONTAINER ID   IMAGE          COMMAND                CREATED          STATUS          PORTS     NAMES
cc4708c06075   ubuntu:22.04   "bash -c 'sleep 20'"   2 seconds ago    Up 2 seconds              gracious_fermat
c4b15ceb9ce7   ubuntu:22.04   "/bin/bash"            19 minutes ago   Up 19 minutes             eager_goldberg

# 20秒后,容器退出,说明当主进程结束后,容器会自动退出
C:\Users\24981>docker ps
CONTAINER ID   IMAGE          COMMAND       CREATED          STATUS          PORTS     NAMES
c4b15ceb9ce7   ubuntu:22.04   "/bin/bash"   19 minutes ago   Up 19 minutes             eager_goldberg

​ 因此,使用该选项时,最好是跟随一个长期执行的命令,例如开启一个服务,运行一个长时间的脚本等。那么docker run -it创建的容器会不会遇到上述情况呢,因为我们同样是使用了/bin/bash命令,当我们想退出容器的时候,会输入exit,但是这会退出掉主进程,导致容器的退出。让我们尝试一下:

# 当输入exit后,容器退出
C:\Users\24981>docker run -it ubuntu:22.04
root@c7fd9970fb03:/# exit
exit
C:\Users\24981>docker ps -a
CONTAINER ID   IMAGE          COMMAND                 CREATED          STATUS                      PORTS     NAMES
c7fd9970fb03   ubuntu:22.04   "/bin/bash"             57 seconds ago   Exited (0) 7 seconds ago              naughty_feistel

​ 既然如此,那么容器似乎只能运行一些长时间执行的命令了,似乎很有局限性。但是,我们知道原因在于主程序的退出,如果我们可以一直保持主程序执行即可。方法有很多种,其中第一种是“分离容器,但不退出进程”。我们知道,当我们使用docker run -it时,有一个虚拟终端和容器持续交互,只要不退出该终端,那么容器也不会停止。docker提供了一种分离容器的方式,快捷键为ctrl + p ctrl +q,使用该快捷键后,不会终止进程,而是离开容器的终端,回到宿主机的终端。这样,容器仍然等待着交互,主进程没有退出,容器也不会退出。至于如何重连容器,会在后续部分提到。

# 我们看到容器0de1d5f77019仍然在运行状态
C:\Users\24981>docker run -it ubuntu:22.04
root@0de1d5f77019:/# (此处执行了ctrl + p ctrl +q)
C:\Users\24981>docker ps
CONTAINER ID   IMAGE          COMMAND       CREATED          STATUS          PORTS     NAMES
0de1d5f77019   ubuntu:22.04   "/bin/bash"   6 seconds ago    Up 6 seconds              keen_cohen

​ 第二种方式比较简单,是使用docker run -id命令docker run -dt命令。为何将-i-t-d结合在一起就可以了呢,下面进行详细的分析。首先是-d的作用,只是让容器在后台运行,不会占用宿主机的终端,也就是说-d从来不会影响容器是否会立刻退出,而在前面我们已经看到了使用-i的话,容器会占用宿主机的终端,但是也可以一直输入命令给容器,即使用-i时不会导致容器立刻退出。同理,使用-t时,虽然不能够向容器发送命令,但是分配给了容器一个虚拟的终端,容器依旧等待着输入,没有立刻退出。只不过上述两种方式都导致容器占用宿主机的终端,所以加上-d后,就相当于让容器不占用宿主机的终端,然后一直运行。

# 可以看到使用-id或-dt都会让容器保持运行状态
C:\Users\24981>docker run -id ubuntu:22.04
6d1b06b80b6b527063ef90cfa7b9b23626c4f31b888c3101e1cfeef6a4cd398c
C:\Users\24981>docker run -dt ubuntu:22.04
adac82337bfcdf5c6ce7fbf5bb29df55561bea22c98c762cc7973673ea5c7340
C:\Users\24981>docker ps
CONTAINER ID   IMAGE          COMMAND       CREATED         STATUS         PORTS     NAMES
adac82337bfc   ubuntu:22.04   "/bin/bash"   1 second ago    Up 1 second              sad_sutherland
6d1b06b80b6b   ubuntu:22.04   "/bin/bash"   9 seconds ago   Up 8 seconds             objective_bouman

​ 第三种方式则是使用一个能够长期运行的进程来作为主进程。因为在某些情况下,可能主进程只会执行一段时间,例如运行一个脚本。但是又可能希望容器在执行完脚本后不要立刻退出。这时候,可以使用一个长期运行的进程作为主进程,在保证容器不会立刻退出后,就再次进入容器执行需要使用的脚本,当脚本运行完毕后,由于主进程仍然运行,不会导致容器的立刻退出。

# 在容器中运行了一个长期的进程,可以看到容器仍然运行
C:\Users\24981>docker run -d ubuntu:22.04 bash -c "while true; do sleep 1000; done"
71106a0472f2f1606fcb147635b4b80a7a688d5e7c768e03e51d28763cb4cd12
C:\Users\24981>docker ps
CONTAINER ID   IMAGE          COMMAND                   CREATED         STATUS         PORTS     NAMES
71106a0472f2   ubuntu:22.04   "bash -c 'while true…"   2 seconds ago   Up 1 second              charming_elion

​ 总的来说,知道了容器为什么会立刻退出的原因后,可以有很多方式来避免容器的立刻退出,上述三种方式只是比较简单和常见的使用方式。在某些特殊的场景中,上述的方式可能不适用,但是只要避免容器中主进程的退出,就能保证容器持续的运行。

使用Docker搭建Python环境

​ 由于我们想要构建一个基于Flask的web应用,我们首先就要有一个python环境。最简单的方式自然是使用Docker抓取一个Python镜像,这里使用python的3.9版本。

# 爬取python3.9镜像,使用docker images查看是否爬取成功
docker pull python:3.9
C:\Users\24981>docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
python       3.9       63f534c4e1aa   8 days ago    996MB

# 创建容器,使用-p 5000:5000创建宿主机和容器的端口映射,即访问宿主机5000端口时会转发到容器的5000端口
# 进入容器后是python的交互界面,使用ctrl+p ctrl+1分离容器
C:\Users\24981>docker run -it -p 5000:5000 python:3.9
Python 3.9.19 (main, Sep  4 2024, 06:01:16)
[GCC 12.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

# 使用docker exec -it进入分离的容器,其会新建一个终端供我们使用,而不是使用原本的终端(python的交互程序)
# 使用docker exec必须定义一个命令,这里定义的是/bin/bash,即在一个新的终端里使用/bin/bash
# 然后安装Flask
C:\Users\24981>docker exec -it a8756dc7c494 /bin/bash
root@a8756dc7c494:/# pip install Flask

​ 接着,为了开发一个简单的Flask应用,我们需要准备一个处理请求的脚本app.py,该脚本可以放在容器的任何位置,其内容为:

from flask import Flask

@app.route('/hello')
def hello_world():
    return "Hello World!!!!"

@app.route('/')
def index():
    return render_template('index.html', user=user_data)

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000, debug=True)

​ 然后在容器中执行命令python app.py运行该脚本,应当能看到类似的以下输出:

root@a8756dc7c494:/ python app.py

  • Serving Flask app ‘app’
  • Debug mode: on
    WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
  • Running on all addresses (0.0.0.0)
  • Running on http://127.0.0.1:5000
  • Running on http://172.17.0.2:5000
    Press CTRL+C to quit

​ 在宿主机的浏览器中输入127.0.0.1:5000进行访问,因为进行了端口映射,访问主机的5000端口就相当于访问容器的5000端口。

在这里插入图片描述

好的,现在我们已经实现了一个简单的Flask应用了,但是这过于简单了,我们希望为应用增加增删改查的功能,即将数据库也加入到我们应用中,在这里我们使用mysql数据库。在做这件事之前,我们需要思考一个问题:mysql的服务应该安装在当面的容器中,还是新建一个mysql容器呢。实际上,当然可以在容器中安装一个mysql服务,因为当前容器是基于ubuntu系统,完全可以通过apt安装mysql服务。但是根据Docker官方的建议,容器应遵循“单一职责”原则,即每个容器应该专注于运行一个特定的服务或应用程序。因此,我们当前的容器用来运行flask应用,就需要另外一个容器运行mysql,然后让当前的容器和mysql容器进行交互来实现数据的增删改查。这种每个容器只运行一个功能或服务的好处在于,每个容器都有自己的运行环境,应用之间不会互相干扰,一个容器的崩溃不会影响其它的容器,而且这种做法易于维护和更新,只对需要更新的容器进行修改即可等。

使用Docker搭建mysql环境

​ 在我们使用mysql容器之前,先回顾一下传统ubuntu或linux服务器部署mysql服务以及使用它们的流程:

​ (1)首先通过apt install mysql-server安装mysql服务;

# 安装mysql,并且查看mysql服务是否启动
sudo apt install mysql-server
huang@racknerd-47a0b4f:~$ sudo systemctl status mysql
● mysql.service - MySQL Community Server
     Loaded: loaded (/lib/systemd/system/mysql.service; enabled; vendor preset: enabled)
     Active: active (running) since Fri 2024-09-06 14:55:03 UTC; 16h ago
     ....

​ (2)然后使用sudo mysql_secure_installation,用来设置mysql中root用户的密码。这里的root是mysql中的root用户,除此之外用户还可以创建其它的用户用来登录mysql。本文不使用该命令,因为在sudo下,不使用密码也可以进入mysql。

# 使用sudo,不用输入密码也可以进入,当然我们也不知道root的密码是什么
huang@racknerd-47a0b4f:~$ sudo mysql -u root -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 12
Server version: 8.0.39-0ubuntu0.22.04.1 (Ubuntu)

Copyright (c) 2000, 2024, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
# 可以正常地执行命令
mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.00 sec)

​ (3)接着就是使用程序连接mysql来进行增删改查操作了。

​ 那么,如果使用一个mysql容器,会有什么不同吗。如果使用的是官方提供的mysql镜像的话,那么确实略有不同,因为其在创建容器时指定了必须要传入root的密码,是通过设置环境变量来实现的,传入的参数以及参数的介绍在DockerHub中有所介绍:

# 可以看到如果只是运行镜像的话,容器提示应该设置环境变量
C:\Users\24981>docker run -it mysql
2024-09-07 11:43:57+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 9.0.1-1.el9 started.
2024-09-07 11:43:58+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
2024-09-07 11:43:58+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 9.0.1-1.el9 started.
2024-09-07 11:43:58+00:00 [ERROR] [Entrypoint]: Database is uninitialized and password option is not specified
    You need to specify one of the following as an environment variable:
    - MYSQL_ROOT_PASSWORD
    - MYSQL_ALLOW_EMPTY_PASSWORD
    - MYSQL_RANDOM_ROOT_PASSWORD
    
# 当我们通过-e的命令可以将环境变量传入到容器,此时也不会报错
# 我们同时使用了-p做了端口映射,因为需要访问数据库
# 这里容器是运行了一个mysql服务,我们是无法进行交互的,但是其是一个长期运行的命令,所以直接分离容器即可
C:\Users\24981>docker run -it -e MYSQL_ROOT_PASSWORD=123 mysql
2024-09-07 11:45:23+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 9.0.1-1.el9 started.
2024-09-07 11:45:23+00:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
2024-09-07 11:45:23+00:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 9.0.1-1.el9 started.
2024-09-07 11:45:23+00:00 [Note] [Entrypoint]: Initializing database files
2024-09-07T11:45:23.955406Z 0 [System] [MY-015017] [Server] MySQL Server Initialization - start.
2024-09-07T11:45:23.956688Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 9.0.1) initializing of server in progress as process 81
2024-09-07T11:45:23.969457Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
2024-09-07T11:45:24.521825Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
2024-09-07T11:45:27.126087Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.

# 可以看到容器还在运行
C:\Users\24981>docker ps
CONTAINER ID   IMAGE        COMMAND                   CREATED          STATUS          PORTS
   NAMES
ec0238ccb18b   mysql        "docker-entrypoint.s…"   59 seconds ago   Up 59 seconds        heuristic_shannon

# 这里我们进入容器打印环境变了,可以发现确实将MYSQL_ROOT_PASSWORD设置为了123
C:\Users\24981>docker exec -it ec0238ccb18b /bin/bash
bash-5.1# printenv
MYSQL_MAJOR=innovation
HOSTNAME=ec0238ccb18b
PWD=/
MYSQL_ROOT_PASSWORD=123
HOME=/root
MYSQL_VERSION=9.0.1-1.el9
GOSU_VERSION=1.17
TERM=xterm
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MYSQL_SHELL_VERSION=9.0.1-1.el9
_=/usr/bin/printenv
 
# 这里不输入密码是不可以的
bash-5.1# mysql -u root -p
Enter password:
ERROR 1045 (28000): Access denied for user 'root'@'localhost' (using password: NO)

# 必须要输入密码才能进入mysql,这里我们已经成功的进入mysql了
bash-5.1# mysql -u root -p123
mysql: [Warning] Using a password on the command line interface can be insecure.
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 16
Server version: 9.0.1 MySQL Community Server - GPL

Copyright (c) 2000, 2024, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> show databases;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
4 rows in set (0.01 sec)

​ 在成功地创建了mysql容器后,下面需要做的就是使用python与mysql数据库交互来保存所需要的数据了。

# 首先进入python容器安装pymysql和cryptography,后者是使用mysql的一个库
C:\Users\24981>docker exec -it a8756dc7c494 /bin/bash
root@a8756dc7c494:/# pip install pymysql; pip install cryptography

# 连接mysql需要知道mysql的主机名和端口,其中端口已经设置好是3306,这是镜像中设置的,创建好容器后会自动暴露该端口
# 那么需要确定主机名是什么,因为我们需要访问的是mysql容器
# 这里使用docker inspect查看mysql容器的详细信息,从中找到IPAddress这一项,在默认情况下所有容器在一个网络中,其中mysql的IP为172.17.05,使用该IP地址即可让python容器访问mysql容器。实际上可以使用容器名访问,后续会提到
C:\Users\24981> docker inspect ec0238ccb18b
"IPAddress": "172.17.0.2"

# 接着新建一个mysql_test.py文件测试是否能正确连接mysql
# 文件内容如下

import pymysql

conn = pymysql.connect(host="172.17.0.2", port=3306, user="root", password="123")

cursor = conn.cursor()

create_sql = "create database user_info"

cursor.execute(create_sql)
cursor.close()
conn.close()

# 执行完上述mysql语句,发现确实新建了数据库,说明访问mysql容器成功
root@a8756dc7c494:/python_web# python mysql_test.py
mysql> show databases
    -> ;
+--------------------+
| Database           |
+--------------------+
| information_schema |
| mysql              |
| performance_schema |
| sys                |
| user_info          |
+--------------------+
5 rows in set (0.00 sec)

​ 在确定了可以使用mysql容器后,下面要做的就是实现一个能够显示和修改用户信息的功能和页面:

# 进入我们刚创建的数据库user_info,创建新的表user_details
mysql> use user_info;
Database changed
mysql> create table user_details (id int, username varchar(100), age int, hobbies varchar(100));
Query OK, 0 rows affected (0.05 sec)

mysql> select * from user_details;
Empty set (0.01 sec)

mysql> insert into user_details (id, username, age, hobbies) values (1, 'huang', 25, 'lol, sanguosha');
Query OK, 1 row affected (0.01 sec)

mysql>  select * from user_details;
+------+----------+------+----------------+
| id   | username | age  | hobbies        |
+------+----------+------+----------------+
|    1 | huang    |   25 | lol, sanguosha |
+------+----------+------+----------------+
1 row in set (0.00 sec)

# 页面为index.html,内容如下所示
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Edit User Info</title>
</head>
<body>
    <h1>User Information</h1>
    <form method="POST" action="/edit">
        <label for="name">Name:</label><br>
        <input type="text" id="name" name="name" value="{{ user['userame'] }}"><br><br>
        
        <label for="age">Age:</label><br>
        <input type="text" id="age" name="age" value="{{ user['age'] }}"><br><br>
        
        <label for="hobbies">Hobbies:</label><br>
        <input type="text" id="hobbies" name="hobbies" value="{{ user['hobbies'] }}"><br><br>
        
        <button type="submit">Save</button>
    </form>
</body>
</html>

# app.py的代码如下,增加了对index.html的修改功能
from flask import Flask, render_template, request, redirect
import pymysql

conn = pymysql.connect(host="172.17.0.2", port=3306, user="root", password="123", database='user_info')



import os

app = Flask(__name__)

@app.route('/hello')
def hello_world():
    return "Hello World!!!!"

@app.route('/')
def index():
    user_data = {}
    cursor = conn.cursor()
    cursor.execute("select * from user_details where id = 1;")
    result = cursor.fetchall()[0]
    user_id = result[0]
    user_data["username"] = result[1]
    user_data["age"] = result[2]
    user_data["hobbies"] = result[3]
    return render_template('index.html', user=user_data)

@app.route('/edit', methods=['POST'])
def edit():
    username = request.form.get('username')
    age = request.form.get('age')
    hobbies = request.form.get('hobbies')

    cursor = conn.cursor()
    cursor.execute(f"update user_details set username='{username}', age={age}, hobbies='{hobbies}' where id = 1;")
    conn.commit()

    return redirect('/')


# ok
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=5000, debug=True)

​ 访问127.0.0.1:5000的时候可以看到用户的信息
在这里插入图片描述
对表格信息进行修改,然后点击save,发现表格信息确实被修改:

在这里插入图片描述

mysql>  select * from user_details;
+------+----------+------+------------------------+
| id   | username | age  | hobbies                |
+------+----------+------+------------------------+
|    1 | huang2   |   35 | lol, sanguosha,reading |
+------+----------+------+------------------------+
1 row in set (0.00 sec)

使用Docker进行应用部署

​ 接下来,就是将我们的容器迁移到部署环境中。此时我们意识到,我们对容器做了很多改变,而这些改变是保留在我们本地的容器的。而原本的镜像本身还是没有进行修改的状态,如果我们想将容器迁移到其它主机上,似乎需要先将原本的镜像在部署环境重新创建一次,再将容器的修改拷贝的部署环境。这当然是一种方式,但实际上还有另外两种常见的方式。在上述关于联合文件系统中我们提到,镜像就是只读的文件层,相当于lowerdir,而容器在此基础上添加了一个upperdir,对容器的修改全部内容都体现在上面。所以我们只要将upperdir和原本的lowerdir合并起来作为新的lowdir,再准备一个新的upperdir就实现对原来容器内容的保存,并在其基础上进行修改。前面的操作其实就是将容器导出为镜像,后面的操作是将新的镜像创建为一个容器。

​ 所以,我们只需要将当前的容器导出为一个新的镜像即可,这样就可以将修改的内容全部保存下来。然后再根据这个镜像创建容器即可。此外,Docker提供了一种通过编写DockerFile文件的形式在原本的镜像之上直接创建一个新的镜像,而跳过创建容器这一步骤。容器还有一个问题是当容器被删除时,容器的内容就会消失,我们所做的改变都会消失掉,而有些数据我们希望是持久化保存的,比如mysql中数据库的数据,当容器删除时我们可能还希望保留其中的数据,这个时候就需要对容器中的数据进行持久化操作。下面将演示如何将开发环境的容器迁移到部署环境中,并进行持久化。

​ 总的来说我们目前有三种方式来实现将本地的容器部署到其它环境:(1)在部署环境使用初始镜像新建容器,然后将修改的文件拷贝到部署环境的容器中;(2)将本地的容器导出为一个新的镜像,让部署环境直接通过镜像创建容器;(3)通过DockerFile的形式直接编写一个新的镜像,通过镜像创建容器。

这里以将Flask容器部署到部署环境作为例子来展示第二种方式——将容器导出为新的镜像。

(1)将容器导出为镜像进行部署

​ 接下来,我们将flask容器导出为一个镜像:

# 我们对python容器进行了很多修改,包括安装flask包,编写程序等,这里将该容器导出为一个新的镜像,名为flask:latest,其中latest是我们镜像的标签或者说版本
# 使用docker commit 命令指定容器和新的镜像名可以将当前容器导出为一个镜像
C:\Users\24981>docker commit a8756dc7c494 flask:latest
sha256:c3268a39b527854b8ecac36a6380ac997183168a896195a0ca632ee025c761b2
# 可以看到我们已经将容器导出为镜像
C:\Users\24981>docker images
REPOSITORY   TAG       IMAGE ID       CREATED         SIZE
flask        latest    c3268a39b527   5 minutes ago   1.37GB
# 我们可以将镜像打包为一个压缩包,上传到部署环境
# 还有一种选择是将镜像上传到仓库,这样就可以通过命令来抓取,随后会谈到这种做法
C:\Users\24981>docker save -o flask.tar flask:latest
# 然后将镜像./flask.tar上传到服务器(部署环境)
sftp> put ./flask.tar
Uploading ./flask.tar to /home/huang/flask.tar
./flask.tar          
# 使用docker load -i 方法将惊现的压缩包加载为镜像
# 可以看到已经有了flask镜像,大小和在开发环境时一样,并且IMAGE ID也相同
huang@racknerd-47a0b4f:~$ sudo docker load -i ./flask.tar
[sudo] password for huang:
ea2f0503377d: Loading layer [==================================================>]  387.9MB/387.9MB
Loaded image: flask:latest
huang@racknerd-47a0b4f:~$ sudo docker images
REPOSITORY    TAG       IMAGE ID       CREATED             SIZE
flask         latest    c3268a39b527   About an hour ago   1.37GB

# 使用该镜像创建一个容器
huang@racknerd-47a0b4f:~$ sudo docker run -it -p 5000:5000 flask
# 创建成功
huang@racknerd-47a0b4f:~$ sudo docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED             STATUS             PORTS
     NAMES
8a82157ffc72   flask     "python3"                9 days ago          Up 9 days          0.0.0.0:5000->5000/tcp
     nostalgic_nash

(2)使用初始镜像新建容器,然后将修改的文件拷贝到部署环境的容器中

​ 不同于flask容器做了太多修改,例如在原来python环境中下载了很多包,又添加了一些python文件,对容器中的很多文件夹进行了修改。mysql容器对于数据库的数据中的修改,发生在/var/lib/mysql,在其官方的镜像文档中提到了这一点。因此,我们可以选择将mysql容器导出为一个镜像,也可以选择使用原来的mysql镜像构建容器,然后将该容器的var/lib/mysql文件夹导出到本地用于部署环境。这里使用第二种方式:

# 将mysql中的数据导出,实际上在创建mysql的容器时就应该进行持久化,在这里会使用到持久化
# mysql的数据存放在/var/lib/mysql下,dockerhub中mysql的镜像有所介绍到这一点
# 首先压缩该文件夹,这个步骤不可缺少,因为windows在处理符号链接时缺乏权限,所以需要将所有文件压缩成一个文件,否则可能会导致拷贝的时候丢失一些文件
tar -czvf mysql_backup.tar.gz /var/lib/mysql
# 使用docker cp命令将容器内文件夹拷贝到本地
C:\Users\24981>docker cp ec0238ccb18b:mysql_backup.tar.gz ./mysql_backup.tar.gz

# 将压缩包上传到部署环境
sftp> put -r ./mysql_backup.tar.gz
# 解压压缩包
tar -xzvf .mysql_backup.tar.gz -C ./mysql_backup

# 然后我们将mysql镜像抓取到部署环境,并将
huang@racknerd-47a0b4f:~$ sudo docker pull mysql

# 使用mysql镜像创建一个mysql容器,并使用持久化
# 其中的-v指定了本地的文件夹挂载到容器中指定的目录,这里就是用解压的mysql_backup替换掉新容器中的/var/lib/mysql,实现数据的拷贝
# 使用这种方式挂载后,后续容器中/var/lib/mysql的变化会同步到mysql_backup中,同理mysql_backup的变化也会提现到/var/lib/mysql中,但通常情况下不会对mysql_backup进行操作
sudo docker run -it -p 3306:3306 -v ./mysql_backup:/var/lib/mysql
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS          PORTS                               NAMES
3c9c2a8336b8   mysql     "docker-entrypoint.s…"   23 minutes ago   Up 23 minutes   0.0.0.0:3306->3306/tcp, 33060/tcp   xenodochial_blackburn

​ 现在已经如windows那样在linux服务器上创建了两个容器,但是进入flask容器后运行app.py脚本可能成功也可能失败,原因在于我们在app.py中指定了连接mysql的ip地址,在本地中其为172.17.0.2,但是在新的部署环境中可能不同,这就导致访问数据库会失败。解决这个问题的方法有多种,其中一种是:在启动容器时将mysql容器的ip地址以环境变量的形式传入,脚本通过环境变量指定访问的mysql的ip地址。但是这种方法麻烦的地方在于,首先需要查看mysql容器的地址,在启动flask的时候需要写较长的启动命令。

​ 实际上,默认情况下,两个容器处于同一个网络中,可以使用容器名代替ip地址进行访问,由于在创建容器时可以使用--name指定容器名,因此不需要查看容器的名称。默认情况下,容器被创建后会被放入到一个名为bridge的网络中,但其在默认情况下不支持使用主机名访问,只支持使用ip地址互相访问。因此,我们需要新建一个网络,实现容器之间通过名称互相访问。

# 在docker中有三个网络,创建的容器默认使用bridge
huang@racknerd-47a0b4f:~$ sudo docker network ls
NETWORK ID     NAME        DRIVER    SCOPE
27885f6e8556   bridge      bridge    local
fce6c98fc881   host        host      local
041c1206c54e   none        null      local
huang@racknerd-47a0b4f:~$ sudo docker inspect xenodochial_blackburn | grep NetworkMode
            "NetworkMode": "bridge",
huang@racknerd-47a0b4f:~$ sudo docker inspect nostalgic_nash | grep NetworkMode
            "NetworkMode": "bridge",

# 使用sed -i 's/host="172.17.0.2"/host="xenodochial_blackburn"/g' app.py 将python容器中的app.py的ip地址修改为xenodochial_blackburn
# 报错,说明不能使用主机名访问容器
Traceback (most recent call last):
  File "/python_web/app.py", line 4, in <module>
    conn = pymysql.connect(host="xenodochial_blackburn", port=3306, user="root", password="123", database='user_info')
  File "/usr/local/lib/python3.9/site-packages/pymysql/connections.py", line 361, in __init__
    self.connect()
  File "/usr/local/lib/python3.9/site-packages/pymysql/connections.py", line 716, in connect
    raise exc
pymysql.err.OperationalError: (2003, "Can't connect to MySQL server on 'xenodochial_blackburn' ([Errno -2] Name or service not known)")

# 使用docker network create命令创建一个新的docker网络
huang@racknerd-47a0b4f:~$ sudo docker network create mynetwork
6dae71a5253b74d26bfe5d9da0ca6b344cc28d5b5fd4cb931785117ccbd6a11f
# 让两个容器断开原来的网络,连接新的网络
huang@racknerd-47a0b4f:~$ sudo docker network disconnect bridge xenodochial_blackburn
huang@racknerd-47a0b4f:~$ sudo docker network disconnect bridge nostalgic_nash
huang@racknerd-47a0b4f:~$ sudo docker network connect mynetwork xenodochial_blackburn
huang@racknerd-47a0b4f:~$ sudo docker network connect mynetwork nostalgic_nash

# 再次运行,发现成功!!!
root@8a82157ffc72:/python_web# python app.py
 * Serving Flask app 'app'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5000
 * Running on http://172.18.0.3:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 117-957-915
(3)通过DockerFile的形式直接编写一个新的镜像

​ 在Docker的使用中,还有两个问题:(1)在我们将python容器中的flask容器的ip地址替换为主机名时,我们使用了sed命令,这是因为在flask容器中没有nano、vi、vim等文本编辑工具。所以当我们发现需要修改容器中某个文件的内容时会较为麻烦。当然,可以在容器中安装额外的工具实现,但是这也增加了容器的体积。

​ (2)我们在开发环境编写代码的时候,进入容器进行开发较为麻烦,对于VSCode来说,需要下载一些插件可以访问容器内的文件,又要在容器内安装一些插件,例如python等,使得VSCode能够有对应的提示等等。而在本地开发,则直接开发即可,但是因为没有在容器中直接开发,如何将本地开发的文件和容器结合为镜像是一个问题。

​ DockerFile可以解决上述两个问题,其是一个文本文件,包含了一系列用于构建镜像的命令和指令,可以定义如何从基础镜像构建新的镜像,并指定在镜像构建过程中执行的步骤。简单来说,它就像一个linux中的脚本一样,通过执行一条条命令来逐渐搭建起容器所需要的环境和文件。其还提供了额外的好处:Dockerfile 记录了创建镜像的所有步骤,确保每次构建都能得到相同的结果。Dockerfile 可以放入版本控制系统(如 Git),跟踪和记录镜像构建过程中的所有更改。清晰地定义了镜像的构建过程,易于理解和维护。

​ 下面我们就使用DockerFile来同时实现对flask容器和mysql容器的部署以了解DockerFile是如何使用的:

# Dockerfile中的指令不区分大小写,但是约定俗成全部大写可以更好地区别指令和参数
# Dockerfile按照顺序执行,就像常规的脚本一样

# FROM 指令初始化一个新的构建阶段,并为后续指令设置基础镜像。 因此,有效的 Dockerfile 必须以 FROM 指令开始(ARG除外)。 映像可以是任何有效的映像,我们在开发环境使用的基础镜像是python:3.9,因此在这里我们指定为python:3.9
# 如果不指定标签的话,默认使用latest
FROM python:3.9
# RUN 指令将执行任何命令,在当前映像之上创建一个新层。 添加的层将用于 Dockerfile 的下一步,有两种形式,Shell形式和Exec形式,CMD指令和ENTRYPOINT指令和RUN一样有这两种形式
# Shell形式 RUN [OPTIONS] <command> ... 类似于在Linux通过shell运行命令,在不需要明确的 shell 环境时,这种方式可以省略显式调用 /bin/sh
# Exec 形式RUN [OPTIONS] [ "<command>", ... ] 直接执行二进制文件,不使用 shell 解释器。这种方式能够更清晰地控制程序和其参数,避免 shell 执行时的一些额外行为
RUN pip install Flask & pip install pymysql & pip install cryptography
# COPY 指令从中指定位置复制新文件或目录,并将它们添加到镜像的指定路径中
COPY ./python_web /python_web
# EXPOSE 指令通知 Docker,容器在运行时监听指定的网络端口
EXPOSE 5000
# CMD 用于指定容器启动时默认要运行的命令或参数。如果在运行容器时没有提供任何命令或参数
CMD ["/bin/bash"]

​ 实际上整个Dockerfile文件只有5行命令,通过这5行命令我们就可以构建出一个新的镜像:

# 使用docker build命令通过Dockerfile构建镜像,其中-t用来指定镜像名,.表示Dockerfile文件所在的位置,默认会寻找该位置下的名为Dockerfile的文件
docker build -t flask_file .
[+] Building 23.7s (8/8) FINISHED 

# 我们看到docker中已经有了flask_file这个镜像
PS E:\docker_volumns> docker images
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
flask_file   latest    ea0b558971d9   48 minutes ago   1.03GB

使用Docker Compose编排容器

​ 目前我们的flask应用相对来说已经比较简单了,但仍然需要两个容器,即flask容器(python容器)以及一个mysql容器。在部署时我们需要启动这两个容器,并且启动时命令要指定好映射的端口和绑定的容器卷,不仅导致命令较长,而且容易出错。一旦容器数量过多,这种部署方式会明显的带来明显的成本。Docker Compose 是一种用于定义和运行多容器应用程序的工具。 它是开启简化、高效的开发和部署体验的关键。

​ 下面使用Docker Compose来部署之前的flask容器和mysql容器,在使用之前已将镜像放入到本地(实际上,不放入本地也可以,通过指定镜像的路径也可以抓取到镜像)。使用Docker Compose需要先创建一个yaml格式的文件,这里为compose.yml。其中我们将flask容器的命令换为了/bin/bash -c "while true; do sleep 1000; done",防止其启动后自动退出。这里的networks也要声明,尽管我们已经创建了该网络,但是在文件中要提及:

services:
  flask:
    image: flask_file
    container_name: flask_c
    ports:
      - 5000:5000
    networks:
      - mynetwork
    command: /bin/bash -c "while true; do sleep 1000; done"
  
  mysql_c:
    image: mysql
    container_name: mysql_c
    ports:
      - 3306:3306
    networks:
      - mynetwork
    volumes:
      - ./mysql:/var/lib/mysql
    environment:
      - MYSQL_ROOT_PASSWORD=123
  
networks:
  mynetwork:
    external: true

​ 接着在终端执行:

# docker-compose默认情况下会将容器的启动状况发送到终端,占用宿主机的终端
# 使用-d让docker-compose不占用终端
docker-compose -f compose.yml up -d

# 所有容器都启动成功
PS E:\docker_volumns> docker ps 
CONTAINER ID   IMAGE        COMMAND                   CREATED        STATUS          PORTS                               NAMES
44907893d3af   mysql        "docker-entrypoint.s…"   13 hours ago   Up 29 seconds   0.0.0.0:3306->3306/tcp, 33060/tcp   mysql_c
c7b0149144da   flask_file   "/bin/bash -c 'while…"   13 hours ago   Up 24 seconds   0.0.0.0:5000->5000/tcp              flask_c

​ 到此为止,我们就已经完成了在一个环境上开发应用,并将其部署到另一个环境中的环节。我们可以看到,中间其实需要我们写一些脚本和配置以实现到其它环境的迁移,似乎过程仍然较为繁琐。但是对于部署人员来说,只要开发人员提供镜像和对应的端口、持久化卷等配置,只需要一个文件就可以启动所有需要的容器,因此在部署的角度上来说是非常方便的。
本文并没有详细地介绍所有的docker命令,只是通过实战的方式展示了一些常用的docker命令的用法。具体docker命令的用法可以查看官方文档或其它资料。

镜像仓库的使用

镜像使用sftp等方式传输较为麻烦,我们可以创建一个仓库,直接通过docker命令来抓取镜像。

  • 首先登录到阿里云,通过控制台进入到容器镜像服务,然后进入个人实例

    在这里插入图片描述

  • 首先创建命名空间,再创建容器仓库

    在这里插入图片描述

  • 然后点击仓库对应的管理按钮获得脚本命令

    在这里插入图片描述

  • 按照脚本命令来提交和抓取镜像即可

    在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值