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
  • 相关阅读:
    全局变量-静态变量
    System.Web.Optimization 找不到引用,教你如何解决?
    CSS text-decoration 属性
    HTML 5 <span> 标签
    [VS]
    C# 条件表达式max=(a>b)?a:b;含义
    vs 2017 Integrated Security 为sspi 含义
    Visual studio 利用Nuget 控制台安装已经下载好的插件
    使用INTERSECT运算符
    Oracle DB 使用子查询来解决查询
  • 原文地址:https://www.cnblogs.com/kkbill/p/12950707.html
Copyright © 2011-2022 走看看