使用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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM