使用Docker部署应用以及容器数据卷Volume

前言

本节通过使用 Docker 部署一个简单的 Web 应用来梳理 Docker 的基本使用;并讲解容器数据卷(Volume)的使用和机制。

实验准备

实验所需要的文件在 /work/container/web 目录下,包含以下文件:

root@ubuntu:~/work/container/web# ls
app.py  Dockerfile  requirements.txt

app.py

from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"           
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())
    
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

这段代码中,使用 Flask 框架启动了一个 Web 服务器,而它唯一的功能是:如果当前环境中有“NAME”这个环境变量,就把它打印在“Hello”之后,否则就打印“Hello world”,最后再打印出当前环境的 hostname。

这个应用的依赖,则被定义在了同目录下的 requirements.txt 文件里,内容如下所示:

$ cat requirements.txt
Flask

最后,也是将一个应用容器化的第一步,就是制作容器镜像。通过编写Dockerfile文件来制作容器镜像,本实验用到的Dockerfile文件如下:

# 使用官方提供的Python开发镜像作为基础镜像
FROM python:3.6-slim

# 将工作目录切换为/app
WORKDIR /app

# 将当前目录下的所有内容复制到/app下
ADD . /app

# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的80端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个Python应用的启动命令
CMD ["python", "app.py"]

Dockerfile 的设计思想,是使用一些标准的原语(即FROM/WORKDIR/...),描述我们所要构建的 Docker 镜像,并且这些原语,都是按顺序处理的

  • FROM:FROM 指令为后续的操作设置基础镜像(Base Image),一个有效的 Dockerfile 文件必须以 FROM 指令开始。指定了“python:3.6-slim”这个官方维护的基础镜像,在这个基础镜像中,已经安装好了python的语言环境等;
  • WORKDIR:WORKDIR 指令为其后续的RUN/CMD等指令设置工作目录。在这里,将工作目录切换至/app,也就是说,在这一句指令执行之后,Dockerfile 之后的操作都以该命令指定的目录(即/app)作为当前目录;
  • ADD <src> ... <dest>:ADD 指令将<src>目录下的文件或目录拷贝至镜像文件系统的<dest>路径下。在这里,就是将当前目录下的3个文件拷贝至/app目录下;
  • RUN:RUN 指令就是在容器里执行相应的shell命令。在这里,使用pip命令安装这个应用所需要的依赖;
  • EXPOSE:对外暴露容器在运行时的监听端口,此外还可以指定是端口监听基于TCP还是UDP的,默认为TCP。在这里,表示允许外界访问容器的80端口。你也可以写成EXPOSE 80/tcp
  • ENV:设置环境变量;
  • CMD:CMD指令的主要作用就是为容器设置默认行为。在这里表示的意思是 Dockerfile 指定 python app.py 为这个容器的进程。其中app.py 的实际路径是/app/app.py。所以,CMD ["python", "app.py"]等价于docker run <image> python app.py 。注意,一个Dockerfile文件中只能有一个CMD指令,如果出现多个,只有最后一个会起作用。

关于Dockerfile文件各个指令详细说明,参考Docker reference

接下来,就可以开始制作镜像了。

构建镜像和运行容器

我们通过 docker build 命令来制作镜像,这个命令的作用就是Build an image from a Dockerfile。

root@ubuntu:~/work/container/web# docker build -t helloworld .
Sending build context to Docker daemon  4.096kB
Step 1/7 : FROM python:3.6-slim
3.6-slim: Pulling from library/python
5b54d594fba7: Pull complete 
76fff9075457: Pull complete 
351a67428beb: Pull complete 
68edd34c5fde: Pull complete 
e3269dfd8c02: Pull complete 
Digest: sha256:30df04422229a2aa9041dcbde4006a4c1bf83ef6c1200dd36bfb0ab09ed19b98
Status: Downloaded newer image for python:3.6-slim
 ---> 3e48f0cc67e7
Step 2/7 : WORKDIR /app
 ---> Running in 7b6cb88bfa5f
Removing intermediate container 7b6cb88bfa5f
 ---> 66fa2e295430
Step 3/7 : ADD . /app
 ---> 9ea1140edda5
Step 4/7 : RUN pip install --trusted-host pypi.python.org -r requirements.txt
 ---> Running in 6d79dd383e00
 ...
Removing intermediate container 6d79dd383e00
 ---> ce27f7d4737a
Step 5/7 : EXPOSE 80
 ---> Running in a35edbde159d
Removing intermediate container a35edbde159d
 ---> 0775dd5bc758
Step 6/7 : ENV NAME World
 ---> Running in ff7947f31b16
Removing intermediate container ff7947f31b16
 ---> 7930397fc9a4
Step 7/7 : CMD ["python", "app.py"]
 ---> Running in 3600087d3515
Removing intermediate container 3600087d3515
 ---> f4cb037a3aeb
Successfully built f4cb037a3aeb
Successfully tagged helloworld:latest

其中,-t 的作用是给这个镜像加一个 Tag,也就是起一个名字。docker build 会自动加载当前目录下的 Dockerfile 文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于 Docker 使用基础镜像启动了一个容器,然后在容器中依次执行 Dockerfile 中的原语。需要注意的是,Dockerfile 中的每个原语执行后,都会生成一个对应的镜像层,从上面的Step 1/7, 2/7...可以看出来。即使原语本身并没有明显地修改文件的操作(比如,ENV 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。

扫描二维码关注公众号,回复: 11260141 查看本文章

Successfully built f4cb037a3aeb可以看到,镜像已经成功制作完成,对应的镜像ID就是f4cb037a3aeb,可以通过 docker images 命令进行验证。

root@ubuntu:~/work/container/web# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
helloworld          latest              f4cb037a3aeb        9 seconds ago       184MB

接下来,通过 docker run 命令启动容器,也就是使用这个镜像:

root@ubuntu:~/work/container/web# docker run -p 4000:80 helloworld
 * Serving Flask app "app" (lazy loading)
 * Environment: production
   WARNING: This is a development server. Do not use it in a production d
   Use a production WSGI server instead.
 * Debug mode: off
 * Running on http://0.0.0.0:80/ (Press CTRL+C to quit)
 ...

至此,这个简单的web服务器已经启动。

在另一个终端上可以看到这个容器正在运行:

root@ubuntu:~# docker ps
CONTAINER ID     IMAGE        COMMAND       ...         PORTS              NAMES
7c35e5ffdcf7  helloworld   "python app.py"         0.0.0.0:4000->80/tcp   quirky_hoover

启动时加了 -p 参数,表示把容器的80端口映射到宿主机的4000端口上,这样,访问宿主机的4000端口,就会转到容器的80端口上了。

root@ubuntu:~# curl http://localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> 7c35e5ffdcf7<br/>

可以看到,正常返回结果。

事实上,我们还可以直接通过容器ip:80 的方式来访问该web服务。不过,这得首先需要知道容器的ip,可以通过docker inspect 获取容器相关的详细信息。

root@ubuntu:~# docker inspect 7c35e5ffdcf7
[
    {
        ...
        "NetworkSettings": {
            "Bridge": "",
            "SandboxID": "85002c051b93b960a24ec871b67ea28076876b711c8b667ab622368e9d775722",
            "HairpinMode": false,
            "LinkLocalIPv6Address": "",
            "LinkLocalIPv6PrefixLen": 0,
            "Ports": {
                "80/tcp": [
                    {
                        "HostIp": "0.0.0.0",
                        "HostPort": "4000"
                    }
                ]
            },
            "SandboxKey": "/var/run/docker/netns/85002c051b93",
            "SecondaryIPAddresses": null,
            "SecondaryIPv6Addresses": null,
            "EndpointID": "d2769081b329792023cdbdb6395963dc7e1214cdc045e6d9f08d0cb2c1c5b71c",
            "Gateway": "172.18.0.1",
            "GlobalIPv6Address": "",
            "GlobalIPv6PrefixLen": 0,
            "IPAddress": "172.18.0.3",
            "IPPrefixLen": 16,
            "IPv6Gateway": "",
            "MacAddress": "02:42:ac:12:00:03",
            "Networks": {
                "bridge": {
                    "IPAMConfig": null,
                    "Links": null,
                    "Aliases": null,
                    "NetworkID": "8d1d0466296cb2545f28c1e8c0467c266e167284465333843865208fbdeff654",
                    "EndpointID": "d2769081b329792023cdbdb6395963dc7e1214cdc045e6d9f08d0cb2c1c5b71c",
                    "Gateway": "172.18.0.1",
                    "IPAddress": "172.18.0.3",
                    "IPPrefixLen": 16,
                    "IPv6Gateway": "",
                    "GlobalIPv6Address": "",
                    "GlobalIPv6PrefixLen": 0,
                    "MacAddress": "02:42:ac:12:00:03",
                    "DriverOpts": null
                }
            }
        }
    }
]

从上面的信息可以知道,容器的ip是172.18.0.3,因此可以直接像如下访问。

root@ubuntu:~# curl 172.18.0.3:80
<h3>Hello World!</h3><b>Hostname:</b> 7c35e5ffdcf7<br/>

至此,已经使用容器完成了一个应用的开发与测试。

我们来简单回顾一下:

  • 除了应用相关的文件,首先要编写一个Dockerfile文件,因此我们需要了解诸如FROM/RUN/CMD这样的指令的含义;编写正确的Dockerfile文件是构建镜像的前提;
  • 然后我们使用 docker build 命令构建镜像;
  • 构建好镜像之后,就可以使用 docker run 命令来启动容器了(该命令的作用就是根据指定的容器镜像来运行容器);当容器正常运行后,也就是说业务已成功部署并运行了。

镜像的分发

如果现在想要把这个容器的镜像上传到 DockerHub 上分享给更多的人,我要怎么做呢?为了能够上传镜像,首先需要注册一个 Docker Hub 账号(如果还没有Docker Hub账号,先去官网申请),然后使用 docker login 命令登录。

登录Docker Hub

root@ubuntu:~# docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
Username: kkbill
Password: 
WARNING! Your password will be stored unencrypted in /root/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store

Login Succeeded

然后使用 docker tag 命令给容器镜像打标签

root@ubuntu:~# docker tag helloworld kkbill/helloworld:v1.0

在本地可以看到镜像已经发生了一些变化

root@ubuntu:~# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
kkbill/helloworld   v1.0                f4cb037a3aeb        51 minutes ago      184MB

接下来,把这个镜像推到 Docker Hub上去。

root@ubuntu:~# docker push kkbill/helloworld:v1.0

随后在个人主页上可以看到:

至此,已经成功把自己打包的镜像上传到Docker Hub上了,如果别人需要,可以通过docker pull kkbill/helloworld:v1.0 pull下来使用。

此外,还可以使用 docker commit 命令,把一个正在运行的容器,直接提交为一个镜像。在这里,我先进入容器中做了简单的修改(即添加了一个文件),随后再执行 docker commit 命令。

root@ubuntu:~# docker exec -ti 7adfd4d9ac2d /bin/sh
# ls
Dockerfile  app.py  requirements.txt
# touch test.txt
# ls
Dockerfile  app.py  requirements.txt  test.txt
# exit
root@ubuntu:~# docker commit 7adfd4d9ac2d kkbill/helloworld:v1.1
sha256:9e677a13bbf067e448ec112aa06dc0a2a397fa921253d838d73a2a873fcc7b5a
root@ubuntu:~# docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
kkbill/helloworld   v1.1                9e677a13bbf0        17 seconds ago      184MB
kkbill/helloworld   v1.0                f4cb037a3aeb        About an hour ago   184MB

如有必要,也可以再次把这个新的镜像push到Docker Hub上去。

关于 docker commit 这个命令,官方的描述是:Create a new image from a container’s changes。实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。不过下面这些只读层在宿主机上是共享的,不会占用额外的空间

而由于使用了联合文件系统,在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的Copy-on-Write。而Init 层的存在,就是为了避免执行 docker commit 时,把 Docker 自己对 /etc/hosts 等文件做的修改,也一起提交掉。

另外,在企业内部,能不能也搭建一个跟 Docker Hub 类似的镜像上传系统呢?当然可以,这个统一存放镜像的系统,就叫作 Docker Registry。感兴趣的话,可以查看Docker 的官方文档,以及 Harbor 项目

数据卷(Volume)

容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:

  • 容器里进程新建的文件,怎么才能让宿主机获取到?
  • 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作

Docker 支持两种 volume 的声明方式,可以把宿主机中的目录挂载到容器内的指定目录中。

  • docker run -v /test ...:这种方式并没有显示声明宿主机目录,那么 Docker 就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的 /test 目录上。
  • docker run -v /home:/test ...:把宿主机的 /home 目录挂载到容器的 /test 目录上。

首先,启动一个 helloworld 容器,记得加上 -v 参数,表明要挂载一个数据卷。

root@ubuntu:~# docker run -d -v /test kkbill/helloworld:v1.0
4e7b0192d0cde5260298db796c654df238b7a58ac75937a883b2d1cea37a0ad8

通过 docker volume 查看一下对应的 volume 信息:

root@ubuntu:~# docker volume ls
DRIVER              VOLUME NAME
local               ad20a6c12c554fa429caca5261a6e459ffbc94a41a2db6bfd1a2f6f8fa3a81c7
root@ubuntu:~# docker volume inspect ad20a6c12c554fa42...
[
    {
        "CreatedAt": "2020-05-16T16:38:37+08:00",
        "Driver": "local",
        "Labels": null,
        "Mountpoint": "/var/lib/docker/volumes/ad20a6c12c554fa42.../_data",
        "Name": "ad20a6c12c554fa42...",
        "Options": null,
        "Scope": "local"
    }
]

可以看到,挂载点在/var/lib/docker/volumes/ad20a6c12c554fa42.../_data ,和之前分析的一致。这个 _data 文件夹,就是这个容器的 Volume 在宿主机上对应的临时目录了。

或者,我们也可以通过 docker inspect container_id 来查看该容器的volume挂载信息,如下:

root@ubuntu:~# docker inspect 4e7b0192d0cd
...
        "Mounts": [
            {
                "Type": "volume",
                "Name": "ad20a6c12c554fa42...",
                // 宿主机上的目录
                "Source": "/var/lib/docker/volumes/ad20a6c12c554fa42.../_data",
                // 容器内的目录
                "Destination": "/test",
                "Driver": "local",
                "Mode": "",
                "RW": true,
                "Propagation": ""
            }
        ],

接下来,进入容器内部并添加一个文件。

root@ubuntu:~# docker exec -ti 4e7b0192d0cd /bin/bash
root@4e7b0192d0cd:/app# ls /   //可以看到,在根目录下已经创建好了/test文件夹
app  boot  etc	 lib	media  opt   root  sbin  sys   tmp  var
bin  dev   home  lib64	mnt    proc  run   srv	 test  usr  
root@4e7b0192d0cd:/app# cd /test
root@4e7b0192d0cd:/test# ls
root@4e7b0192d0cd:/test# echo "hello,world" > test.txt
root@4e7b0192d0cd:/test# ls
test.txt

然后回到宿主机,去 _data 文件价下看看发生了什么变化。

root@ubuntu:~# ls /var/lib/docker/volumes/ad20a6c12c554fa42.../_data/
test.txt
root@ubuntu:~# cat /var/lib/docker/volumes/ad20a6c12c554fa42../_data/test.txt 
hello,world

可以看到,我们在宿主机上能够看到容器添加的文件。

如果在宿主机上对挂载目录下的文件进行修改,或是在挂载目录下新增/删除文件,在容器内部应该也能马上看到:

root@ubuntu:~# vim /var/lib/docker/volumes/ad20a6c12c554fa42.../_data/test.txt 
root@ubuntu:~# cat /var/lib/docker/volumes/ad20a6c12c554fa42.../_data/test.txt
hello,world
add something here  // 新增一句话
// 进入挂载目录,并新增一个文件
root@ubuntu:/var/lib/docker/volumes/ad20a6c12c554fa42.../_data# touch test2.txt

再次进入容器内,可以看到,在宿主机上对文件的修改在容器内也可以看到:

root@ubuntu:~# docker exec -ti 4e7b0192d0cd /bin/bash
root@4e7b0192d0cd:/app# cat /test/test.txt 
hello,world
add something here
root@4e7b0192d0cd:/app# ls /test/
test.txt  test2.txt

以上就是容器数据卷(Volume)的操作演示。

那么,Volume 背后的原理是什么呢?这一操作究竟是怎样实验的呢?

之前已经介绍过,当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 pivot_root(或者chroot )之前,容器进程一直可以看到宿主机上的整个文件系统

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。

所以,我们只需要在 rootfs 准备好之后,在执行 pivot_root之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上(有点绕,仔细体会),这个 Volume 的挂载工作就完成了。

注意:这里提到的"容器进程",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。(这部分很重要,目前还没有完全搞懂...2020/05/16)

而这里要使用到的挂载技术,就是 Linux 的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

从Linux 内核的角度来看,绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”

img

如上图所示,执行mount --bind /home /test操作,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

(全文完)


参考:

  1. 极客时间专栏:https://time.geekbang.org/column/article/18119

猜你喜欢

转载自www.cnblogs.com/kkbill/p/12950707.html