Kubernetes — 從新認識Docker容器

這一次,我要用+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 的掛載點,也出如今這一層。 經過這樣的剖析,對於曾經「神祕莫測」的容器技術,你是否是感受清晰了不少呢?

相關文章
相關標籤/搜索