這一次,我要用+Docker+部署一個用+Python+編寫的+Web+應用。這個應用的代碼部分(app.py)很是簡單:html
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 文件裏,內容以下所示:node
$ cat requirements.txt Flask
而將這樣一個應用容器化的第一步,是製做容器鏡像。 不過,相較於我以前介紹的製做 rootfs 的過程,Docker 爲你提供了一種更便捷的方式,叫做 Dockerfile,以下所示。python
# 使用官方提供的 Python 開發鏡像做爲基礎鏡像 FROM python:2.7-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 的設計思想,是使用一些標準的原語(即大寫高亮的詞語),描述咱們所要構建的 Docker 鏡像。而且這些原語,都是按順序處理的。 好比 FROM 原語,指定了「python:2.7-slim」這個官方維護的基礎鏡像,從而免去了安裝 Python 等語言環境的操做。不然,這一段咱們就得這麼寫了:docker
FROM ubuntu:latest RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential ...
其中,RUN 原語就是在容器裏執行 shell 命令的意思。 而 WORKDIR,意思是在這一句以後,Dockerfile 後面的操做都以這一句指定的 /app 目錄做爲當前目錄。 因此,到了最後的 CMD,意思是 Dockerfile 指定 python app.py 爲這個容器的進程。這裏,app.py 的實際路徑是 /app/app.py。因此,CMD[「python」,「app.py」]等價於 "docker run python app.py"。shell
另外,在使用 Dockerfile 時,你可能還會看到一個叫做 NTRYPOINT 的原語。實際上,它和 CMD 都是 Docker 容器進程啓動所必需的參數,完整執行格式是:「ENTRYPOINT CMD」。flask
可是,默認狀況下,Docker 會爲你提供一個隱含的 ENTRYPOINT,即:/bin/sh -c。因此,在不指定 ENTRYPOINT 時,好比在咱們這個例子裏,實際上運行在容器裏的完整進程是:/bin/sh+-c 「python app.py」,即 CMD 的內容就是 ENTRYPOINT 的參數。 備註:基於以上緣由,咱們後面會統一稱 Docker+容器的啓動進程爲 ENTRYPOINT,而不是 CMD。ubuntu
須要注意的是,Dockerfile 裏的原語並不都是指對容器內部的操做。就好比 ADD,它指的是把當前目錄(即 Dockerfile 所在的目錄)裏的文件,複製到指定容器內的目錄當中。小程序
讀懂這個 Dockerfile 以後,我再把上述內容,保存到當前目錄裏一個名叫「Dockerfile」的文件中:bash
$ ls Dockerfile app.py requirements.txt
接下來,我就可讓+Docker+製做這個鏡像了,在當前目錄執行:服務器
$ docker build -t helloworld .
其中,-t 的做用是給這個鏡像加一個 Tag,即:起一個好聽的名字。docker build 會自動加載當前目錄下的 Dockerfile 文件,而後按照順序,執行文件中的原語。而這個過程,實際上能夠等同於 Docker 使用基礎鏡像啓動了一個容器,而後在容器中依次執行 Dockerfile 中的原語。
須要注意的是,Dockerfile 中的每一個原語執行後,都會生成一個對應的鏡像層。即便原語自己並無明顯地修改文件的操做(好比,ENV 原語),它對應的層也會存在。只不過在外界看來,這個層是空的。 docker build 操做完成後,我能夠經過 docker images 命令查看結果:
$ docker image ls REPOSITORY TAG IMAGE ID helloworld latest 653287cdf998
接下來,我使用這個鏡像,經過+docker+run+命令啓動容器:
$ docker run -p 4000:80 helloworld
在這一句命令中,鏡像名+helloworld+後面,我什麼都不用寫,由於在+Dockerfile+中已經指定了+CMD。不然,我就得把進程的啓動命令加在後面:
$ docker run -p 4000:80 helloworld python app.py
容器啓動以後,我可使用+docker+ps+命令看到:
$ docker ps CONTAINER ID IMAGE COMMAND CREATED 4ddf4638572d helloworld "python app.py" 10 seconds ago
同時,我已經經過 -p 4000:80 告訴了 Docker,請把容器內的 80 端口映射在宿主機的 4000 端口上。 這樣作的目的是,只要訪問宿主機的 4000 端口,我就能夠看到容器裏應用返回的結果:
$ curl http://localhost:4000 <h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>
不然,我就得先用 docker inspect 命令查看容器的 IP 地址,而後訪問「http://< 容器 IP 地址 >:80」才能夠看到容器內應用的返回。
至此,我已經使用容器完成了一個應用的開發與測試,若是如今想要把這個容器的鏡像上傳到 DockerHub 上分享給更多的人,我要怎麼作呢?
爲了可以上傳鏡像,我首先須要註冊一個 Docker Hub 帳號,而後使用 docker login 命令登陸。 接下來,我要用 docker tag 命令給容器鏡像起一個完整的名字:
$ docker tag helloworld geektime/helloworld:v1
注意:你本身作實驗時,請將 "geektime" 替換成你本身的 Docker Hub 帳戶名稱,好比 zhangsan/helloworld:v1 其中,geektime 是我在 Docker Hub 上的用戶名,它的「學名」叫鏡像倉庫(Repository);「/」後面的 helloworld 是這個鏡像的名字,而「v1」則是我給這個鏡像分配的版本號。 而後,我執行 docker+push:
$ docker push geektime/helloworld:v1
這樣,我就能夠把這個鏡像上傳到 Docker Hub 上了。 此外,我還可使用 docker commit 指令,把一個正在運行的容器,直接提交爲一個鏡像。通常來講,須要這麼操做緣由是:這個容器運行起來後,我又在裏面作了一些操做,而且要把操做結果保存到鏡像裏,好比:
$ docker exec -it 4ddf4638572d /bin/sh # 在容器內部新建了一個文件 root@4ddf4638572d:/app# touch test.txt root@4ddf4638572d:/app# exit # 將這個新建的文件提交到鏡像中保存 $ docker commit 4ddf4638572d geektime/helloworld:v2
這裏,我使用了 docker exec 命令進入到了容器當中。在瞭解了 Linux Namespace 的隔離機制後,你應該會很天然地想到一個問題:docker exec 是怎麼作到進入容器裏的呢?
實際上,Linux Namespace 建立的隔離空間雖然看不見摸不着,但一個進程的 Namespace 信息在宿主機上是確確實實存在的,而且是以一個文件的方式存在。
好比,經過以下指令,你能夠看到當前正在運行的+Docker+容器的進程號(PID)是 25686:
$ docker inspect --format '{{ .State.Pid }}' 4ddf4638572d 25686
這時,你能夠經過查看宿主機的 proc 文件,看到這個 25686 進程的全部 Namespace 對應的文件:
$ ls -l /proc/25686/ns total 0 lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835] lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278] lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276] lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281] lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279] lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279] lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837] lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]
能夠看到,一個進程的每種 Linux Namespace,都在它對應的/proc[進程號]/ns 下有一個對應的虛擬文件,而且連接到一個真實的 Namespace 文件上。
有了這樣一個能夠「hold+住」全部 Linux Namespace 的文件,咱們就能夠對 Namespace 作一些頗有意義事情了,好比:加入到一個已經存在的 Namespace 當中。
這也就意味着:一個進程,能夠選擇加入到某個進程已有的 Namespace 當中,從而達到「進入」這個進程所在容器的目的,這正是 docker exec 的實現原理。 而這個操做所依賴的,乃是一個名叫 setns() 的 Linux 系統調用。它的調用方法,我能夠用以下一段小程序爲你說明:
#define _GNU_SOURCE #include <fcntl.h> #include <sched.h> #include <unistd.h> #include <stdlib.h> #include <stdio.h> #define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0) int main(int argc, char *argv[]) { int fd; fd = open(argv[1], O_RDONLY); if (setns(fd, 0) == -1) { errExit("setns"); } execvp(argv[2], &argv[2]); errExit("execvp"); }
正如上所示,當咱們執行 ifconfig 命令查看網絡設備時,我會發現能看到的網卡「變少」了:只有兩個。而個人宿主機則至少有四個網卡。這是怎麼回事呢?
實際上,在 setns() 以後我看到的這兩個網卡,正是我在前面啓動的 Docker 容器裏的網卡。也就是說,我新建立的這個 /bin/bash 進程,因爲加入了該容器進程(PID=25686)的 Network Namepace,它看到的網絡設備與這個容器裏是同樣的,即:/bin/bash 進程的網絡設備視圖,也被修改了。
而一旦一個進程加入到了另外一個 Namespace 當中,在宿主機的 Namespace 文件上,也會有所體現。
在宿主機上,你能夠用 ps 指令找到這個 set_ns 程序執行的/bin/bash 進程,其真實的 PID 是 28499:
# 在宿主機上 ps aux | grep /bin/bash root 28499 0.0 0.0 19944 3612 pts/0 S 14:15 0:00 /bin/bash
這時,若是按照前面介紹過的方法,查看一下這個 PID=28499 的進程的 Namespace,你就會發現這樣一個事實:
$ ls -l /proc/28499/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281] $ ls -l /proc/25686/ns/net lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]
在 /proc[PID]/ns/net 目錄下,這個 PID=28499 進程,與咱們前面的 Docker 容器進程(PID=25686)指向的 Network Namespace 文件徹底同樣。這說明這兩個進程,共享了這個名叫+net:[4026532281]的 Net: work Namespace。 此外,Docker 還專門提供了一個參數,可讓你啓動一個容器並「加入」到另外一個容器的 Network Namespace 裏,這個參數就是 -net,好比
$ docker run -it --net container:4ddf4638572d busybox ifconfig
這樣,咱們新啓動的這個容器,就會直接加入到 ID=4ddf4638572d 的容器,也就是咱們前面的建立的 Python 應用容器(PID=25686)的 Network Namespace 中。因此,這裏 ifconfig 返回的網卡信息,跟我前面那個小程序返回的結果如出一轍,你也能夠嘗試一下。
而若是我指定–net=host,就意味着這個容器不會爲進程啓用 Network Namespace。這就意味着,這個容器拆除了 Network Namespace 的「隔離牆」,因此,它會和宿主機上的其餘普通進程同樣,直接共享宿主機的網絡棧。這就爲容器直接操做和使用宿主機網絡提供了一個渠道。
轉了一個大圈子,我實際上是爲你詳細解讀了 docker exec 這個操做背後,Linux Namespace 更具體的工做原理。
這種經過操做系統進程相關的知識,逐步剖析 Docker 容器的方法,是理解容器的一個關鍵思路,但願你必定要掌握。
如今,咱們再一塊兒回到前面提交鏡像的操做 docker commit 上來吧。 docker commit,實際上就是在容器運行起來後,把最上層的「可讀寫層」,加上原先容器鏡像的只讀層,打包組成了一個新的鏡像。固然,下面這些只讀層在宿主機上是共享的,不會佔用額外的空間。
而因爲使用了聯合文件系統,你在容器裏對鏡像 rootfs 所作的任何修改,都會被操做系統先複製到這個可讀寫層,而後再修改。這就是所謂的:Copy-on-Write。
而正如前所說,Init 層的存在,就是爲了不你執行 docker commit 時,把 Docker 本身對 /etc/hosts 等文件作的修改,也一塊兒提交掉。 有了新的鏡像,咱們就能夠把它推送到 Docker Hub 上了:
$ docker push geektime/helloworld:v2
你可能還會有這樣的問題:我在企業內部,能不能也搭建一個跟 Docker Hub 相似的鏡像上傳系統呢?
固然能夠,這個統一存放鏡像的系統,就叫做 Docker Registry。感興趣的話,你能夠查看Docker 的官方文檔,以及VMware 的 Harbor 項目。
最後,我再來說解一下 Docker 項目另外一個重要的內容:Volume(數據卷)。
前面我已經介紹過,容器技術使用了 rootfs 機制和 Mount Namespace,構建出了一個同宿主機徹底隔離開的文件系統環境。這時候,咱們就須要考慮這樣兩個問題:
容器裏進程新建的文件,怎麼才能讓宿主機獲取到? 宿主機上的文件和目錄,怎麼才能讓容器裏的進程訪問到? 這正是 Docker Volume 要解決的問題:Volume 機制,容許你將宿主機上指定的目錄或者文件,掛載到容器裏面進行讀取和修改操做。 在 Docker 項目裏,它支持兩種 Volume 聲明方式,能夠把宿主機目錄掛載進容器的 /Ftest 目錄當中:
$ docker run -v /test ... $ docker run -v /home:/test ...
而這兩種聲明方式的本質,其實是相同的:都是把一個宿主機的目錄掛載進了容器的 /test+目錄。 只不過,在第一種狀況下,因爲你並無顯示聲明宿主機目錄,那麼 Docker 就會默認在宿主機上建立一個臨時目錄/Fvar/lib/docker/volumes[VOLUME_ID]/_data,而後把它掛載到容器的 /test 目錄上。而在第二種狀況下,Docker 就直接把宿主機的/home 目錄掛載到容器的 /test 目錄上。
那麼,Docker 又是如何作到把一個宿主機上的目錄或者文件,掛載到容器裏面去呢?難道又是 Mount Namespace 的黑科技嗎?
實際上,並不須要這麼麻煩。當容器進程被建立以後,儘管開啓了 Mount Namespace,可是在它執行 chroot(或者 pivot_root)以前,容器進程一直能夠看到宿主機上的整個文件系統。
而宿主機上的文件系統,也天然包括了咱們要使用的容器鏡像。這個鏡像的各個層,保存在/var/lib/docker/aufs/diff 目錄下,在容器進程啓動後,它們會被聯合掛載在/var/lib/docker/aufs/mnt/目錄中,這樣容器所需的 rootfs 就準備好了。
因此,咱們只須要在 rootfs 準備好以後,在執行 chroot 以前,把 Volume 指定的宿主機目錄(好比/home 目錄),掛載到指定的容器目錄(好比 /test 目錄)在宿主機上對應的目錄(即/var/lib/docker/aufs/mnt[可讀寫層+D]/test)上,這個 Volume 的掛載工做就完成了。
更重要的是,因爲執行這個掛載操做時,「容器進程」已經建立了,也就意味着此時 Mount Namespace 已經開啓了。因此,這個掛載事件只在這個容器裏可見。你在宿主機上,是看不見容器內部的這個掛載點的。這就保證了容器的隔離性不會被 Volume 打破。
注意:這裏提到的 " 容器進程 ",是 Docker 建立的一個容器初始化進程 (dockerinit),而不是應用進程 (ENTRYPOINT + CMD)。dockerinit 會負責完成根目錄的準備、掛載設備和目錄、配置 hostname 等一系列須要在容器內進行的初始化操做。最後,它經過 execv() 系統調用,讓應用進程取代本身,成爲容器裏的 PID=1 的進程。
而這裏要使用到的掛載技術,就是 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 目錄裏。
因此,在一個正確的時機,進行一次綁定掛載,Docker 就能夠成功地將一個宿主機上的目錄或文件,不動聲色地掛載到容器中。
這樣,進程在容器裏對這個 /test 目錄進行的全部操做,都實際發生在宿主機的對應目錄(好比,/home,或者/var/lib/docker/volumes[VOLUME_ID]_data)裏,而不會影響容器鏡像的內容。
那麼,這個 /test 目錄裏的內容,既然掛載在容器 rootfs 的可讀寫層,它會不會被 docker commit 提交掉呢?
也不會。 這個緣由其實咱們前面已經提到過。容器的鏡像操做,好比 docker commit,都是發生在宿主機空間的。而因爲 Mount Namespace 的隔離做用,宿主機並不知道這個綁定掛載的存在。因此,在宿主機看來,容器中可讀寫層的/test 目錄(/var/lib/docker/aufs/mnt%2F%5B[可讀寫層]/test),始終是空的。
不過,因爲 Docker 一開始仍是要建立/test 這個目錄做爲掛載點,因此執行了 docker commit 以後,你會發現新產生的鏡像裏,會多出來一個空的/test 目錄。畢竟,新建目錄操做,又不是掛載操做,Mount Namespace 對它可起不到「障眼法」的做用。
結合以上的講解,咱們如今來親自驗證一下: 首先,啓動一個 helloworld 容器,給它聲明一個 Volume,掛載在容器裏的 /test 目錄上:
$ docker run -d -v /test helloworld cf53b766fa6f
容器啓動以後,咱們來查看一下這個+Volume+的+ID:
$ docker volume ls DRIVER VOLUME NAME local cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d
而後,使用這個+ID,能夠找到它在 Docker 工做目錄下的+volumes+路徑:
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
這個 _data 文件夾,就是這個容器的 Volume 在宿主機上對應的臨時目錄了。 接下來,咱們在容器的 Volume 裏,添加一個文件 text.txt:
$ docker exec -it cf53b766fa6f /bin/sh cd test/ touch text.txt
這時,咱們再回到宿主機,就會發現+text.txt+已經出如今了宿主機上對應的臨時目錄裏:
$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/ text.txt
但是,若是你在宿主機上查看該容器的可讀寫層,雖然能夠看到這個/test 目錄,但其內容是空的
$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test
能夠確認,容器 Volume 裏的信息,並不會被 docker commit 提交掉;但這個掛載點目錄 /test 自己,則會出如今新的鏡像當中。 以上內容,就是 Docker Volume 核心原理了。
更重要的是,我着重介紹瞭如何使用 Linux Namespace、Cgroups,以及 rootfs 的知識,對容器進行了一次庖丁解牛似的解讀。 藉助這種思考問題的方法,最後的 Docker 容器,咱們實際上就能夠用下面這個「全景圖」描述出來:
這個容器進程「python app.py」,運行在由 Linux Namespace 和 Cgroups 構成的隔離環境裏;而它運行所須要的各類文件,好比 python,app.py,以及整個操做系統文件,則由多個聯合掛載在一塊兒的 rootfs 層提供。 這些 rootfs 層的最下層,是來自 Docker 鏡像的只讀層。
在只讀層之上,是 Docker 本身添加的 Init 層,用來存放被臨時修改過的 /etc/hosts 等文件。 而 rootfs 的最上層是一個可讀寫層,它以 Copy-on-Write 的方式存聽任何對只讀層的修改,容器聲明的 Volume 的掛載點,也出如今這一層。 經過這樣的剖析,對於曾經「神祕莫測」的容器技術,你是否是感受清晰了不少呢?