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