前言
本節通過使用 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 所使用的“指針”。
如上圖所示,執行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)里,而不會影響容器鏡像的內容。
(全文完)
參考: