0.前言
前面我們討論了Docker容器實現(xiàn)隔離和資源限制用到的技術(shù)Linux namespace 、Linux CGroups,本篇我們來討論Docker容器鏡像用到的技術(shù)UnionFS缓熟。
1.關(guān)于UnionFS
1)什么是UnionFS
聯(lián)合文件系統(tǒng)(Union File System):2004年由紐約州立大學(xué)石溪分校開發(fā)移稳,它可以把多個目錄(也叫分支)內(nèi)容聯(lián)合掛載到同一個目錄下,而目錄的物理位置是分開的屎飘。UnionFS允許只讀和可讀寫目錄并存妥曲,就是說可同時刪除和增加內(nèi)容。UnionFS應(yīng)用的地方很多钦购,比如在多個磁盤分區(qū)上合并不同文件系統(tǒng)的主目錄檐盟,或把幾張CD光盤合并成一個統(tǒng)一的光盤目錄(歸檔)。另外押桃,具有寫時復(fù)制(copy-on-write)功能UnionFS可以把只讀和可讀寫文件系統(tǒng)合并在一起葵萎,虛擬上允許只讀文件系統(tǒng)的修改可以保存到可寫文件系統(tǒng)當中。
2)docker的鏡像rootfs唱凯,和layer的設(shè)計
任何程序運行時都會有依賴羡忘,無論是開發(fā)語言層的依賴庫,還是各種系統(tǒng)lib磕昼、操作系統(tǒng)等卷雕,不同的系統(tǒng)上這些庫可能是不一樣的,或者有缺失的票从。為了讓容器運行時一致漫雕,docker將依賴的操作系統(tǒng)、各種lib依賴整合打包在一起(即鏡像)峰鄙,然后容器啟動時浸间,作為它的根目錄(根文件系統(tǒng)rootfs),使得容器進程的各種依賴調(diào)用都在這個根目錄里吟榴,這樣就做到了環(huán)境的一致性魁蒜。
不過,這時你可能已經(jīng)發(fā)現(xiàn)了另一個問題:難道每開發(fā)一個應(yīng)用吩翻,都要重復(fù)制作一次rootfs嗎(那每次pull/push一個系統(tǒng)豈不瘋掉)兜看?
比如,我現(xiàn)在用Debian操作系統(tǒng)的ISO做了一個rootfs仿野,然后又在里面安裝了Golang環(huán)境铣减,用來部署我的應(yīng)用A。那么脚作,我的另一個同事在發(fā)布他的Golang應(yīng)用B時葫哗,希望能夠直接使用我安裝過Golang環(huán)境的rootfs缔刹,而不是重復(fù)這個流程,那么本文的主角UnionFS就派上用場了劣针。
Docker鏡像的設(shè)計中校镐,引入了層(layer)的概念,也就是說捺典,用戶制作鏡像的每一步操作鸟廓,都會生成一個層,也就是一個增量rootfs(一個目錄)襟己,這樣應(yīng)用A和應(yīng)用B所在的容器共同引用相同的Debian操作系統(tǒng)層引谜、Golang環(huán)境層(作為只讀層),而各自有各自應(yīng)用程序?qū)忧嬖。涂蓪憣釉毖省尤萜鞯臅r候通過UnionFS把相關(guān)的層掛載到一個目錄,作為容器的根文件系統(tǒng)贮预。
需要注意的是贝室,rootfs只是一個操作系統(tǒng)所包含的文件、配置和目錄仿吞,并不包括操作系統(tǒng)內(nèi)核滑频。這就意味著,如果你的應(yīng)用程序需要配置內(nèi)核參數(shù)唤冈、加載額外的內(nèi)核模塊峡迷,以及跟內(nèi)核進行直接的交互,你就需要注意了:這些操作和依賴的對象务傲,都是宿主機操作系統(tǒng)的內(nèi)核凉当,它對于該機器上的所有容器來說是一個“全局變量”枣申,牽一發(fā)而動全身售葡。
3)各Linux版本的UnionFS不同
由于各種原因(有興趣的可自行谷歌),Linux各發(fā)行版實現(xiàn)的UnionFS各不相同忠藤,所以Docker在不同linux發(fā)行版中使用的也不同挟伙。你可以通過docker info
來查看docker使用的是哪種,比如:
- centos, docker18.03.1-ce:
Storage Driver: overlay2
- debain, docker17.03.2-ce:
Storage Driver: aufs
2.舉個例子(debain aufs)
1)準備如下目錄和文件
$ tree
.
|-- a
| |-- a.log
| `-- x.log
`-- b
|-- b.log
`-- x.log
2)執(zhí)行掛載命令
$ mkdir mnt
$ mount -t aufs -o dirs=./a:./b none ./mnt
$ tree ./mnt
./mnt
|-- a.log
|-- b.log
`-- x.log
可以看到被掛載的mnt目錄合并了目錄a和目錄b
3)修改
$ echo test > mnt/x.log
$ cat mnt/x.log
test
$ cat a/x.log
test
$ cat b/x.log
你會發(fā)現(xiàn)x.log在a模孩、b目錄都存在尖阔,在修改后只有a目錄生效了,原因是我們在mount aufs命令中榨咐,沒有指a介却、b目錄的權(quán)限,默認上來說块茁,命令行上第一個(最左邊)的目錄是可讀可寫的齿坷,后面的全都是只讀的桂肌,所以會出現(xiàn)上面這種情況,你也可以在掛載的時候自己指定權(quán)限(mount -t aufs -o dirs=./a=rw:./b=rw none ./mnt
)永淌,如果你有興趣可以去嘗試一下崎场,這里就不再演示了。
那么再試一下修改b目錄(只讀目錄)才有的b.log文件試一下呢:
$ echo test > mnt/b.log
$ cat mnt/b.log
test
$ cat b/b.log
$ cat a/b.log
test
你會發(fā)現(xiàn)遂蛀,b目錄下的文件沒有被修改谭跨,而是在a目錄(可讀寫目錄)創(chuàng)建了一個b.log。
4)刪除
$ touch b/bb.log
$ rm mnt/a.log
$ rm mnt/bb.log
$ ls -al mnt
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
$ ls -al a
-rw-r--r-- 1 root root 0 Sep 19 23:15 .wh.bb.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
$ ls -al b
-rw-r--r-- 1 root root 0 Sep 19 23:11 b.log
-rw-r--r-- 1 root root 0 Sep 19 23:14 bb.log
-rw-r--r-- 1 root root 0 Sep 19 23:11 x.log
你會看到在mnt目錄中刪除a.log和bb.log后李滴,a目錄(可讀寫)中的a.log真的刪除了螃宙,而b目錄(只讀)中的bb.log還在,只是a目錄中多個.wh.bb.log這個文件所坯。
一般來說只讀目錄都會有whiteout的屬性污呼,所謂whiteout的意思,就是如果在union中刪除的某個文件包竹,實際上是位于一個readonly的目錄上燕酷,那么,在mount的union這個目錄中你將看不到這個文件,但是readonly這個層上我們無法做任何的修改锥涕,所以相寇,我們就需要對這個readonly目錄里的文件作whiteout。AUFS的whiteout的實現(xiàn)是通過在上層的可寫的目錄下建立對應(yīng)的whiteout隱藏文件來實現(xiàn)的酱讶。 所以上面的rm mnt/bb.log
操作和touch a/.wh.bb.log
效果相同。
5)來看一個docker容器
我們一起來執(zhí)行如下命令:
#啟動一個容器
$ docker run -dt golang:1.8.3 /bin/sh
7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34
#通過上面容器id查看掛載點
$ ls /var/lib/docker/image/aufs/layerdb/mounts/7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34
init-id mount-id parent
$ cat /var/lib/docker/image/aufs/layerdb/mounts/7bcd61b6ccd79a7367cb9872015ad20871be5b44f8bad74d35e045c89b610f34/mount-id
e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b#
可以看到容器掛載的目錄是e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
彼乌,那么找到該目錄泻肯,看看里面的文件都有些什么:
$ ls /var/lib/docker/aufs/mnt/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
bin dev etc go go% home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
一個完整的操作系統(tǒng)根目錄出現(xiàn)在里面。我們再來看看這個rootfs聯(lián)合掛載的層級結(jié)構(gòu):
# 通過上面找到的mount-id查看aufs的內(nèi)部id(也叫si)
$ cat /proc/mounts |grep e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b
none /var/lib/docker/aufs/mnt/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b aufs rw,relatime,si=63e50947768841ec,dio,dirperm1 0 0
# 然后通過si查看layer
$ cat /sys/fs/aufs/si_63e50947768841ec/br[0-9]*
/var/lib/docker/aufs/diff/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b=rw
/var/lib/docker/aufs/diff/e4e2f1159f512ab74a6afbfeca51413cc3b6a24e86caccf91e40a9d611ce0a9b-init=ro
/var/lib/docker/aufs/diff/974a7e81b15c1eb6ea6c3c66dfb50dfcdf7b99b1e6458e2d3dca9451e2414106=ro
/var/lib/docker/aufs/diff/fd68755d715f47edc7f5ceaa2e5dc6788d4ca36a4d50f51a92a53045cd0b9fb1=ro
/var/lib/docker/aufs/diff/0e1237afa6d0fff72d9fdd5f84ef7275b1a49448d7523d590686131a3b129496=ro
/var/lib/docker/aufs/diff/440bf3d93514f6a35bd99d4ac098d9b709e878146e355c670bd8f1f533c185c5=ro
/var/lib/docker/aufs/diff/57e27832290597d0c5f2dc2ab55d1c53a7aa8a2a40eb6d21d014ad1210b1bb6f=ro
/var/lib/docker/aufs/diff/55da955ef5752f9c3d1810a7b23e0325dd7947a0c0aaecf6ae373f3e33979143=ro
由此我們找到了每個增量rootfs(即layer)所在的目錄慰照,那么現(xiàn)在你可以在容器里執(zhí)行上面UnionFS中實驗過的增刪改灶挟,看看在最終被修改的layer是哪個,這里就不一一實驗了毒租。從上面可以看到容器的layer一共有8層:
第一部分 只讀層
它是這個容器的rootfs最下面的6層(xxx=ro結(jié)尾)稚铣。可以看到墅垮,它們的掛載方式都是只讀的(ro+wh惕医,即readonly+whiteout,上面已經(jīng)講過一般來說只讀目錄都會有whiteout屬性)算色。
第二部分 Init層
它是一個以“-init”結(jié)尾的層抬伺,夾在只讀層和讀寫層之間。Init層是Docker項目單獨生成的一個內(nèi)部層灾梦,專門用來存放/etc/hosts峡钓、/etc/resolv.conf等信息齐鲤。需要這樣一層的原因是,這些文件本來屬于只讀的系統(tǒng)鏡像層的一部分椒楣,但是用戶往往需要在啟動容器時寫入一些指定的值比如hostname给郊,所以就需要在可讀寫層對它們進行修改∨趸遥可是淆九,這些修改往往只對當前的容器有效,我們并不希望執(zhí)行docker commit時毛俏,把這些信息連同可讀寫層一起提交掉炭庙。所以,Docker做法是煌寇,在修改了這些文件之后焕蹄,以一個單獨的層掛載了出來。而用戶執(zhí)行docker commit只會提交可讀寫層阀溶,所以是不包含這些內(nèi)容的腻脏。
第三部分 可讀寫層
它是這個容器的rootfs最上面的一層,它的掛載方式為:rw银锻,即read write永品。在沒有寫入文件之前,這個目錄是空的击纬。而一旦在容器里做了寫操作鼎姐,你修改產(chǎn)生的內(nèi)容就會以增量的方式出現(xiàn)在這個層中。刪除ro-wh層等文件時更振,也會在rw層創(chuàng)建對應(yīng)的個whiteout文件炕桨,把只讀層里的文件“遮擋”起來。最上面這個可讀寫層的作用肯腕,就是專門用來存放你修改rootfs后產(chǎn)生的增量献宫,無論是增刪改,都發(fā)生在這里乎芳。而當我們使用完了這個被修改過的容器之后遵蚜,還可以使用docker commit和push指令,保存這個被修改過的可讀寫層奈惑,并上傳到Docker Hub上,供其他人使用睡汹。而與此同時肴甸,原先的只讀層里的內(nèi)容則不會有任何變化。這囚巴,就是增量rootfs的好處原在。
最終友扰,這8個層都被聯(lián)合掛載到/var/lib/docker/aufs/mnt目錄下,表現(xiàn)為一個完整的操作系統(tǒng)和golang環(huán)境供容器使用庶柿。
6)性能
IBM的研究中心對Docker的性能給了一份非常不錯的性能報告(PDF)《An Updated Performance Comparison of Virtual Machinesand Linux Containers》村怪。
這里扒了兩張圖下來,順序讀寫和隨機讀寫:
3.對照Docker源碼
1)在啟動docker daemon時會根據(jù)系統(tǒng)初始化好能使用的unionfs
func NewDaemon(ctx context.Context, config *config.Config, pluginStore *plugin.Store) (daemon *Daemon, err error) {
//...
for operatingSystem, gd := range d.graphDrivers {
layerStores[operatingSystem], err = layer.NewStoreFromOptions(layer.StoreOptions{
Root: config.Root,
MetadataStorePathTemplate: filepath.Join(config.Root, "image", "%s", "layerdb"),
GraphDriver: gd,
GraphDriverOptions: config.GraphOptions,
IDMapping: idMapping,
PluginGetter: d.PluginStore,
ExperimentalEnabled: config.Experimental,
OS: operatingSystem,
})
}
//...
}
func NewStoreFromOptions(options StoreOptions) (Store, error) {
driver, err := graphdriver.New(options.GraphDriver, options.PluginGetter, graphdriver.Options{
Root: options.Root,
DriverOptions: options.GraphDriverOptions,
UIDMaps: options.IDMapping.UIDs(),
GIDMaps: options.IDMapping.GIDs(),
ExperimentalEnabled: options.ExperimentalEnabled,
})
//...
}
// New creates the driver and initializes it at the specified root.
func New(name string, pg plugingetter.PluginGetter, config Options) (Driver, error) {
//...
driversMap := scanPriorDrivers(config.Root)
list := strings.Split(priority, ",")
logrus.Debugf("[graphdriver] priority list: %v", list)
for _, name := range list {
if name == "vfs" {
// don't use vfs even if there is state present.
continue
}
if _, prior := driversMap[name]; prior {
driver, err := getBuiltinDriver(name, config.Root, config.DriverOptions, config.UIDMaps, config.GIDMaps)
//...
return driver, nil
}
}
//...
}
2)再來看創(chuàng)建容器時浮庐,如何使用這個driver的
//docker daemon創(chuàng)建容器api的http handler
router.NewPostRoute("/containers/create", r.postContainersCreate)
//handler 挨著往下扒~
func (s *containerRouter) postContainersCreate(ctx context.Context, w http.ResponseWriter, r *http.Request, vars map[string]string) error {
//...
ccr, err := s.backend.ContainerCreate(types.ContainerCreateConfig{
Name: name,
Config: config,
HostConfig: hostConfig,
NetworkingConfig: networkingConfig,
AdjustCPUShares: adjustCPUShares,
})
//...
}
func (daemon *Daemon) ContainerCreate(params types.ContainerCreateConfig) (containertypes.ContainerCreateCreatedBody, error) {
return daemon.containerCreate(params, false)
}
func (daemon *Daemon) containerCreate(params types.ContainerCreateConfig, managed bool) (containertypes.ContainerCreateCreatedBody, error) {
//...
container, err := daemon.create(params, managed)
//...
}
func (daemon *Daemon) create(params types.ContainerCreateConfig, managed bool) (retC *container.Container, retErr error) {
//...
//創(chuàng)建init和rw層
// Set RWLayer for container after mount labels have been set
rwLayer, err := daemon.imageService.CreateLayer(container, setupInitLayer(daemon.idMapping))
//...
}
func (i *ImageService) CreateLayer(container *container.Container, initFunc layer.MountInit) (layer.RWLayer, error) {
var layerID layer.ChainID
if container.ImageID != "" {
img, err := i.imageStore.Get(container.ImageID)
if err != nil {
return nil, err
}
layerID = img.RootFS.ChainID()
}
rwLayerOpts := &layer.CreateRWLayerOpts{
MountLabel: container.MountLabel,
InitFunc: initFunc,
StorageOpt: container.HostConfig.StorageOpt,
}
// Indexing by OS is safe here as validation of OS has already been performed in create() (the only
// caller), and guaranteed non-nil
//這里的layerStores正式NewDaemon時 layerStores[operatingSystem], err = layer.NewStoreFromOptions(...)這里的這個
return i.layerStores[container.OS].CreateRWLayer(container.ID, layerID, rwLayerOpts)
}
3)接下來我們來追溯CreateRWLayer的實現(xiàn):
func (ls *layerStore) CreateRWLayer(name string, parent ChainID, opts *CreateRWLayerOpts) (RWLayer, error) {
//...
//這里driver不同環(huán)境有不同的實現(xiàn)甚负,下面我們主要來看aufs的實現(xiàn)
if err = ls.driver.CreateReadWrite(m.mountID, pid, createOpts); err != nil {
return nil, err
}
//
//這里的saveMount正是save上面2.5里面查看掛載點的mount-id,init-id审残,parent
//
if err = ls.saveMount(m); err != nil {
return nil, err
}
return m.getReference(), nil
}
//我們這里來看aufs的實現(xiàn)
//
// CreateReadWrite creates a layer that is writable for use as a container
// file system.
func (a *Driver) CreateReadWrite(id, parent string, opts *graphdriver.CreateOpts) error {
return a.Create(id, parent, opts)
}
// Create three folders for each id
// mnt, layers, and diff
func (a *Driver) Create(id, parent string, opts *graphdriver.CreateOpts) error {
if opts != nil && len(opts.StorageOpt) != 0 {
return fmt.Errorf("--storage-opt is not supported for aufs")
}
if err := a.createDirsFor(id); err != nil {
return err
}
// Write the layers metadata
f, err := os.Create(path.Join(a.rootPath(), "layers", id))
if err != nil {
return err
}
defer f.Close()
if parent != "" {
ids, err := getParentIDs(a.rootPath(), parent)
if err != nil {
return err
}
if _, err := fmt.Fprintln(f, parent); err != nil {
return err
}
for _, i := range ids {
if _, err := fmt.Fprintln(f, i); err != nil {
return err
}
}
}
return nil
}
至此梭域,容器鏡像的實現(xiàn)我們就討論的差不多了,有興趣的朋友搅轿,可以在去看看devicemapper病涨、overlay2等驅(qū)動,這就不一一展開討論了璧坟。