容器化技術(No.1) -- Docker 基礎

此分享內容是對書籍及其他技術分享能容的總結

Docker 基礎

今天的分享主要有以下幾個目標:

  • 首先最重要的一點, 希望能夠幫助不太瞭解 Docker 的兄弟們, 知道 Docker 到底是什麼東西;
  • 能夠初步理解 Docker 的核心原理, 包括 namespacecgroups;
  • 能夠上手構建和使用 Docker, 並應用於工作中.

什麼是 Docker

提到一個技術, 我們首先能想到的就是傳統的靈魂 3 問:

  • Docker 是什麼?
  • 爲什麼要用 Docker?
  • 怎麼使用 Docker?

什麼是 Docker

官網給出如下定義:

A container is a standard unit of software that packages up code and all its dependencies so the application runs quickly and reliably from one computing environment to another. A Docker container image is a lightweight, standalone, executable package of software that includes everything needed to run an application: code, runtime, system tools, system libraries and settings.

翻譯過來就是:

容器是打包代碼及其所有依賴項的軟件的標準單元, 因此應用程序可以從一個環境快速可靠地運行到另一個環境. Docker容器鏡像是輕巧的, 獨立的, 可執行的軟件包, 其中包含運行應用程序所需的一切:代碼, 運行時, 系統工具, 系統庫和設置.

Docker 相關概念

  • Image: 是一個可執行的包, 包含了應用程序運行的所有內容: 代碼, 運行時, 庫, 環境變量和配置文件
  • Container: 容器是鏡像運行時的實例
  • Daemon: 負責維護 Docker 運行的守護進程, 擔負着資源管理, 任務調度等多項功能
    三者關係: Container 基於 Image 被 Daemon 創建和管理, 來實現提供服務的功能

Docker 特性

Docker containers that run on Docker Engine:
Standard: Docker created the industry standard for containers, so they could be portable anywhere
Lightweight: Containers share the machine’s OS system kernel and therefore do not require an OS per application, driving higher server efficiencies and reducing server and licensing costs
Secure: Applications are safer in containers and Docker provides the strongest default isolation capabilities in the industry

譯:

在Docker Engine上運行的Docker容器:
標準:Docker創建了容器的行業標準, 因此它們可以在任何地方移植
輕巧:容器共享計算機的操作系統系統內核, 因此不需要每個應用程序都使用操作系統, 從而提高了服務器效率, 並降低了服務器和許可成本
安全:容器中的應用程序更安全, Docker提供了業界最強大的默認隔離功能
這些先不糾結他的這些特性, 官方爲什麼這麼說我們姑且先有個印象, 我們瞭解了他的原理及使用, 自然就知道了.

總結

Docker 是什麼這個問題的答案從他解決的問題出發, Docker 的誕生和發展解決的最根本問題就是服務打包的問題. 最後總結一下, 他就是一個便捷的打包方案, 這個概括可能不精確, 但是很直觀的總結了我們能用 Docker 來解決的問題是什麼.

Docker VS Virtual Machines

提到容器, 和虛擬機之間的對比對照是不可避免的. 既然容器包含了程序及其所有依賴項, 那他和虛擬機之間的差異有人能給大家說一下麼?
下面這兩張圖是從官網上摘下來的, 很常見, 網上其他的圖例也都大同小異, 基本上都是這個結構的圖
Docker VS Virtual Machines
大家現對這兩張圖有個印象, 我們接下來講一下 Docker 內核原理, 講完之後應該大家就會理解這兩張圖的含義, 甚至還可以發現這兩張圖中描述欠妥的地方, 我們最後來總結二者的不同.
Docker 能幹什麼(爲什麼使用 Docker) 及如何使用 Docker, 我會在接下來的時間給大家做分享.

Docker 核心原理

Docker 的本質就是宿主機上的一個進程, 通過 namespace 實現了資源隔離, 通過 cgroups 實現了資源限制, 通過 copy-on-write 機制實現了高效的文件操作.
容器技術的核心功能, 就是通過約束和修改進程的動態表現, 從而爲其創造出一個"邊界"
具體是如何基於 namespacecgroups 這兩種技術實現的, 我們接下來詳細分解一下.

namespace

單進程

首先需要明確的是: 容器是"單進程"模型

由於一個容器的本質就是一個進程, 用戶的應用進程實際上就是容器裏 PID=1 的進程, 也是其他後續創建的所有進程的父進程. 這就意味着, 在一個容器中, 你沒辦法同時運行兩個不同的應用, 除非你能事先找到一個公共的 PID=1 的程序來充當兩個不同應用的父進程.
容器本身的設計, 就是希望容器和應用能夠同生命週期, 這個概念對後續的容器編排非常重要. 否則, 一旦出現類似於 容器是正常運行的, 但是裏面的應用早已經掛了 的情況, 編排系統處理起來就非常麻煩了. 這個問題我們之前也經常碰到. 通常我們會寫一個 start.sh 的腳本, 啓動實例的時候執行這個腳本, 但是這樣通常 PID 爲 1 的進程就是 sh, 導致 java 程序已經掛了, 容器還依然正常運行

root 1 0 0 17:29 ? 00:00:00 sh start.sh 
root 6 1 1 17:29 ? 00:02:13 java -jar -Dspring.profiles.active=prod app.jar 

但是爲了使鏡像更靈活, 這也是應對的一種變通方式, 之後也是需要對這種使用方式進行調整和完善的.

介紹

說完容器是單進程模型的, 接下來我們們就來看看容器裏這個 PID 爲 1 的進程到底是設個什麼樣的存在.
Linux namespace 提供了一種內核級別隔離系統資源的方法, 通過將系統的全局資源放在不同的 namespace 中, 來實現資源隔離的目的. 在同一個 namespace 下的進程可以感知彼此的變化, 而對外界的進程一無所知. 這樣就可以讓容器總的進程產生錯覺, 彷彿自己置身於一個獨立的系統環境中, 以達到獨立和隔離的目的. 不同 namespace 的程序, 可以享有一份獨立的系統資源.
目前 Linux 中提供了六類系統資源的隔離機制:

Namespace 系統調用參數 隔離內容
UTS CLONE_NEWUTS 主機名與域名
IPC CLONE_NEWIPC 信號量, 消息隊列和共享內存
PID CLONE_NEWPID 進程編號
Network CLONE_NEWNET 網絡設備, 網絡棧, 端口等等
Mount CLONE_NEWNS 掛載點(文件系統)
User CLONE_NEWUSER 用戶和用戶組

Docker 就是利用這些不同的 namespace 來實現的, 所以說: 一個正在運行的 Docker 容器, 就是一個啓用了多個 Linux namespace 的, 可用資源受 cgroups 配置限制的應用進程

namespace 的 API 包括 clone(), setns() 以及 unshare(), 還有 /proc 下的部分文件(這裏僅做列舉介紹, 具體 Namespace 的使用方式這裏不做重點介紹).

  1. 通過 clone() 創建新進程的同時創建 namespace
  2. 查看 /proc/[pid]/ns 文件
  3. 通過 setns() 加入一個已經存在的 namespace
  4. 通過 unshare() 在原先進程上進行 namespace 隔離
  5. 延伸*: fork()系統調用
    Namespace 技術實際上修改了應用進程看待整個計算機"視圖", 即它的"視線"被操作系統做了限制, 只能"看到"某些指定的內容. 但對於宿主機來說, 這些被"隔離"了的進程跟其他進程並沒有太大區別

實例

我們通過一個容器實例來看看他都創建了哪些 namespace

[root@irvin ~]# docker run -it busybox /bin/sh 
/ # ps 
PID USER TIME COMMAND 
1 root 0:00 /bin/sh 
6 root 0:00 ps 

我們保持容器的啓動狀態, 通過如下指令, 可以看到當前正在運行的 Docker 容器的進程號(PID)是 87453:

[root@irvin ~]# docker inspect --format '{{ .State.Pid }}' 7e7b3939b788 
87453 

可以看到容器內 PID 爲 1 的進場實際上就是宿主機上 PID 爲 87453 的進程, 只是通過執行 namespace 的 clone() Api 調用實現的.
通過調用該接口就會創建 PID namespace, 而每個 namespace 裏的應用進程, 都會認爲自己是當前容器裏的第 1 號進程, 它們既看不到宿主機裏真正的進程空間, 也看不到其他 PID namespace 裏的具體情況
我麼也可以查看一下該容器都創建了哪些 namespace (形如 4026531839 的即爲 namespace 號)

[root@irvin ~]# ls -l /proc/87453/ns 
總用量 0 
lrwxrwxrwx 1 root root 0 1月 2 19:07 ipc -> ipc:[4026531839] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 mnt -> mnt:[4026531840] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 net -> net:[4026531956] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 pid -> pid:[4026531836] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 user -> user:[4026531837] 
lrwxrwxrwx 1 root root 0 1月 2 19:07 uts -> uts:[4026531838] 

可以看到, 一個進程的每種 Linux namespace, 都在它對應的 /proc/[進程號]/ns 下有一個對應的虛擬文件, 並且鏈接到一個真實的 namespace 文件上.
一個進程, 可以選擇加入到某個進程已有的 namespace 當中, 從而達到"進入"這個進程所在容器的目的, 這正是 docker exec 的實現原理. 而這個操作所依賴的, 是一個名叫 setns() 的 Linux 系統調用, 它一共接收兩個參數, 第一個參數是 argv[1], 即當前進程要加入的 namespace 文件的路徑, 比如 /proc/87453/ns/net;而第二個參數, 則是你要在這個 namespace 裏運行的進程, 比如 /bin/bash

總結

Docker 容器概念聽起來很高級, 實際上是在創建容器進程時, 指定了這個進程所需要啓用的一組 namespace 參數. 這樣, 容器就只能"看"到當前 namespace 所限定的資源, 文件, 設備, 狀態, 或者配置. 而對於宿主機以及其他不相關的程序, 它就完全看不到了.
所以說, 容器, 其實是一種特殊的進程而已.

Docker VS Virtual Machines

我們回過頭來看看之前講到的容器與虛擬機的區別:
Docker VS Virtual Machines

  • Hypervisor 的軟件是虛擬機最主要的部分. 它通過硬件虛擬化功能, 模擬出了運行一個操作系統需要的各種硬件, 比如 CPU, 內存, I/O 設備等等. 然後, 它在這些虛擬的硬件上安裝了一個新的操作系統, 即 Guest OS.
  • 跟真實存在的虛擬機不同, 在使用 Docker 的時候, 並沒有一個真正的"Docker 容器"運行在宿主機裏面. Docker 項目幫助用戶啓動的, 還是原來的應用進程, 只不過在創建這些進程時, Docker 爲它們加上了各種各樣的 Namespace 參數.

所以上圖不不嚴謹之處應作如下調整
Docker VS Virtual Machines

並不像 Hypervisor 那樣對應用進程的隔離環境負責, 也不會創建任何實體的"容器", 真正對隔離環境負責的是宿主機操作系統本身.
我們應該把 Docker 畫在跟應用同級別並且靠邊的位置. 這意味着, 用戶運行在容器裏的應用進程, 跟宿主機上的其他進程一樣, 都由宿主機操作系統統一管理, 只不過這些被隔離的進程擁有額外設置過的 namespace 參數. 而 Docker 項目在這裏扮演的角色, 更多的是旁路式的輔助和管理工作
使用虛擬化技術作爲應用沙盒, 就必須要由 Hypervisor 來負責創建虛擬機, 這個虛擬機是真實存在的, 並且它裏面必須運行一個完整的 Guest OS 才能執行用戶的應用進程. 這就不可避免地帶來了額外的資源消耗和佔用.

根據實驗, 一個運行着 CentOS 的 KVM 虛擬機啓動後, 在不做優化的情況下, 虛擬機自己就需要佔用 100~200 MB 內存. 此外, 用戶應用運行在虛擬機裏面, 它對宿主機操作系統的調用就不可避免地要經過虛擬化軟件的攔截和處理, 這本身又是一層性能損耗, 尤其對計算資源, 網絡和磁盤 I/O 的損耗非常大
而相比之下, 容器化後的用戶應用, 卻依然還是一個宿主機上的普通進程, 這就意味着這些因爲虛擬化而帶來的性能損耗都是不存在的;而另一方面, 使用 namespace 作爲隔離手段的容器並不需要單獨的 Guest OS, 這就使得容器額外的資源佔用幾乎可以忽略不計
所以可以這麼說: 敏捷高性能是容器相較於虛擬機最大的優勢

cgroups

使用 namespace 的障眼法技術, 使得進程在容器內雖然只能看到內部進程, 但他作爲宿主機的第 n 號進程, 與其他所有進程之間依然是平等的競爭關係. 因此雖然進程被隔離起來, 但是使用的資源卻是可以隨時被宿主機上的其他進程佔用的(如 CUP, 內存等), 這顯然對於我們要使用容器的期許是不合理的, 所以這個時候 就會講到 cgroups.
Linux cgroups(Linux Control Group) 就是 Linux 內核中用來爲進程設置資源限制的一個重要功能, 最主要的作用, 就是限制一個進程組能夠使用的資源上限, 包括 CPU, 內存, 磁盤, 網絡帶寬等等
Linux cgroups 的設計還是比較易用的, 簡單粗暴地理解呢, 它就是一個子系統目錄加上一組資源限制文件的組合. 而對於 Docker 等 Linux 容器項目來說, 它們只需要在每個子系統下面, 爲每個容器創建一個控制組(即創建一個新目錄), 然後在啓動容器進程之後, 把這個進程的 PID 填寫到對應控制組的 tasks 文件中就可以了

[root@irvin ~]# mount -t cgroup 
cgroup on /sys/fs/cgroup/systemd type cgroup (rw,nosuid,nodev,noexec,relatime,xattr,release_agent=/usr/lib/systemd/systemd-cgroups-agent,name=systemd) 
cgroup on /sys/fs/cgroup/net_cls,net_prio type cgroup (rw,nosuid,nodev,noexec,relatime,net_prio,net_cls) 
cgroup on /sys/fs/cgroup/cpu,cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct,cpu) 
cgroup on /sys/fs/cgroup/pids type cgroup (rw,nosuid,nodev,noexec,relatime,pids) 
cgroup on /sys/fs/cgroup/perf_event type cgroup (rw,nosuid,nodev,noexec,relatime,perf_event) 
cgroup on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory) 
cgroup on /sys/fs/cgroup/devices type cgroup (rw,nosuid,nodev,noexec,relatime,devices) 
cgroup on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio) 
cgroup on /sys/fs/cgroup/freezer type cgroup (rw,nosuid,nodev,noexec,relatime,freezer) 
cgroup on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset) 
cgroup on /sys/fs/cgroup/hugetlb type cgroup (rw,nosuid,nodev,noexec,relatime,hugetlb) 

我們可以在對應文件夾下找到我們啓動的容器實例資源限制的配置文件.

文件系統*

文件系統的隔離是怎麼做的

文件系統的隔離是怎麼做的? 大家可能第一反應都是: 通過 mount namespace 做的. 確實是這樣的, 但是 mount namespace 是怎麼做的? 和其他幾個 namespace 的用法有沒有什麼差異?
mount namespace 修改的, 是容器進程對文件系統 “掛載點” 的認知. 但是, 這也就意味着, 只有在 “掛載” 這個操作發生之後, 進程的視圖纔會被改變. 而在此之前, 新創建的容器會直接繼承宿主機的各個掛載點. 這就是 mount namespace 跟其他 namespace 的使用略有不同的地方:它對容器進程視圖的改變, 一定是伴隨着掛載操作(mount)才能生效
所以很容易想到, 我們可以在容器進程啓動之前重新掛載它的整個根目錄 /. 而由於 mount namespace 的存在, 這個掛載可以實現容器對宿主機文件系統的隔離

延伸*

實際上, mount namespace 正是基於對 chroot 的不斷改良才被髮明出來的, 它也是 Linux 操作系統裏的第一個 namespace. 感興趣的可以瞭解一下 chroot, pivot_rootswitch_root 等內容

鏡像

這個掛載在容器根目錄上, 用來爲容器進程提供隔離後執行環境的文件系統, 就是所謂的"容器鏡像". 它還有一個更爲專業的名字, 叫作:rootfs(根文件系統)
因此, 對 Docker 項目來說, 它最核心的原理實際上就是爲待創建的用戶進程:

  1. 啓用 Linux namespace 配置;
  2. 設置指定的 cgroups 參數;
  3. 切換進程的根目錄(Change Root).

需要明確的是, rootfs 只是一個操作系統所包含的文件, 配置和目錄, 並不包括操作系統內核. 在 Linux 操作系統中, 這兩部分是分開存放的, 操作系統只有在開機啓動時纔會加載指定版本的內核鏡像 (所以說, rootfs 只包括了操作系統的"軀殼", 並沒有包括操作系統的"靈魂". 那麼, 對於容器來說, 這個操作系統的"靈魂"又在哪裏呢?實際上, 同一臺機器上的所有容器, 都共享宿主機操作系統的內核)
正是由於 rootfs 的存在, 容器纔有了一個重要特性: 一致性
由於 rootfs 裏打包的不只是應用, 而是整個操作系統的文件和目錄, 也就意味着, 應用以及它運行所需要的所有依賴, 都被封裝在了一起
不過, 這時你可能已經發現了另一個非常棘手的問題:難道我每開發一個應用, 或者升級一下現有的應用, 都要重複製作一次 rootfs 嗎?
Docker 在鏡像的設計中, 引入了層(layer)的概念解決了這個問題. 用到了一種叫作聯合文件系統(Union File System)的能力. 也就是說, 用戶製作鏡像的每一步操作, 都會生成一個層, 也就是一個增量 rootfs.

UnionFS

這裏簡單介紹一下什麼是 UnionFS, 感興趣的可以私下了解一下.
比如, 我現在有兩個目錄 A 和 B, 它們分別有兩個文件:

[root@irvin ~]# tree 
. 
├── A 
│ ├── a 
│ └── x 
└── B 
├── b 
└── x 

然後, 我使用聯合掛載的方式, 將這兩個目錄掛載到一個公共的目錄 C 上:

[root@irvin ~]# mkdir C 
[root@irvin ~]# mount -t aufs -o dirs=./A:./B none ./C 

默認上來說,命令行上第一個(最左邊./A)的目錄是可讀可寫的,後面的全都是隻讀的, 你也可以在掛載的時候自己指定權限:

[root@irvin ~]# mount -t aufs -o dirs=./A=rw:./B=rw none ./C 

這時, 我再查看目錄 C 的內容, 就能看到目錄 A 和 B 下的文件被合併到了一起:

[root@irvin ~]# tree ./C 
./C 
├── a 
├── b 
└── x 

可以看到, 在這個合併後的目錄 C 裏, 有 a, b, x 三個文件, 並且 x 文件只有一份. 這, 就是"合併"的含義. 此外, 如果你在目錄 C 裏對 a, b, x 文件做修改, 這些修改也會在對應的目錄 A, B 中生效.

我們來操作一下這裏的文件:

  1. 我們首先嚐試修改 x 文件
[root@irvin ~]# echo test > ./C/x 
[root@irvin ~]# cat ./C/x 
test 
[root@irvin ~]# cat ./A/x 
test 
[root@irvin ~]# cat ./B/x 
  1. 再試一下修改b目錄(只讀目錄)纔有的b
[root@irvin ~]# echo test > ./C/b 
[root@irvin ~]# cat ./C/b 
test 
[root@irvin ~]# cat ./B/b 
[root@irvin ~]# cat ./A/b 
test 

你會發現: B 目錄下的文件沒有被修改, 而是在 A 目錄(可讀寫目錄)創建了一個 b

  1. 在 B 文件夾下創建 btemp 文件, 並通過 C 目錄刪除該文件及 a 文件, 看看會有什麼效果
    在 B 目錄下創建 btemp 文件, C 的目錄結果顯而易見爲
[root@irvin ~]# touch b/btemp 
[root@irvin ~]# tree ./C 
./C 
├── a 
├── b 
├── btemp 
└── x 

首先刪除 a 文件

[root@irvin ~]# rm ./C/a 
[root@irvin ~]# tree 
. 
├── A 
│ └── x 
└── B 
├── b 
├── btemp 
└── x 

刪除 btemp

[root@irvin ~]# rm ./C/btemp 
[root@irvin ~]# tree 
. 
├── A 
│ ├── .wh.btemp 
│ └── x 
└── B 
├── b 
├── btemp 
└── x 

我們發現在 C 目錄中刪除 a 和 btemp 後, A 目錄(可讀寫)中的 a 真的刪除了, 而 B 目錄(只讀)中的 btemp 還在, 只是 A 目錄中多個 .wh.btemp 這個文件.
一般來說只讀目錄都會有 whiteout 的屬性, 所謂 whiteout 的意思, 就是如果在 union 中刪除的某個文件, 實際上是位於一個 readonly 的目錄上. 那麼, 在 mount 的 union 這個目錄中你將看不到這個文件,但是 readonly 這個層上我們無法做任何的修改, 所以, 我們就需要對這個 readonly 目錄裏的文件作 whiteout. AUFS 的 whiteout 的實現是通過在上層的可寫的目錄下建立對應的 whiteout 隱藏文件來實現的.

rootfs 組成

通過聯合文件系統, Docker 將各個 Layer 整合成一個鏡像, 這個容器的 rootfs 由如下圖所示的三部分組成:

  1. 只讀層
    它是這個容器的 rootfs 最下面的五層, 可以看到, 它們的掛載方式都是隻讀的

  2. Init 層
    "-init"結尾的層, 夾在只讀層和讀寫層之間. Init 層是 Docker 項目單獨生成的一個內部層, 專門用來存放 /etc/hosts, /etc/resolv.conf 等信息. 需要這樣一層的原因是: 這些文件本來屬於只讀的系統鏡像層的一部分, 但用戶往往需要在啓動容器時寫入一些指定的值比如 hostname, 所以就需要在可讀寫層對他們進行修改. 但這些修改往往只對當前容器有效, 我們並不希望執行 docker commit 命令時, 把這些信息連同可讀寫層一起提交. 所以 Docker 的做法是, 在修改了這些文件後, 以一個單獨層掛載了出來. 而用戶執行 docker commit 命令時只會提交可讀寫層, 而是不包含這些 內容的.

  3. 可讀寫層
    它是這個容器的 rootfs 最上面的一層, 它的掛載方式爲:rw. 在沒有寫入文件之前, 這個目錄是空的. 而一旦在容器裏做了寫操作, 你修改產生的內容就會以增量的方式出現在這個層中.

如果我現在要做的, 是刪除只讀層裏的一個文件呢? 這就用到了我們之前所說的 whiteout 了. 所以, 最上面這個可讀寫層的作用, 就是專門用來存放你修改 rootfs 後產生的增量, 無論是增, 刪, 改, 都發生在這裏. 而當我們使用完了這個被修改過的容器之後, 還可以使用 docker commit 和 push 指令, 保存這個被修改過的可讀寫層, 並上傳到 Docker Hub 上, 供其他人使用;而與此同時, 原先的只讀層裏的內容則不會有任何變化. 這, 就是增量 rootfs 的好處
rootfs 的最上層是一個可讀寫層, 它以 Copy-on-Write 的方式存放任何對只讀層的修改, 容器聲明的 Volume 的掛載點, 也出現在這一層

基礎實踐(一)

今天最後一部分, 對 docker 鏡像構建及常用命令的使用做介紹, 上邊說了一大堆雲裏霧裏的假大空, 這部分可能是大家使用中用大的最多思考的最多的地方. 但是上邊的基礎原理和邏輯是你理解這些命令, 遇到問題定位問題解決問題的根本, 讓你更快理解和上手. 今後的分享中我也會儘可能的把各種原理和底層實現的部分, 首先我先搞明白, 揉碎到每一次的分享中, 實踐中結合原理來分享, 加深大家的理解, 而不是隻講假大空的原理或只講怎麼用, 爲什麼這麼用不知道, 出了問題都不知道怎麼排查.

鏡像構建

指令

我們用一個例子來實操一下:

#Comment 
FROM java:8 
MAINTAINER Irvin "[email protected]" 
ENV ACTIVE local 
COPY mm-lbs-service.jar /app.jar 
COPY start.sh /start.sh 
ENTRYPOINT ["./start.sh"] 
  • FROM
FROM <image> 或 FROM <image>:<tag> 

第一條命令必須是 FROM, 它用於指定構建鏡像的基礎鏡像.
FROM 參數 tag 默認爲 latest
FROM java --即爲-> FROM java:latest

  • ENV
ENV <key> <value> 或 ENV <key>=<value> 

ENV 指令可以爲鏡像創建除容器聲明環境變量. 並且在 Dockerfile 中, ENV 指令聲明的環境變量會被後面的特定指令解釋使用(變量前面加\``可以轉義). 另外ONBUILD` 指令不支持環境替換

  • COPY
COPY <src> <dest> 

COPY 指令將複製 所指向的文件或目錄到新的鏡像中的 路徑下.
必須是上下文根目錄相對路徑(即 Dockerfile 所在目錄), 形如 COPY ../sth/sth 是不行的.
可以是文件或目錄, 必須是鏡像中的絕對路徑或者是相對於 WORKDIR 的相對路徑

  • ADD
ADD <src> <dest> 

功能與 COPY 相似, 但 ADD 指令還支持其他功能:

可以是一個指向一個網絡文件的 URL. ADD http://example.com/foobar /foobar
還可以指向一個本地壓縮歸檔文件, 該文件複製到容器中時會被解壓提取. 但若 URL 中的文件爲歸檔文件則不會被解壓提取. ADD example.tar.gz

  • RUN
RUN <command> (shell 格式) 
RUN ["executable", "param1", "param2"] (exec 格式, 推薦格式) 

RUN 指令會在前面一條命令創建出的鏡像的基礎上創建一個容器, 並在容器中運行命令, 在命令結束運行後提交容器爲新鏡像, 新鏡像被 Dockerfile 中的下一條指令使用.
RUN 指令的兩種格式表示在容器中的兩種運行方式. 當使用 shell 格式時, 命令通過 /bin/sh -c 運行; 當使用 exec 格式時, 命令是直接運行的, 容器不調用 shell 程序.
exec 格式中的參數會當成 JSON 數組被 Docker 解析, 因此必須使用雙引號,不能使用單引號.
因爲 exec 格式不會在 shell 中執行, 所以環境變量參數不會被替換, 例如, 當執行 CMD ["echo", "$HOME"] 指令時, $HOME 不會作爲環境變量替換, 如果希望運行 shell 程序, 指令可以寫成 CMD ["sh", "-c", "echo", "$HOME"]

  • CMD
CMD <command> (shell 格式) 
CMD ["executable", "param1", "param2"] (exec 格式, 推薦格式) 
CMD ["param1", "param2"] (爲 ENTRYPOINT 指令提供參數) 

CMD 指令提供容器運行時的默認指令, 這些默認值可以是一條指令, 也可以是一些參數.
一個 Dockerfile 中可以有多條 CMD 指令, 但只有最後一條指令有效.
CMD ["param1", "param2"] 格式是在 CMD 和 ENTRYPOINT指令配合時使用的, CMD 指令中的參數會添加到 ENTRYPOINT 指令中.
使用 shell 和 exec 格式時, 命令在容器中的運行方式與 RUN 相同. 不同在於, RUN 指令在構建鏡像時執行, 並生成新鏡像; CMD 指令在構建鏡像時並不執行任何命令, 而是在容器啓動時默認將 CDM 指令作爲第一條執行的命令.
如果用戶在運行 docker run 命令時指定了命令參數, 則會覆蓋 CMD 指令中的命令.

  • ENTRYPOINT
ENTRYPOINT <command> (shell 格式) 
ENTRYPOINT ["executable", "param1", "param2"] (exec 格式, 推薦格式) 

與 CMD 指令類似, 但他們之間又有不同.
一個 Dockerfile 中可以有多條 ENTRYPOINT 指令, 但只有最後一條指令有效.
當使用 shell 格式時, ENTRYPOINT 指令會忽略任何 CMD 指令和 docker run 命令的參數, 並且會運行在 /bin/sh -c 中. 這意味着 ENTRYPOINT 指令進程爲 /bin/sh -c 的子進程, 進程在容器中的 PID 也就不是 1, 且不能接收 Unix 信號. 即當使用 docker stop <container> 命令時, 命令進程接收不到 SIGTERM 信號. 推薦使用 exec 格式, 使用此格式時, docker run 傳入的命令參數會覆蓋 CMD 指令中的內容並附加到 ENTRYPOINT 指令的參數中.

與 CMD 指令的不同
CMD 可以是參數也可以是指令; ENTRYPOINT 只能是命令.
docker run 提供的參數可以覆蓋 CMD, 但不能覆蓋 ENTRYPOINT

規範及實踐心得
  1. Dockerfile 中, 指令不區分大小寫, 但是爲了與參數區分, 推薦大寫
# 反例 
from java:8 
maintainer Irvin "[email protected]" 
env ACTIVE local 
copy mm-*.jar /app.jar 
add start.sh /start.sh 
run chmod 755 /start.sh 
entrypoint ["./start.sh"] 
  1. 構建鏡像時, 給鏡像打上易讀的標籤, 可以幫助瞭解鏡像功能或版本信息
# 反例 
docker build -t myservice . 
# 推薦 
docker build -t mineservice/lbs:v3.0.1 . 
  1. 謹慎選擇基礎鏡像.
    不同鏡像大小不同, busybox < debian < centos < ubuntu, 因此相比於使用 Ubuntu 或 CentOS 鏡像, 更推薦使用 Debian 鏡像, 因爲他非常輕量(<100M), 並且讓然是一個完整的發佈版本.
    在構建自己的 Docker 鏡像時, 只安裝和更新必須使用的包.

  2. 充分利用緩存
    Docker daemon 會順序執行 Dockerfile 中的指令, 而且一旦緩存失效, 後續命令將不能使用緩存. 爲了有效利用緩存, 需要保證指令的連續性, 儘量將多有 Dockerfile 文件中相同的部分都放在前面, 而將不同的部分放下後面

  3. 正確使用 ADD 和 COPY 命令
    儘管二者用法相近, 但 COPY 仍是首選. COPY 相對於 ADD 而言, 功能簡單夠用. 個人建議: 若需要添加壓縮文件或通過 URL 獲取遠程資源, 建議將資源準備好(該解壓的解壓, 該下載的下載), 再通過 COPY 指令添加到鏡像中.
    當在 Dockerfile 中的不同部分需要用到不同文件時,不要一次性地將這些文件都添加到鏡像當中, 而是在需要的時候逐個添加, 這樣有利於充分利用緩存.
    通過 ADD 指令獲取遠程 URL 中的壓縮包也是不推薦的做法. 應該使用 RUN wgetRUN curl 代替. 這樣可以的刪除解壓後不再需要的文件, 並且不需要在鏡像中再添加一層, 例如:

不推薦的做法: 
ADD http://example.com/big.tar.xz /usr/src/things/ 
RUN tar -xJf /usr/src/things/big.tar.xz -C /usr/src/things 
RUN make -C /usr/src/things all 
--- 
推薦的做法: 
RUN mkdir -p /usr/src/things \ 
		&& curl -SL http://example.com/big.tar.xz \ 
		| tar -xJC /usr/src/things \ 
		&& make -C /usr/src/things all 

需要注意的是, Dockerfile 中的每個原語執行後, 都會生成一個對應的鏡像層

  1. RUN 命令
    爲了使 Dockerfile 易讀, 易理解和可維護, 較長指令通過 \ 分隔多行
# 反例 
RUN mkdir -p /usr/src/things && curl -SL http://example.com/big.tar.xz | tar -xJC /usr/src/things && make -C /usr/src/things all 

大部分使用 RUN 指令的場景是運行 apt-get 命令, 需要注意以下幾點:

① 不要在一行中單獨使用指令 RUN apt-get update, 推薦 RUN apt-get update && apt-get install -y package-bar package-foo package-baz.因爲當軟件源更新後, 這樣做會引起緩存問題, 導致 RUN apt-get install 指令運行失敗
② 避免使用指令 RUN apt-get upgradeRUN apt-get dist-upgrade. 建議直接使用 RUN apt-get install -y foo. 因爲在一個無特權的容器裏, 一些必要的包會更新失敗.
剛纔提到了 Dockerfile 中的每個原語執行後, 都會生成一個對應的鏡像層, 有的同事可能會想: 是不是儘量吧所有的腳本都放到一個 RUN 指令裏邊執行就行了, 就是最佳實踐了?
答案當然是: NO!!!.

在 Docker 的核心概念中, 提交鏡像是廉價的, 鏡像之間有層級關係,像一棵樹. 不要害怕鏡像的層數多, 我們可以在任意一個容器. 因此, 不要將所有的命令卸載一個 RUN 指令中. RUN 指令分層符合 Docker 的核心概念, 這很像源碼控制 

這是我從書上抄下來的一段話, 亂七八糟. 我理解的就是: 這是一門藝術. 就行架構設計一樣, 沒有正確答案, 你可以按照自己的需求, 命令之間的關係, 上下文關聯合理拆分

  1. CMD 和 ENTRYPOINT 指令
    推薦二者結合使用. 使用 exec 格式的 ENTRYPOINT 指令設置固定的默認命令和參數, 然後使用 CMD 指令設置可變的參數.

  2. 不要在 Dockerfile 中做端口映射

Docker 的兩個核心概念是可複製性可移植性, 這種方式破壞了可移植性, 這樣一個鏡像只能在一個機器上啓動一個實例. 映射應通過 docker run 命令中的 -p 參數指定

# 反例 
EXPOSE 80:8080 
# 只暴露端口 
EXPOSE 80 

生命週期

首先我們看一下 Docker 的生命週期:
Docker VS Virtual Machines

容器操作常用命令

通過 Docker 生命週期的示意圖, 我們可以看到裏邊有很多的命令, 我會按照我們實際操作的順序逐一給大家示範. 我這裏提供常用命令的常見用法, 至於詳細參數以後的分享中會進行介紹, 本次不會逐一介紹.

  1. docker build
    前邊我們在講 Dockerfile 的時候提到了一個 docker build 命令, 通過 Dockerfile 來構建鏡像.
# --tag, -t 爲鏡像添加名字及標籤
docker build -t name:tag .
# -f 指定要使用的Dockerfile路徑
docker build -f /path/to/a/Dockerfile .

docker build 最後的 . 號, 指定鏡像構建過程中的上下文環境的目錄, 並不是指 Dockerfile 所在的目錄. 在 Dockerfile 中進行類似 COPY 這樣的指令, 實際上是經上下文環境目錄都加載到了 Docker 引擎之, 而不是在本機環境下直接加載要 COPY 的單一文件.

大家知道 .dockerignore 文件嗎, 根據上述內容, 可以幫助該文件的作用了
  1. docker images
    查看鏡像列表
$ docker images
REPOSITORY   TAG        IMAGE ID         CREATED          SIZE
<none>       <none>     77af4d6b9913     19 hours ago     1.089 GB
committ      latest     b6fa739cedf5     19 hours ago     1.089 GB
<none>       <none>     78a85c484f71     19 hours ago     1.089 GB
docker       latest     30557a29d5ab     20 hours ago     1.089 GB
<none>       <none>     5ed6274db6ce     24 hours ago     1.089 GB
postgr       9          746b819f315e     4 days ago       213.4 MB
postgres     9.3        746b819f315e     4 days ago       213.4 MB
postgres     9.3.5      746b819f315e     4 days ago       213.4 MB
postgres     latest     746b819f315e     4 days ago       213.4 MB

查看指定倉庫中的鏡像

$ docker images java
REPOSITORY   TAG     IMAGE ID         CREATED          SIZE
java         8       308e519aac60     6 days ago       824.5 MB
java         7       493d82594c15     3 months ago     656.3 MB
java         latest  2711b1d6f3aa     5 months ago     603.9 MB

添加過濾條件, 查看未被標記的鏡像

$ docker images -f "dangling=true"
REPOSITORY  TAG       IMAGE ID         CREATED         SIZE
<none>      <none>    8abc22fbb042     4 weeks ago     0 B
<none>      <none>    48e5f45168b9     4 weeks ago     2.489 MB
<none>      <none>    bf747efa0e2f     4 weeks ago     0 B
<none>      <none>    980fe10e5736     12 weeks ago    101.4 MB
<none>      <none>    dea752e4e117     12 weeks ago    101.4 MB
<none>      <none>    511136ea3c5a     8 months ago    0 B

刪除鏡像 docker rmi

刪除未被標記的鏡像
$ docker rmi $(docker images -f "dangling=true" -q)
8abc22fbb042
48e5f45168b9
bf747efa0e2f
980fe10e5736
dea752e4e117
511136ea3c5a
  1. docker load
    加載鏡像
$ docker load -i docker.mineservice.lbs.tar
$ docker load < docker.mineservice.lbs.tar
  1. docker run
    通過 Docker 生命週期示意圖, 我麼可以發現, docker run 命令實際上是融合了 docker createdocker start 兩步操作. 直接將鏡像創建並運行一個容器實例.
    docker run 命令太有的說了, 這次只列舉幾個常用的參數
docker run [OPTIONS] IMAGE [COMMAND] [ARG...]
OPTIONS:
--help				Print usage
--add-host			Add a custom host-to-IP mapping (host:ip)
--detach , -d		Run container in background and print container ID
--env , -e			Set environment variables
--hostname , -h	Container host name
--interactive , -i	Keep STDIN open even if not attached
--mac-address		Container MAC address (e.g., 92:d0:c6:0a:29:33)
--memory , -m		Memory limit
--name				Assign a name to the container
--privileged		Give extended privileges to this container
--publish , -p		Publish a container’s port(s) to the host
--publish-all , -P	Publish all exposed ports to random ports
--restart			Restart policy to apply when a container exits
--tty , -t			Allocate a pseudo-TTY
--volume , -v		Bind mount a volume

restart 策略

Docker 容器的重啓策略如下:  
① no, 默認策略, 在容器退出時不重啓容器;
② on-failure, 在容器非正常退出時(退出狀態非0),纔會重啓容器; 
③ on-failure:3, 在容器非正常退出時重啓容器, 最多重啓3次; 
④ always, 在容器退出時總是重啓容器;
⑤ unless-stopped, 在容器退出時總是重啓容器, 但是不考慮在 Docker 守護進程啓動時就已經停止了的容器  

拉取鏡像 docker pull

默認拉取標籤爲 latest 鏡像
$ docker pull debian
Using default tag: latest
latest: Pulling from library/debian
fdd5d7827f33: Pull complete
a3ed95caeb02: Pull complete
Digest: sha256:e7d38b3517548a1c71e41bffe9c8ae6d6d29546ce46bf62159837aad072c90aa
Status: Downloaded newer image for debian:latest
$ docker pull debian:jessie
jessie: Pulling from library/debian
fdd5d7827f33: Already exists
a3ed95caeb02: Already exists
Digest: sha256:a9c958be96d7d40df920e7041608f2f017af81800ca5ad23e327bc402626b58e
Status: Downloaded newer image for debian:jessie
在執行 `docker run` 時, 若本地鏡像不存在, 會自動從鏡像倉庫拉取鏡像, 不需要手動執行 `docker pull` 命令

停止容器 docker stop

$ docker stop lbs

刪除容器 docker rm

$ docker rm lbs
刪除的容器一定是狀態爲 stop 的容器

重啓容器 docker restart

$ docker restart lbs
他不是 `docker stop` 與 `docker start` 的簡單組合, 着我們之後分享的時候再說
  1. docker logs
    展示容器日誌
$ docker logs -f --tail 500 lbs
  1. docker exec
    運行的容器內執行命令, 使用的比較多的是
$ docker exec -it lbs bash

意思是在 lbs 容器內執行 bash 命令, 並提供終端保持容器輸入的打開狀態. 說白了就是給個爲終端持續操作容器

  1. docker stats
    實時展示容器資源使用情況
$ docker stats
  1. docker inspect
    展示容器實例底層信息
$ docker inspect lbs
  1. docker ps
    展示容器實例列表
$ docker ps
$ docker ps -a
  1. docker cp
    在容器與宿主機之間進行文件複製
# 將宿主機 mm-lbs-service.jar 文件複製到容器 lbs 內的 /app.jar 文件
$ docker cp mm-lbs-service.jar lbs:/app.jar
# 將容器 lbs 內的 /app.jar 文件複製到宿主機 /APP/mm-lbs-service.jar 文件
$ docker cp lbs:/app.jar /APP/mm-lbs-service.jar 
  1. docker commit
    以一個容器實例爲基礎創建一個新的鏡像
$ docker commit -m "update" -a irvin lbs mineservice/lbs:vx.x.x

總結

  • 介紹 Docker;
  • 內核原理, 介紹了 namespace 及 cgroups. 對比了容器與虛擬機的差異;
  • Dockerfile 怎麼寫;
  • 常用 Docker 命令.
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章