zoukankan      html  css  js  c++  java
  • 使用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 原语),它对应的层也会存在。只不过在外界看来,这个层是空的。

    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
  • 相关阅读:
    AtCoder Beginner Contest 167
    AtCoder Beginner Contest 166
    AtCoder Beginner Contest 165
    AtCoder Beginner Contest 164
    AtCoder Beginner Contest 163
    AtCoder Beginner Contest 162
    AtCoder Beginner Contest 161
    AtCoder Beginner Contest 160
    AtCoder Beginner Contest 159
    自定义Mybatis自动生成代码规则
  • 原文地址:https://www.cnblogs.com/kkbill/p/12950707.html
Copyright © 2011-2022 走看看