Docker Study Note

Docker Container 基礎入門篇 1

猴子也會的 Docker,但猴子並不想會。

Azole (小賴)

--

Docker 到今天,應該是很多人都會的基本技術了,原理可能不一定非常了解,但基本的操作應該或多或少都知道點,相關的文章也非常多,很猶豫要不要寫這一篇,其實我一開始是想要筆記 Amazon ECS 的,不過為了更好地學習 ECS,還是決定從 Docker container 開始複習起。

由於我自己的學習方式,通常是從實作開始,只有透過自己動手做,才能對一項技術更有感覺,所以會先從基本操作開始慢慢延伸到深入一點的討論,自己複習一下,希望也能給別人帶來幫助。

圖 1: Docker 當年的宣傳

Docker 安裝

其他 OS 的安裝在 Docker 官網都能找到,就不佔篇幅了,這個自行處理,如果有問題可以到社群或留言討論。

這邊特別提醒一下,本系列文的範例都是在 Ubuntu 上完成的,本文已更新至:

# 20230828 更新本文實驗
$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 22.04.2 LTS
Release: 22.04
Codename: jammy

安裝完成之後,如果想要讓非 root user 也能用 docker 命令,也就是不用在每次下 docker 指令前都加 sudo 的話,記得執行下列指令,可以將你目前的使用者加入 docker 群組中,已獲得執行的權限:

# your-user 記得換成自己的使用者名稱
# 執行完成後,登出該使用者,再重新登入一次讓命令生效
$ sudo usermod -aG docker your-user

安裝完成後,如果要檢查安裝有沒有成功,可以用以下這個指令檢查看看版本:

# 20230828 更新本文實驗
$ docker --version
Docker version 24.0.5, build ced0996

或是執行以下兩個可以取得 docker 環境資訊的命令來測試:

# Show the Docker version information
$ docker version
# Display system-wide information
$ docker info

Docker 基本組成

圖 2: docker 基本組成與指令(來源:https://www.slideshare.net/TrisM/docker-41045742 (page 22))

上面這張圖算是統整了 Docker 的重要組成與指令了,我們先來看看 Docker 中重要的三個最基本的東西:

  • image: 這是一個*唯讀*的模板,可用來建立 container,如果有學過物件導向語言,可以想成 image 就是 class、container 是 object 或是 instance。
  • container: 這就是我們的主角,是一個獨立、隔離的空間,裡面包括了我們執行一個應用程式所需要的元件。
  • Docker registry: 這其實就很像 github,裡面存放了很多 images,公開的像是 Docker Hub,你也可以建立自己私人的 registry。

我相信這樣說完之後,沒概念的還是沒概念,就讓我們直接啟動一個容器來看看吧!

Docker 指令: 啟動容器

在正確安裝 docker 後,執行此指令:

$ docker container run -it node:20 /bin/bash

應該會看到以下的畫面:

圖 3: 啟動一個 node:20 的 container
  • docker container run :啟動一個新的 container,相關的選項還不少,這次會就幾個常用或重要的來練習。
  • -i :--interactive 啟動互動模式,保持標準輸入的開放。
  • -t : --tty讓 Docker 分配一個虛擬終端機(pseudo-TTY),並且綁定到容器的標準輸出上。
  • node:20 : 啟動這個 container 所依據的 image。
  • /bin/bash :容器啟動後要執行的命令。

簡單地說,以上指令就是我們「從 image node:20 啟動了一個 container,並且開啟了它的輸入/輸出,然後請它執行 /bin/bash 這個命令」。

圖 4: Image 跟 container 關係

在執行完這行指令後,你可以看到畫面停在 root@4c9bee8fef4b:/# ,要注意的是,這時候我們已經不在我們原本的環境中了,而是「進入」了 container 中,可以在這裡執行 node -v指令,會發現這個環境中已經安裝了 node,且版本是 20.5.1。

這時候我們在 container 中執行 ps aux 會看到它 PID 為 1 的 process 就是我們剛剛指定的 /bin/bash ,這個非常的重要,但我們要放到後面再討論了。

圖 5: 在 container 中觀察 processes 的狀態

這裡讓我們先做個小測試:

  1. 在剛剛建立的容器中,建立一個檔案,例如 touch AAA.txt ,建議完成後用 ls 確認一下。
  2. 開啟另外一個命令視窗,同樣再執行一次 docker container run -it node:20 /bin/bash ,然後一樣進入了一個容器,執行 ls 看看,應該會看不到 AAA.txt 這個檔案才是。
圖 6: 第一個 container 有 AAA.txt 檔案
圖 7: 用同一個 image 啟動的 container 中沒有 AAA.txt 檔案

這裡很重要的就是我們提到的 container 是一個獨立的、隔離的環境,當你啟動了兩個容器時,即便是從同一個 image 啟動起來,Docker 是會幫你做出兩個不同的容器的:

圖 8: Image 與 containers 之間的關係

這時候再開啟一個新的命令視窗,然後執行以下指令:

$ docker container ls

這個指令是用來列出目前有哪些 container 在運行中,執行後應該可以看到:

圖 9: 列出目前執行中的 containers

這裡看到目前在環境中已經啟動了兩個容器,而你的 CONTAINER ID 與 NAMES 應該會跟上面圖片的不同,值得注意的是,這個容器 ID 其實會出現在容器的虛擬終端機中:

# 第一個 container 的提示命令
root@4c9bee8fef4b:/#

# 第二個 container 的提示命令
root@1d41325da509:/#

Docker Image

在更近一步討論容器的其他操作之前,我們先來想一下,node:20 這個 image 是哪裡來得呢?

不知道你有沒有注意到,我們剛剛啟動了兩個容器的執行過程其實不太一樣:

啟動第一個容器的畫面:

圖 10: 啟動第一個 container 的畫面

啟動第二個容器的畫面:

圖 11: 啟動第二個 container 的畫面

第一個容器啟動時會多了很多東西,第一行是Unable to find image 'node:20' locally ,這句話的意思是在「本地找不到這個 image」,所以接下來就會開始 pull from library/node ,也就是去 registry 拉取我們所指定的 image,沒有指定的話,通常就是去 Docker Hub 拉,例如 https://hub.docker.com/_/node,從這個頁面我們可以發現 Docker Hub 提供了不同版本的 node 環境,而我們剛剛用的是 20 這個版本。

而當我們啟動第二個容器的時候,因為本地已經有了 node:20 這個 image,所以就可以直接啟動,而不用再去拉取 image 了。

Docker Hub 上有各式各用的 image,非常地好用,例如你想要跑一個 MySQL,但不想裝在自己的電腦上,這時候你就可以從 Docker Hub 上拉取一個 MySQL,還可以隨時換不同的版本。不過這邊會建議盡量用官方提供的 image 比較好,畢竟你不知道別人提供的 image 裡放了什麼東西…

在啟動容器之前,也可以先用指令把 image 拉取好,例如 $ docker image pull node:18,這就是要從 Docker Hub 上拉下一個 tag 為 18 的 node image 到本機來。

這邊補充一下 tag,docker image 的名稱中 : 前面的是名稱,後面的是 tag(image_name:tag_name),tag 通常用來表現一些特殊的資訊,例如版本,像 node:20 ,就是版本為 20 的 node image。當然,既然叫 tag,不是叫 version 什麼的,就表示它不一定是要用來標註版本,基本上就是留一個欄位讓你來標注一些資訊,而且這是非必要的、可以不用放,例如 docker image pull node ,當沒有放 tag 時,它會自動幫你拉 node:latest 這個 image,也就是說, latest 是預設的 tag。

圖 12: 從 DockerHub 上 pull node:18 這個 image 到主機中

如果想要看看自己的環境中目前有哪些 image,可以用以下指令:

$ docker image ls
圖 13: 列出目前有哪些 images

這邊可以看到,兩個 image 的大小分別都有 1 G 多,雖然這不是這兩個 images 真正佔用的硬碟空間,但如果你使用的 images 都比較沒有關聯,那日積月累下來,還是很佔硬碟空間的。所以沒事不要亂拉,或是要定期清理,才不會佔用太多硬碟空間喔,之後再來分享怎麼清理。(雖然我們後面會討論到 image layer 共用的部分,但積少成多,在主機中放了一堆 images,硬碟還是會被撐爆的…)

Image Layer

剛剛在說到 image 時,我們說 image 是一個「唯讀」的模板,這是什麼意思呢?還記得我們剛剛做的測試嗎?在第一個 container 中新增了一個 AAA.txt,但後來啟動的第二個 container 中並沒有看到這個檔案。

這是因當我們從 image 啟動一個 container 時候,Docker 會載入這個 image 作為唯讀層,並且在上面加上一個可寫層,而我們在這個 container 中的操作實際上就是發生在這個可寫層中:

圖 14: Image 與 container 的關係

不管你在這個可寫層中做了多少事情,例如在這個 container 中加入一個新的檔案,這個檔案是存在這個獨立的可寫層中的,當你下次又從 node:20 這個 image 來建立另外一個新的 container 時,仍會用原本藍色這個區塊(原本的 node:20)來建構這個 container,並且在上面加上另外一個可寫層,做出另外一個 container。

圖 15: Image 與 container 的關係

那總不能每次都是一個新的環境,而無法保留我們之前做過的事對吧,因此 Docker 提供的一個指令,讓我們可以把我們的容器做成一個新的 image:

docker container commit CONTAINER_ID [Repository:[Tag]]

# 在本文的範例中,執行指令如下,記得把 container id 換成你自己的:
$ docker container commit 4c9bee8fef4b node:20-updated

當執行完這個指令後,來檢視一下目前本機中有的 images:

圖 16: 列出目前有哪些 images

多了一個 node:20-updated ,用這個 image 來啟動一個 container 看看:

圖 17: 由 node:20-updated 建立 container

用這個新的 image 建構出來的 container 會有 AAA.txt 這個檔案,這是因為我們用 node:20-updated 這個我們 commit 出來的新 image,會在原有的 node:20 上面再加上一層(layer),並且把我們剛剛對那個 container 做的事給封裝起來,變成一個新的 image,當我們用這個新的 image 來建構新的 container 時,就會在其上再加上一層可寫層來讓我們操作:

圖 18: Image Layers

Docker 很聰明的是,雖然我們在電腦中目前有 node:20node:20-updated 兩個不同的 image,不過 Docker 並不會重複存兩個 node:20 ,也就是重複的部分 Docker 只會存一個,這樣就大大的節省了空間,此外也可以理解為什麼要作成唯讀了,唯讀能做到共用而不互相影響。(關於這部分,在後面更進階的討論中,再來聊實際上的實作,這邊就先這樣簡單地認識一下。)

當然,原本我們從 Docker Hub 上拉下來的 node:20 也不會只有單獨一層,所以當我們在 pull 這個 image 時,會看到的畫面是像下面這樣:

圖 19: image node:20 拉取的過程

按照這個過程,node:20 至少是由 8 個 layer 構成的。還記得我們 pull node:18 的過程嗎:

圖 20: 從 DockerHub 上 pull node:18 這個 image 到主機中

這裡像是 de4cac68b616 顯示的是 Already exists ,這是因為我們在拉 node:20 時,已經拉過了,因此在拉 node:18 時,已經存在的就不用再重拉一次,這也再次驗證了在 Docker 中,layer 是共用的、不會多佔空間的。

這邊有個不太常用的指令,但在熟悉 Docker 時可以玩玩看:

$ docker image history node:20
圖 21: 查看 node:20 image 的歷史

我們也來查看一下 node:20-updated 這個 image 並且比較看看:

圖 22: 查看 node:20-updated image 的歷史

這邊可以看到 node:20-updated 比起 node:20 會多了一個 78c69cf95de8 ,這個就是我們剛剛啟動第一個 container 時做出來的一層,而這之下,就跟 node:20 一模一樣了。

最後,這個 commit 出來的新 image,你也可以把它推送(push)到 Docker Hub 或是其他的 image repository 上去分享給別人。不過,在實務上,我們很少直接用 commit 這個指令,通常都會透過 Dockerfile 來做新的 image,這個就留到下次討論了。

其他 Docker 生命週期指令

延續剛剛的測試,我們在 node:20-updated 做出來的 container 中執行 exit 離開這個 container,這時候如果執行 docker container ls會發現,只剩下兩個 container,而剛剛離開的那個 container 已經看不到了:

圖 23: 列出 containers

但如果加上 -a ,就可以檢視所有的 container:

圖 24: 列出所有的 containers

這是因為我們剛剛在啟動 container 時,要它執行的命令是 /bin/bash (且 PID 為 1),而當我們下了 exit 指令時,是在離開這個 bash,也就是離開了 PID 為 1 的這個 process,既然主要的 process 已經停止執行了,這個 container 自然也就關閉了,我們之後再來討論怎麼讓 container 持續執行。從這裡也可以從 Status 這個欄位看到 container 已經離開多久。

這時候如果想要回到這個 container,可以透過 docker container start CONTAINER_ID 來回到這個 container:

圖 25: 重新啟動 422f119c50f6 container

docker container start 只是重新啟動這個 container,執行後我們還是在本機中,如果要進入 container 中,可以透過 exec 來進入 container 中,基本上這個指令常用的參數,例如 -it ,跟 docker container run 的時候差不多,我們就先不討論了。

docker container exec -it CONTAINER_ID /bin/bash

圖 26: 用 exec 進入 container 中

這時候我們一樣再執行 exit,然後再下 docker container ls來檢查,卻會發現這個 container 還是活著的、跟剛剛不一樣,我們透過 docker container exec 再進去一次,這次在裡面執行一下 ps aux

圖 27: 觀察 container 中的 process status

這時候我們可以看到有兩個 /bin/bash 的 process 在運行,我們透過 docker container exec 所執行的 /bin/bash 其實是 PID 為 26 的這一個,所以當我們執行 exit 時,離開的是這一個 bash,而 PID 為 1 的這個 /bin/bash 仍在執行中,所以這個 container 會持續存活。

docker container exec 有一個用起來很像的指令 docker container attach CONTINER_ID

圖 28: 用 attach 回到 container 中

attach 這個指令一樣會進入 container 中,用起來跟剛剛的 docker container exec -it CONTAINER_ID 效果很像,但如果進去後執行 ps aux 會發現只有一個 /bin/bash process。你應該可以猜到,如果這時候執行了 exit ,會離開的是 PID 為 1 的 /bin/bash process,進而離開這個 container。此時再透過 docker container ls或是 docker container ls -a 來確認,會發現這個 container 已經關閉了。所以,雖然 attach 也能進入這個 container 中,但我自己很少用就是了,以免一不小心把 container 給關掉…

移除 image 跟 container

前面有提到,如果電腦中存有太多的 image,會佔硬碟空間,因此建議定期清除用不到的 image,那我們來試試看清除剛剛拉下來的那個 node:18 ,其指令為 docker image rm IMAGE_ID :

圖 29: 移除 node:18 image

這邊可以看到,移除的時候也是逐層移除,不過,我們剛剛 pull node:18 時,明明有 8 層,這邊卻只移除了 4 層,這樣真的有移乾淨嗎?

但透過 docker image ls 來檢視時,的確又已經看不到 node:18 了:

圖 30: 檢視主機中的 images

還記得前面有提到 layer 是共用的嗎?也有提到 node:20node:18 有共用一些 layer,而因為共用的那些 layer 在 node:20 還需要用到,所以在移除 node:18 時,當然就不會移掉。

我們來試著移除剛剛我們做出來的那個 node:20-updated 看看:

圖 31: 移除 node:20-updated image

移除失敗,根據錯誤訊息可以知道是因為這個 image 正在被 container 422f119c50f6 給參考著,透過 docker container ls -a檢查看看:

圖 32: 列出所有的 containers

果然,有一個 id 為 422f119c50f6 的 container 是透過這個 image 來建構的,如果這個 container 還在啟動狀態,那就需要先透過 docker container stop CONTAINER_ID 關閉這個 container,如果已經是關閉狀態,那就可以用 docker container rm CONTAINER_ID 來移除這個 container:

圖 33: 移除 container 後列出所有 containers 確認

這時候就可以用 docker image rm IMAGE_ID 來移除 image:

圖 34: 移除 node:20-updated image

結語

這次紀錄了 docker 的基本元件與操作,主要討論的是 docker container 的執行與一些基本的操作。也許在做過這先練習與測試之後,你心裡會有一大堆疑問,有的話是很棒的事情,但如一開始所說的,我想先筆記一些基本的操作,先會基本的運用,有點感覺後,然後再慢慢深入討論。

下次仍舊會是基礎篇,讓我們來討論怎麼讓 container 間可以彼此溝通,然後再來討論我最喜歡的 Dockerfile ,這可是我個人認為 docker 可以如此成功的重要因素之一。

系列文

指令整理

這邊把本文討論過的指令都整理起來,方便大家複習與查找,另外也會列出舊版的指令對照,推薦使用新版的指令,雖然較長,但具有一致的結構,非常好學!

# 查看 docker 版本
$ docker --version
$ docker version

# 查看 docker 系統資訊
$ docker info

# 從 node:20 image 啟動一個 docker container 並開啟輸出入
$ docker container run -it node:20 /bin/bash
# 舊版指令
# docker run -it node:20 /bin/bash

# 查看目前正在運行中的 container
$ docker container ls
# 舊版指令
# docker ps

# 查看全部的 container,包括已經停止的。 (a -> all)
$ docker container ls -a
# 舊版指令
# docker ps -a

# 從 DockerHub 上拉下版本為 18 的 node image
$ docker image pull node:18
# 舊版指令
# docker pull node:18

# 查看目前環境中的 docker images
$ docker image ls
# 舊版指令
# docker images

# 用 container id 為 4c9bee8fef4b 的 container
# 建立一個叫做 node:20-updated 的 image
$ docker container commit 4c9bee8fef4b node:20-updated
# 舊版指令
# docker commit 4c9bee8fef4b node:20-updated

# 查看 node:20 這個 image 的歷史
$ docker image history node:20
# 舊版指令
# docker history node:20

# 停止 4c9bee8fef4b 這個 contaienr
$ docker container stop 4c9bee8fef4b
# 舊版指令
# docker stop 4c9bee8fef4b

# 啟動 4c9bee8fef4b 這個 container
$ docker container start 4c9bee8fef4b
# 舊版指令
# docker start 4c9bee8fef4b

# 在 4c9bee8fef4b 這個 container 中執行 /bin/bash 這個命令,並且開啟輸出入
# 因為是執行 /bin/bash 又開啟了輸出入,所以就像是「進入」了這個 container 中
$ docker container exec -it 4c9bee8fef4b /bin/bash
# 舊版指令
# docker exec -it 4c9bee8fef4b /bin/bash

# 移除 4c9bee8fef4b 這個 container
$ docker container rm 4c9bee8fef4b
# docker rm 4c9bee8fef4b

# 移除所有停止的 containers
$ docker container prune -f

# 移除 node:18 這個 image
# 移除 image 前要先移除用這個 image 啟動的 containers
$ docker image rm node:18
# 舊版指令
# docker rmi node:18

推薦閱讀

透過參加鐵人賽與完成《Docker 實戰 6 堂課》這本書,自己也學到了很多關於 Linux 的知識,設計了很多實驗去驗證自己的理解與觀察,或是從這些實驗的結果去找尋答案。

當初想要寫這樣的內容,是因為自己對 Docker 很有興趣,但市面上已經有很多很棒的 Docker 教學與實戰分享了,我問我自己,我還想讀些什麼呢?我的答案是,我會想要知道更底層的東西,想要知道這些好用的功能背後是怎麼做到的,我也想要更知道 Linux 一些,於是就給自己出了一個這樣的題目,過程中差點後悔,真的是有超過自己原本的能力,但還好沒有放棄。

所以,這的確不是一本初階的書籍,是我嘗試要讓自己更進階一點點的努力,想要跟大家一起變得再厲害一點點的心意,再請大家多多指教了。

--

--

Azole (小賴)

As a passionate software engineer and dedicated technical instructor, I have a particular fondness for container technologies and AWS.