Docker 和虛擬機
容器內(nèi)的進程是直接運行于宿主內(nèi)核的,這點和宿主進程一致,只是容器的 userland 不同,容器的 userland 由容器鏡像提供,也就是說鏡像提供了 rootfs。
假設宿主是 Ubuntu,容器是 CentOS。CentOS 容器中的進程會直接向 Ubuntu 宿主內(nèi)核發(fā)送 syscall,而不會直接或間接的使用任何 Ubuntu 的 userland 的庫。
這點和虛擬機有本質(zhì)的不同,虛擬機是虛擬環(huán)境,在現(xiàn)有系統(tǒng)上虛擬一套物理設備,然后在虛擬環(huán)境內(nèi)運行一個虛擬環(huán)境的操作系統(tǒng)內(nèi)核,在內(nèi)核之上再跑完整系統(tǒng),并在里面調(diào)用進程。
還以上面的例子去考慮,虛擬機中,CentOS 的進程發(fā)送 syscall 內(nèi)核調(diào)用,該請求會被虛擬機內(nèi)的 CentOS 的內(nèi)核接到,然后 CentOS 內(nèi)核訪問虛擬硬件時,由虛擬機的服務軟件截獲,并使用宿主系統(tǒng),也就是 Ubuntu 的內(nèi)核及 userland 的庫去執(zhí)行。
而且,Linux 和 Windows 在這點上非常不同。Linux 的進程是直接發(fā) syscall 的,而 Windows 則把 syscall 隱藏于一層層的 DLL 服務之后,因此 Windows 的任何一個進程如果要執(zhí)行,不僅僅需要 Windows 內(nèi)核,還需要一群服務來支撐,所以如果 Windows 要實現(xiàn)類似的機制,容器內(nèi)將不會像 Linux 這樣輕量級,而是非常臃腫??匆幌挛④浺浦驳?Docker 就非常清楚了。
所以不要把 Docker 和虛擬機弄混,Docker 容器只是一個進程而已,只不過利用鏡像提供的 rootfs 提供了調(diào)用所需的 userland 庫支持,使得進程可以在受控環(huán)境下運行而已,它并沒有虛擬出一個機器出來。
CentOS 7 配置加速器(或其它使用 Systemd 的系統(tǒng))
Ubuntu 16.04 和 CentOS 7 這類系統(tǒng)都已經(jīng)開始使用 systemd 進行系統(tǒng)初始化管理了,對于使用 systemd 的系統(tǒng),應該通過編輯服務配置文件 docker.service 來進行加速器的配置。
在啟用服務后
$ sudo systemctl enable docker
可以直接編輯 /etc/systemd/system/multi-user.target.wants/docker.service 文件來進行配置。
sudo vi /etc/systemd/system/multi-user.target.wants/docker.service
在文件中找到 ExecStart= 這一行,并且在其行尾添加上所需的配置。假設我們的加速器地址為 https://registry.docker-cn.com,那么可以這樣配置:
ExecStart=/usr/bin/dockerd --registry-mirror=https://registry.docker-cn.com
注: Docker 1.12 之前的版本,
dockerd應該換為docker daemon,更早的版本則是docker -d。
保存退出后,重新加載配置并啟動服務:
sudo systemctl daemon-reload
sudo systemctl restart docker
確認一下配置是否已經(jīng)生效:
sudo ps -ef | grep dockerd
如果配置成功,生效后就會在這里看到自己所配置的加速器。
在 1.13 版本以后,可以直接 docker info 查看,如果配置成功,加速器 Registry Mirror 會在最下面列出來。
如果重啟后發(fā)現(xiàn)無法啟動 docker 服務,檢查一下服務日志,看看是不是之前執(zhí)行過那些加速器網(wǎng)站的腳本,如果有做過類似的事情,檢查一下是不是被建立了 /etc/docker/daemon.json 以配置加速器,如果是的話,刪掉這個文件,然后在重啟服務。
使用配置文件是件好事,比如修改配置不必重啟服務,只需發(fā)送 SIGHUP 信號即可。但需要注意,目前在 dockerd 中使用配置文件時,無法輸出當前生效配置,并且當 dockerd 的參數(shù)和 daemon.json 文件中的配置有所重復時,并不是一個優(yōu)先級覆蓋另一個,而是會直接導致引擎啟動失敗。很多人發(fā)現(xiàn)配了加速器后 Docker 啟動不起來了就是這個原因。解決辦法很簡單,去掉重復項。不過在這些問題解決前,建議使用修改 docker.service 這類做法來實現(xiàn)配置,而不是使用配置文件 daemon.json。方便 ps -ef | grep dockerd 一眼看到實際配置情況。
關于permission denied 沒權(quán)限
在 Linux 環(huán)境下,一些新裝了 docker 的用戶,特別是使用了 sudo 命令安裝好了 Docker 后,發(fā)現(xiàn)當前用戶一執(zhí)行 docker 命令,就會報沒權(quán)限的錯誤:
dial unix /var/run/docker.sock: permission denied
官方安裝文檔:只需要將操作 docker 的用戶,加入 docker 組,那么該用戶既擁有了操作 docker 的權(quán)限。
因此,只需要執(zhí)行:
sudo usermod -aG docker $USER
就可以把當前用戶加入 docker 組,退出、重新登錄系統(tǒng)后,執(zhí)行 docker info 看一下,就會發(fā)現(xiàn)可以不用 sudo 直接執(zhí)行 docker 命令了。
如果需要添加別的用戶,將其中的 $USER 換成對應的用戶名即可。
Dockerfile中的EXPOSE 和 docker run -p
Docker中有兩個概念,一個叫做 EXPOSE ,一個叫做 PUBLISH 。
-
EXPOSE是鏡像/容器聲明要暴露該端口,可以供其他容器使用。這種聲明,在沒有設定--icc=false的時候,實際上只是一種標注,并不強制。也就是說,沒有聲明EXPOSE的端口,其它容器也可以訪問。但是當強制--icc=false的時候,那么只有EXPOSE的端口,其它容器才可以訪問。 -
PUBLISH則是通過映射宿主端口,將容器的端口公開于外界,也就是說宿主之外的機器,可以通過訪問宿主IP及對應的該映射端口,訪問到容器對應端口,從而使用容器服務。
EXPOSE 的端口可以不 PUBLISH,這樣只有容器間可以訪問,宿主之外無法訪問。而 PUBLISH 的端口,可以不事先 EXPOSE,換句話說 PUBLISH 等于同時隱式定義了該端口要 EXPOSE。
docker run 命令中的 -p, -P 參數(shù),以及 docker-compose.yml 中的 ports 部分,實際上均是指 PUBLISH。
小寫 -p 是端口映射,格式為 [宿主IP:]<宿主端口>:<容器端口>,其中宿主端口和容器端口,既可以是一個數(shù)字,也可以是一個范圍,比如:1000-2000:1000-2000。對于多宿主的機器,可以指定宿主IP,不指定宿主IP時,守護所有接口。
大寫 -P 則是自動映射,將所有定義 EXPOSE 的端口,隨機映射到宿主的某個端口。
如何讓一個容器連接兩個網(wǎng)絡?
如果是使用 docker run,那很不幸,一次只可以連接一個網(wǎng)絡,因為 docker run 的 --network 參數(shù)只可以出現(xiàn)一次(如果出現(xiàn)多次,最后的會覆蓋之前的)。不過容器運行后,可以用命令 docker network connect 連接多個網(wǎng)絡。
假設我們創(chuàng)建了兩個網(wǎng)絡:
$ docker network create mynet1
$ docker network create mynet2
然后,我們運行容器,并連接這兩個網(wǎng)絡。
$ docker run -d --name web --network mynet1 nginx
$ docker network connect mynet2 web
但是如果使用 docker-compose 那就沒這個問題了。因為實際上,Docker Remote API 是支持一次性指定多個網(wǎng)絡的,但是估計是命令行上不方便,所以 docker run 限定為只可以一次連一個。docker-compose 直接就可以將服務的容器連入多個網(wǎng)絡,沒有問題。
version: '2'
services:
web:
image: nginx
networks:
- mynet1
- mynet2
networks:
mynet1:
mynet2:
Docker 多宿主網(wǎng)絡怎么配置?
Docker 跨節(jié)點容器網(wǎng)絡互聯(lián),最通用的是使用 overlay 網(wǎng)絡。
使用 Swarm -- Docker Swarm Mode,非常簡單,只要 docker swarm init 建立集群,其它節(jié)點 docker swarm join 加入集群后,集群內(nèi)的服務就自動建立了 overlay 網(wǎng)絡互聯(lián)能力。
需要注意的是,如果是多網(wǎng)卡環(huán)境,無論是 docker swarm init 還是 docker swarm join,都不要忘記使用參數(shù) --advertise-addr 指定宣告地址,否則自動選擇的地址很可能不是你期望的,從而導致集群互聯(lián)失敗。格式為 --advertise-addr <地址>:<端口>,地址可以是 IP 地址,也可以是網(wǎng)卡接口,比如 eth0。端口默認為 2377,如果不改動可以忽略。
此外,這是供服務使用的 overlay,因此所有 docker service create 的服務容器可以使用該網(wǎng)絡,而 docker run 不可以使用該網(wǎng)絡,除非明確該網(wǎng)絡為 --attachable。
雖然默認使用的是 overlay 網(wǎng)絡,但這并不是唯一的多宿主互聯(lián)方案。Docker 內(nèi)置了一些其它的互聯(lián)方案,比如效率比較高的 macvlan。如果在局域網(wǎng)絡環(huán)境下,對 overlay 的額外開銷不滿意,那么可以考慮 macvlan 以及 ipvlan,這是比較好的方案。
https://docs.docker.com/engine/userguide/networking/get-started-macvlan/
此外,還有很多第三方的網(wǎng)絡可以用來進行跨宿主互聯(lián),可以訪問官網(wǎng)對應文檔進一步查看:https://docs.docker.com/engine/extend/legacy_plugins/#/network-plugins
容器無狀態(tài)
容器存儲層的無狀態(tài)
服務層面的無狀態(tài)
容器存儲層的無狀態(tài)
這里提到的存儲層是指用于存儲鏡像、容器各個層的存儲,一般是 Union FS,如 AUFS,或者是使用塊設備的一些機制(如 snapshot )進行模擬,如 devicemapper。
Union FS 這類存儲系統(tǒng),相當于是在現(xiàn)有存儲上,再加一層或多層存儲,這類存儲的讀寫性能并不好。并且對于 CentOS 這類只能使用 devicemapper 的系統(tǒng)而言,存儲層的讀寫還經(jīng)常出 bug。因此,在 Docker 使用過程中,要避免存儲層的讀寫。頻繁讀寫的部分,應該使用卷。需要持久化的部分,可以使用命名卷進行持久化。由于命名卷的生存周期和容器不同,容器消亡重建,卷不會跟隨消亡。所以容器可以隨便刪了重新run,而其掛載的卷則會保持之前的數(shù)據(jù)。
服務層面的無狀態(tài)
使用卷持久化容器狀態(tài),雖然從存儲層的角度看,是無狀態(tài)的,但是從服務層面看,這個服務是有狀態(tài)的。
從服務層面上說,也存在無狀態(tài)服務。就是說服務本身不需要寫入任何文件。比如前端 nginx,它不需要寫入任何文件(日志走Docker日志驅(qū)動),中間的 php, node.js 等服務,可能也不需要本地存儲,它們所需的數(shù)據(jù)都在 redis, mysql, mongodb 中了。這類服務,由于不需要卷,也不發(fā)生本地寫操作,刪除、重啟、不保存自身狀態(tài),并不影響服務運行,它們都是無狀態(tài)服務。這類服務由于不需要狀態(tài)遷移,不需要分布式存儲,因此它們的集群調(diào)度更方便。
之前沒有 docker volume 的時候,有些人說 Docker 只可以支持無狀態(tài)服務,原因就是只看到了存儲層需求無狀態(tài),而沒有 docker volume 的持久化解決方案。
現(xiàn)在這個說法已經(jīng)不成立,服務可以有狀態(tài),狀態(tài)持久化用 docker volume。
當服務可以有狀態(tài)后,如果使用默認的 local 卷驅(qū)動,并且使用本地存儲進行狀態(tài)持久化的情況,單機服務、容器的再調(diào)度運行沒有問題。但是顧名思義,使用本地存儲的卷,只可以為當前主機提供持久化的存儲,而無法跨主機。
但這只是使用默認的 local 驅(qū)動,并且使用 本地存儲 而已。使用分布式/共享存儲就可以解決跨主機的問題。docker volume 自然支持很多分布式存儲的驅(qū)動,比如 flocker、glusterfs、ceph、ipfs 等等。常用的插件列表可以參考官方文檔:https://docs.docker.com/engine/extend/legacy_plugins/#/volume-plugins
在鏡像的 Dockerfile 制作中,加入初始化部分
官方鏡像 mysql 中可以使用 Dockerfile 來添加初始化腳本,并且會在運行時判斷是否為第一次運行,如果確實需要初始化,則執(zhí)行定制的初始化腳本。
假設我們使用這種方法將 hello.txt 在初始化的時候加入到 mydata 卷中去。
首先我們需要寫一個進入點的腳本,用以確保在容器執(zhí)行的時候都會運行,而這個腳本將判斷是否需要數(shù)據(jù)初始化,并且進行初始化操作。
#!/bin/bash
# entrypoint.sh
if [ ! -f "/data/hello.txt" ]; then
cp /source/hello.txt /data/
fi
exec "$@"
名為 entrypoint.sh 的這個腳本很簡單,判斷一下 /data/hello.txt 是否存在,如果不存在就需要初始化。初始化行為也很簡單,將實現(xiàn)準備好的 /source/hello.txt 復制到 /data/ 目錄中去,以完成初始化。程序的最后,將執(zhí)行送入的命令。
我們可以這樣寫 Dockerfile:
FROM nginx
COPY hello.txt /source/
COPY entrypoint.sh /
VOLUME /data
ENTRYPOINT ["/entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
當我們構(gòu)建鏡像、啟動容器后,就會發(fā)現(xiàn) /data 目錄下已經(jīng)存在了 hello.txt 文件了,初始化成功了。
關于Docker 容器里運行數(shù)據(jù)庫
Docker Volume 可以解決持久化問題,從本地目錄綁定、受控存儲空間、塊設備、網(wǎng)絡存儲到分布式存儲,Docker Volume 都支持
Docker 不是虛擬機,使用數(shù)據(jù)卷是直接向宿主寫入文件,不存在性能損耗。而且卷的生存周期獨立于容器,容器消亡卷不消亡,重新運行容器可以掛載指定命名卷,數(shù)據(jù)依然存在,也不存在無法持久化的問題。
Dockerfile 與鏡像
docker commit
Docker 提供了很好的 Dockerfile 的機制來幫助定制鏡像,可以直接使用 Shell 命令,非常方便。而且,這樣制作的鏡像更加透明,也容易維護,在基礎鏡像升級后,可以簡單地重新構(gòu)建一下,就可以繼承基礎鏡像的安全維護操作。
使用 docker commit 制作的鏡像被稱為黑箱鏡像,換句話說,就是里面進行的是黑箱操作,除本人外無人知曉。即使這個制作鏡像的人,過一段時間后也不會完整的記起里面的操作。那么當有些東西需要改變時,或者因基礎鏡像更新而需要重新制作鏡像時,會讓一切變得異常困難,就如同重新安裝調(diào)試配置服務器一樣,失去了 Docker 的優(yōu)勢了。
使用
commit的場合是一些特殊環(huán)境,比如入侵后保存現(xiàn)場等等,這個命令不應該成為定制鏡像的標準做法。
shell 腳本
Dockerfile不等于.sh腳本
Dockerfile 確實是描述如何構(gòu)建鏡像的,其中也提供了 RUN 這樣的命令,可以運行 shell 命令。但是和普通 shell 腳本還有很大的不同。
Dockerfile 描述的實際上是鏡像的每一層要如何構(gòu)建,所以每一個RUN是一個獨立的一層。所以一定要理解“分層存儲”的概念。上一層的東西不會被物理刪除,而是會保留給下一層,下一層中可以指定刪除這部分內(nèi)容,但實際上只是這一層做的某個標記,說這個路徑的東西刪了。但實際上并不會去修改上一層的東西。每一層都是靜態(tài)的,這也是容器本身的 immutable 特性,要保持自身的靜態(tài)特性。
Dockerfile 確的寫法應該是把同一個任務的命令放到一個 RUN 下,多條命令應該用 && 連接,并且在最后要打掃干凈所使用的環(huán)境。比如下面這段摘自官方 redis 鏡像 Dockerfile 的部分:
RUN buildDeps='gcc libc6-dev make' \
&& set -x \
&& apt-get update && apt-get install -y $buildDeps --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& wget -O redis.tar.gz "$REDIS_DOWNLOAD_URL" \
&& echo "$REDIS_DOWNLOAD_SHA1 *redis.tar.gz" | sha1sum -c - \
&& mkdir -p /usr/src/redis \
&& tar -xzf redis.tar.gz -C /usr/src/redis --strip-components=1 \
&& rm redis.tar.gz \
&& make -C /usr/src/redis \
&& make -C /usr/src/redis install \
&& rm -r /usr/src/redis \
&& apt-get purge -y --auto-remove $buildDeps
context
context,上下文,是 docker build 中很重要的一個概念。構(gòu)建鏡像必須指定 context:
docker build -t xxx <context路徑>
或者 docker-compose.yml 中的
app:
build:
context: <context路徑>
dockerfile: dockerfile
這里都需要指定 context。
context 是工作目錄,但不要和構(gòu)建鏡像的Dockerfile 中的 WORKDIR 弄混,context 是 docker build 命令的工作目錄。
docker build 命令實際上是客戶端,真正構(gòu)建鏡像并非由該命令直接完成。docker build 命令將 context 的目錄上傳給 Docker 引擎,由它負責制作鏡像。
在 Dockerfile 中如果寫 COPY ./package.json /app/ 這種命令,實際的意思并不是指執(zhí)行 docker build 所在的目錄下的 package.json,也不是指 Dockerfile 所在目錄下的 package.json,而是指 context 目錄下的 package.json。
這就是為什么有人發(fā)現(xiàn) COPY ../package.json /app 或者 COPY /opt/xxxx /app 無法工作的原因,因為它們都在 context 之外,如果真正需要,應該將它們復制到 context 目錄下再操作。
docker build -t xxx . 中的這個.,實際上就是在指定 Context 的目錄,而并非是指定 Dockerfile 所在目錄。
默認情況下,如果不額外指定 Dockerfile 的話,會將 Context 下的名為 Dockerfile 的文件作為 Dockerfile。所以很多人會混淆,認為這個 . 是在說 Dockerfile 的位置,其實不然。
一般項目中,Dockerfile 可能被放置于兩個位置。
- 一個可能是放置于項目頂級目錄,這樣的好處是在頂級目錄構(gòu)建時,項目所有內(nèi)容都在上下文內(nèi),方便構(gòu)建;
- 另一個做法是,將所有 Docker 相關的內(nèi)容集中于某個目錄,比如
docker目錄,里面包含所有不同分支的Dockerfile,以及docker-compose.yml類的文件、entrypoint 的腳本等等。這種情況的上下文所在目錄不再是Dockerfile所在目錄了,因此需要注意指定上下文的位置。
此外,項目中可能會包含一些構(gòu)建不需要的文件,這些文件不應該被發(fā)送給 dockerd 引擎,但是它們處于上下文目錄下,這種情況,我們需要使用 .dockerignore 文件來過濾不必要的內(nèi)容。.dockerignore 文件應該放置于上下文頂級目錄下,內(nèi)容格式和 .gitignore 一樣。
tmp
db
這樣就過濾了 tmp 和 db 目錄,它們不會被作為上下文的一部分發(fā)給 dockerd 引擎。
如果你發(fā)現(xiàn)你的
docker build需要發(fā)送龐大的 Context 的時候,就需要來檢查是不是.dockerignore忘了撰寫,或者忘了過濾某些東西了。
ENTRYPOINT 和 CMD 的不同
Dockerfile 的目的是制作鏡像,換句話說,實際上是準備的是主進程運行環(huán)境。那么準備好后,需要執(zhí)行一個程序才可以啟動主進程,而啟動的辦法就是調(diào)用 ENTRYPOINT,并且把 CMD 作為參數(shù)傳進去運行。也就是下面的概念:
ENTRYPOINT "CMD"
假設有個 myubuntu 鏡像 ENTRYPOINT 是 sh -c,而我們 docker run -it myubuntu uname -a。那么 uname -a 就是運行時指定的 CMD,那么 Docker 實際運行的就是結(jié)合起來的結(jié)果:
sh -c "uname -a"
- 如果沒有指定
ENTRYPOINT,那么就只執(zhí)行CMD; - 如果指定了
ENTRYPOINT而沒有指定CMD,自然執(zhí)行ENTRYPOINT; - 如果
ENTRYPOINT和CMD都指定了,那么就如同上面所述,執(zhí)行ENTRYPOINT "CMD"; - 如果沒有指定
ENTRYPOINT,而CMD用的是上述那種 shell 命令的形式,則自動使用sh -c作為ENTRYPOINT。
注意最后一點的區(qū)別,這個區(qū)別導致了同樣的命令放到 CMD 和 ENTRYPOINT 下效果不同,因此有可能放在 ENTRYPOINT 下的同樣的命令,由于需要 tty 而運行時忘記了給(比如忘記了docker-compose.yml 的 tty:true)導致運行失敗。
這種用法可以很靈活,比如我們做個 git 鏡像,可以把 git 命令指定為 ENTRYPOINT,這樣我們在 docker run 的時候,直接跟子命令即可。比如 docker run git log 就是顯示日志。