Docker 源码分析(十一):镜像存储

阅读数:5223 2015 年 7 月 14 日 00:19

1. 前言

Docker Hub 汇总众多 Docker 用户的镜像,极大得发挥 Docker 镜像开放的思想。Docker 用户在全球任意一个角度,都可以与 Docker Hub 交互,分享自己构建的镜像至 Docker Hub,当然也完全可以下载另一半球 Docker 开发者上传至 Docker Hub 的 Docker 镜像。

无论是上传,还是下载 Docker 镜像,镜像必然会以某种形式存储在 Docker Daemon 所在的宿主机文件系统中。Docker 镜像在宿主机的存储,关键点在于:在本地文件系统中以如何组织形式,被 Docker Daemon 有效的统一化管理。这种管理,可以使得 Docker Daemon 创建 Docker 容器服务时,方便获取镜像并完成 union mount 操作,为容器准备初始化的文件系统。

本文主要从 Docker 1.2.0 源码的角度,分析 Docker Daemon 下载镜像过程中存储 Docker 镜像的环节。分析内容的安排有以下 5 部分:

(1) 概述 Docker 镜像存储的执行入口,并简要介绍存储流程的四个步骤;

(2) 验证镜像 ID 的有效性;

(3) 创建镜像存储路径;

(4) 存储镜像内容;

(5) 在 graph 中注册镜像 ID。

2. 镜像注册

Docker Daemon 执行镜像下载任务时,从 Docker Registry 处下载指定镜像之后,仍需要将镜像合理地存储于宿主机的文件系统中。更为具体而言,存储工作分为两个部分:

(1) 存储镜像内容;

(2) 在 graph 中注册镜像信息。

说到镜像内容,需要强调的是,每一层 layer 的 Docker Image 内容都可以认为有两个部分组成:镜像中每一层 layer 中存储的文件系统内容,这部分内容一般可以认为是未来 Docker 容器的静态文件内容;另一部分内容指的是容器的 json 文件,json 文件代表的信息除了容器的基本属性信息之外,还包括未来容器运行时的动态信息,包括 ENV 等信息。

存储镜像内容,意味着 Docker Daemon 所在宿主机上已经存在镜像的所有内容,除此之外,Docker Daemon 仍需要对所存储的镜像进行统计备案,以便用户在后续的镜像管理与使用过程中,可以有据可循。为此,Docker Daemon 设计了 graph,使用 graph 来接管这部分的工作。graph 负责记录有哪些镜像已经被正确存储,供 Docker Daemon 调用。

Docker Daemon 执行 CmdPull 任务的 pullImage 阶段时,实现 Docker 镜像存储与记录的源码位于./docker/graph/pull.go#L283-L285 ,如下:

err = s.graph.Register(imgJSON,utils.ProgressReader(layer,
 imgSize, out, sf, false, utils.TruncateID(id), “Downloading”),img)

以上源码的实现,实际调用了函数 Register,Register 函数的定义位于./docker/graph/graph.go#L162-L218 :

func (graph *Graph) Register(jsonData []byte, layerData 
archive.ArchiveReader, img *image.Image) (err error)

分析以上 Register 函数定义,可以得出以下内容:

(1) 函数名称为 Register;

(2) 函数调用者类型为 Graph;

(3) 函数传入的参数有 3 个,第一个为 jsonData,类型为数组,第二个为 layerData,类型为 archive.ArchiveReader,第三个为 img, 类型为 *image.Image;

(4) 函数返回对象为 err,类型为 error。

Register 函数的运行流程如图 11-1 所示:

图 11-1 Register 函数执行流程图

3. 验证镜像 ID

Docker 镜像注册的第一个步骤是验证 Docker 镜像的 ID。此步骤主要为确保镜像 ID 命名的合法性。功能而言,这部分内容提高了 Docker 镜像存储环节的鲁棒性。验证镜像 ID 由三个环节组成。

(1) 验证镜像 ID 的合法性;

(2) 验证镜像是否已存在;

(3) 初始化镜像目录。

验证镜像 ID 的合法性使用包 utils 中的 ValidateID 函数完成,实现源码位于./docker/graph/graph.go#L171-L173 ,如下:

if err := utils.ValidateID(img.ID); err != nil {
	return err
}

ValidateID 函数的实现过程中,Docker Dameon 检验了镜像 ID 是否为空,以及镜像 ID 中是否存在字符‘:’,以上两种情况只要成立其中之一,Docker Daemon 即认为镜像 ID 不合法,不予执行后续内容。

镜像 ID 的合法性验证完毕之后,Docker Daemon 接着验证镜像是否已经存在于 graph。若该镜像已经存在于 graph,则 Docker Daemon 返回相应错误,不予执行后续内容。代码实现如下:

	if graph.Exists(img.ID) {
		return fmt.Errorf("Image %s already exists", img.ID)
	}

验证工作完成之后,Docker Daemon 为镜像准备存储路径。该部分源码实现位于./docker/graph/graph.go#L182-L196 ,如下:

if err := os.RemoveAll(graph.ImageRoot(img.ID)); err != nil && !os.IsNotExist(err) {
		return err
	}

	// If the driver has this ID but the graph doesn't, remove it from the driver to start fresh.
	// (the graph is the source of truth).
	// Ignore errors, since we don't know if the driver correctly returns ErrNotExist.
	// (FIXME: make that mandatory for drivers).
	graph.driver.Remove(img.ID)

	tmp, err := graph.Mktemp("")
	defer os.RemoveAll(tmp)
	if err != nil {
		return fmt.Errorf("Mktemp failed: %s", err)
	}

Docker Daemon 为镜像初始化存储路径,实则首先删除属于新镜像的存储路径,即如果该镜像路径已经在文件系统中存在的话,立即删除该路径,确保镜像存储时不会出现路径冲突问题;接着还删除 graph.driver 中的指定内容,即如果该镜像在 graph.driver 中存在的话,unmount 该镜像在宿主机上的目录,并将该目录完全删除。以 AUFS 这种类型的 graphdriver 为例,镜像内容被存放在 /var/lib/docker/aufs/diff 目录下,而镜像会被 mount 至目录 /var/lib/docker/aufs/mnt 下的指定位置。

至此,验证 Docker 镜像 ID 的工作已经完成,并且 Docker Daemon 已经完成对镜像存储路径的初始化,使得后续 Docker 镜像存储时存储路径不会冲突,graph.driver 对该镜像的 mount 也不会冲突。

4. 创建镜像路径

创建镜像路径,是镜像存储流程中的一个必备环节,这一环节直接让 Docker 使用者了解以下概念:镜像以何种形式存在于本地文件系统的何处。创建镜像路径完毕之后,Docker Daemon 首先将镜像的所有祖先镜像通过 aufs 文件系统 mount 至 mnt 下的指定点,最终直接返回镜像所在 rootfs 的路径,以便后续直接在该路径下解压 Docker 镜像的具体内容(只包含 layer 内容)。

4.1 创建 mnt、diff 和 layers

创建镜像路径的源码实现位于./docker/graph/graph.go#L198-L206 , 如下:

// Create root filesystem in the driver
if err := graph.driver.Create(img.ID, img.Parent); err != nil {
		return fmt.Errorf("Driver %s failed to create image rootfs %s: %s", graph.driver, img.ID, err)
}
// Mount the root filesystem so we can apply the diff/layer
rootfs, err := graph.driver.Get(img.ID, "")
if err != nil {
		return fmt.Errorf("Driver %s failed to get image rootfs %s: %s", graph.driver, img.ID, err)
}

以上源码中 Create 函数在创建镜像路径时起到举足轻重的作用。那我们首先分析 graph.driver.Create(img.ID, img.Parent) 的具体实现。由于在 Docker Daemon 启动时,注册了具体的 graphdriver,故 graph.driver 实际的值为具体注册的 driver。方便起见,本章内容全部以 aufs 类型为例,即在 graph.driver 为 aufs 的情况下,阐述 Docker 镜像的存储。在 ubuntu 14.04 系统上,Docker Daemon 的根目录一般为 /var/lib/docker, 而 aufs 类型 driver 的镜像存储路径一般为 /var/lib/docker/aufs。

AUFS 这种联合文件系统的实现,在 union 多个镜像时起到至关重要的作用。首先来关注,Docker Daemon 如何为镜像创建镜像路径,以便支持通过 aufs 来 union 镜像。Aufs 模式下,graph.driver.Create(img.ID, img.Parent) 的具体源码实现位于./docker/daemon/graphdriver/aufs/aufs.go#L161-L190 , 如下:

// Three folders are created for each id
// mnt, layers, and diff
func (a *Driver) Create(id, parent string) error {
	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
}

在 Create 函数的实现过程中,createDirsFor 函数在 Docker Daemon 根目录下的 aufs 目录 /var/lib/docker/aufs 中,创建指定的镜像目录。若当前 aufs 目录下,还不存在 mnt、diff 这两个目录,则会首先创建 mnt、diff 这两个目录,并在这两个目录下分别创建代表镜像内容的文件夹,文件夹名为镜像 ID,文件权限为 0755。假设下载镜像的镜像 ID 为 image_ID,则创建完毕之后,文件系统中的文件为 /var/lib/docker/aufs/mnt/image_ID 与 /var/lib/docker/aufs/diff/image_ID。回到 Create 函数中,执行完 createDirsFor 函数之后,随即在 aufs 目录下创建了 layers 目录,并在 layers 目录下创建 image_ID 文件。

如此一来,在 aufs 下的三个子目录 mnt,diff 以及 layers 中,分别创建了名为镜像名 image_ID 的文件。继续深入分析之前,我们直接来看 Docker 对这三个目录 mnt、diff 以及 layers 的描述,如图 11-2 所示:

图 11-2 aufs driver 目录结构图

简要分析图 11-2,图中的 layers、diff 以及 mnt 为目录 /var/lib/docker/aufs 下的三个子目录,1、2、3 是镜像 ID,分别代表三个镜像,三个目录下的 1 均代表同一个镜像 ID。其中 layers 目录下保留每一个镜像的元数据,这些元数据是这个镜像的祖先镜像 ID 列表;diff 目录下存储着每一个镜像所在的 layer,具体包含的文件系统内容;mnt 目录下每一个文件,都是一个镜像 ID,代表在该层镜像之上挂载的可读写 layer。因此,下载的镜像中与文件系统相关的具体内容,都会存储在 diff 目录下的某个镜像 ID 目录下。

再次回到 Create 函数,此时 mnt,diff 以及 layer 三个目录下的镜像 ID 文件已经创建完毕。下一步需要完成的是:为 layers 目录下的镜像 ID 文件填充元数据。元数据内容为该镜像所有的祖先镜像 ID 列表。填充元数据的流程如下:

(1) Docker Daemon 首先通过 f, err := os.Create(path.Join(a.rootPath(), "layers", id)) 打开 layers 目录下镜像 ID 文件;

(2) 然后,通过 ids, err := getParentIds(a.rootPath(), parent) 获取父镜像的祖先镜像 ID 列表 ids;

(3) 其次,将父镜像镜像 ID 写入文件 f;

(4) 最后,将父镜像的祖先镜像 ID 列表 ids 写入文件 f。

最终的结果是:该镜像的所有祖先镜像的镜像 ID 信息都写入 layers 目录下该镜像 ID 文件中。

4.2 mount 祖先镜像并返回根目录

Create 函数执行完毕,意味着创建镜像路径并配置镜像元数据完毕,接着 Docker Daemon 返回了镜像的根目录,源码实现如下:

rootfs, err := graph.driver.Get(img.ID, "")

Get 函数看似返回了镜像的根目录 rootfs,实则执行了更为重要的内容——挂载祖先镜像文件系统。具体而言,Docker Daemon 为当前层的镜像完成所有祖先镜像的 Union Mount。Mount 完毕之后,当前镜像的 read-write 层位于 /var/lib/docker/aufs/mnt/image_ID。Get 函数的具体实现位于./docker/daemon/graphdriver/aufs/aufs.go#L247-L278 ,如下:

func (a *Driver) Get(id, mountLabel string) (string, error) {
	ids, err := getParentIds(a.rootPath(), id)
	if err != nil {
		if !os.IsNotExist(err) {
			return "", err
		}
		ids = []string{}
	}

	// Protect the a.active from concurrent access
	a.Lock()
	defer a.Unlock()

	count := a.active[id]

	// If a dir does not have a parent ( no layers )do not try to mount
	// just return the diff path to the data
	out := path.Join(a.rootPath(), "diff", id)
	if len(ids) > 0 {
		out = path.Join(a.rootPath(), "mnt", id)

		if count == 0 {
			if err := a.mount(id, mountLabel); err != nil {
				return "", err
			}
		}
	}

	a.active[id] = count + 1

	return out, nil
}

分析以上 Get 函数的定义,可以得出以下内容:

(1) 函数名为 Get;

(2) 函数调用者类型为 Driver;

(3) 函数传入参数有两个:id 与 mountlabel;

(4) 函数返回内容有两部分:string 类型的镜像根目录与错误对象 error。

清楚 Get 函数的定义,再来看 Get 函数的实现。分析 Get 函数实现时,有三个部分较为关键,分别是 Driver 实例 a 的 active 属性、mount 操作、以及返回值 out。

首先分析 Driver 实例 a 的 active 属性。分析 active 属性之前,需要追溯到 Aufs 类型的 graphdriver 中 Driver 类型的定义以及 graphdriver 与 graph 的关系。两者的关系如图 11-3 所示:

图 11-3 graph 与 graphdriver 关系图

Driver 类型的定义位于./docker/daemon/graphdriver/aufs/aufs#L53-L57 ,如下:

type Driver struct {
	root       string
	sync.Mutex // Protects concurrent modification to active
	active     map[string]int
}

Driver 结构体中 root 属性代表 graphdriver 所在的根目录,为 /var/lib/docker/aufs。active 属性为 map 类型,key 为 string,具体运用时 key 为 Docker Image 的 ID,value 为 int 类型,代表该层镜像 layer 被引用的次数总和。Docker 镜像技术中,某一层 layer 的 Docker 镜像被引用一次,则 active 属性中 key 为该镜像 ID 的 value 值会累加 1。用户执行镜像删除操作时,Docker Dameon 会检查该 Docker 镜像的引用次数是否为 0,若引用次数为 0,则可以彻底删除该镜像,若不是的话,则仅仅将 active 属性中引用参数减 1。属性 sync.Mutex 用于多个 Job 同时操作 active 属性时,确保 active 数据的同步工作。

接着,进入 mount 操作的分析。一旦 Get 参数传入的镜像 ID 参数不是一个 Base Image,那么说明该镜像存在父镜像,Docker Daemon 需要将该镜像所有的祖先镜像都 mount 到指定的位置,指定位置为 /var/lib/docker/aufs/mnt/image_ID。所有祖先镜像的原生态文件系统内容分别位于 /var/lib/docker/aufs/diff/<ID>。其中 mount 函数用以实现该部分描述的功能,mount 的过程包含很多与 aufs 文件系统相关的参数配置与系统调用。

最后,Get 函数返回 out 与 nil。其中 out 的值为 /var/lib/docker/aufs/mnt/image_ID,即使用该层 Docker 镜像时其根目录所在路径,也可以认为是镜像的 RW 层所在路径,但一旦该层镜像之上还有镜像,那么在 mount 后者之后,在上层镜像看来,下层镜像仍然是只读文件系统。

5. 存储镜像内容

存储镜像内容,Docker Daemon 的运行意味着已经验证过镜像 ID,同时还为镜像准备了存储路径,并返回了其所有祖先镜像 union mount 后的路径。万事俱备,只欠“镜像内容的存储”。

Docker Daemon 存储镜像具体内容完成的工作很简单,仅仅是通过某种合适的方式将两部分内容存储于本地文件系统并进行有效管理,它们是:镜像压缩内容、镜像 json 信息。

存储镜像内容的源码实现位于./docker/graph/graph.go#L209-L211 ,如下:

if err := image.StoreImage(img, jsonData, layerData, tmp, rootfs); err != nil {
	return err
}

其中,StoreImage 函数的定义位于./docker/docker/image/image.go#L74 ,如下:

func StoreImage(img *Image, jsonData []byte, layerData 
archive.ArchiveReader, root, layer string) error {

分析 StoreImage 函数的定义,可以得出以下信息:

(1) 函数名称:StoreImage;

(2) 函数传入参数名:img,jsonData,layerData,root,layer;

(3) 函数返回类型 error。

简要分析传入参数的含义如表 11-1 所示:

表 11-1 StoreImage 函数参数表

参数名称

参数含义

img

通过下载的 imgJSON 信息创建出的 Image 对象实例

jsonData

Docker Daemon 之前下载的 imgJSON 信息

layerData

镜像作为一个 layer 的压缩包,包含镜像的具体文件内容

root

graphdriver 根目录下创建的临时文件”_tmp”, 值为 /var/lib/docker/aufs/_tmp

layer

Mount 完所有祖先镜像之后,该镜像在 mnt 目录下的路径

掌握 StoreImage 函数传入参数的含义之后,理解其实现就十分简单。总体而言,StoreImage 亦可以分为三个步骤:

(1) 解压镜像内容 layerData 至 diff 目录;

(2) 收集镜像所占空间大小,并记录;

(3) 将 jsonData 信息写入临时文件。

以下详细深入三个步骤的实现。

5.1 解压镜像内容

StoreImage 函数传入的镜像内容是一个压缩包,Docker Daemon 理应在镜像存储时将其解压,为后续创建容器时直接使用镜像创造便利。

既然是解压镜像内容,那么这项任务的完成,除了需要代表镜像的压缩包之后,还需要解压任务的目标路径,以及解压时的参数。压缩包为 StoreImage 传入的参数 layerData,而目标路径为 /var/lib/docker/aufs/diff/<image_ID>。解压流程的执行源代码位于./docker/docker/image/image.go#L85-L120 ,如下:

// If layerData is not nil, unpack it into the new layer
	if layerData != nil {
		if differ, ok := driver.(graphdriver.Differ); ok {
			if err := differ.ApplyDiff(img.ID, layerData); err != nil {
				return err
			}

			if size, err = differ.DiffSize(img.ID); err != nil {
				return err
			}
		} else {
			start := time.Now().UTC()
			log.Debugf("Start untar layer")
			if err := archive.ApplyLayer(layer, layerData); err != nil {
				return err
			}
			log.Debugf("Untar time: %vs", time.Now().UTC().Sub(start).Seconds())

			if img.Parent == "" {
				if size, err = utils.TreeSize(layer); err != nil {
					return err
				}
			} else {
				parent, err := driver.Get(img.Parent, "")
				if err != nil {
					return err
				}
				defer driver.Put(img.Parent)
				changes, err := archive.ChangesDirs(layer, parent)
				if err != nil {
					return err
				}
				size = archive.ChangesSize(layer, changes)
			}
		}
	}

可见当镜像内容 layerData 不为空时,Docker Daemon 需要为镜像压缩包执行解压工作。以 aufs 这种 graphdriver 为例,一旦 aufs driver 实现了 graphdriver 包中的接口 Diff,则 Docker Daemon 会使用 aufs driver 的接口方法实现后续的解压操作。解压操作的源代码如下:

if differ, ok := driver.(graphdriver.Differ); ok {
		if err := differ.ApplyDiff(img.ID, layerData); err != nil {
			return err
		}

		if size, err = differ.DiffSize(img.ID); err != nil {
			return err
		}
	}

以上代码即实现了镜像压缩包的解压,与镜像所占空间大小的统计。代码 differ.ApplyDiff(img.ID, layerData) 将 layerData 解压至目标路径。理清目标路径,且看 aufs 这个 driver 中 ApplyDiff 的实现,位于./docker/docker/daemon/graphdriver/aufs/aufs.go#L304-L306 ,如下:

	func (a *Driver) ApplyDiff(id string, diff archive.ArchiveReader) error {
		return archive.Untar(diff, path.Join(a.rootPath(), "diff", id), nil)
}

解压过程中,Docker Daemon 通过 aufs driver 的根目录 /var/lib/docker/aufs、diff 目录与镜像 ID,拼接出镜像的解压路径,并执行解压任务。举例说明 diff 文件的作用,镜像 27d474 解压后的内容如图 11-4 所示:

图 11-4 镜像解压后示意图

回到 StoreImage 函数的执行流中,ApplyDiff 任务完成之后,Docker Daemon 通过 DiffSize 开启镜像磁盘空间统计任务。

5.2 收集镜像大小并记录

Docker Daemon 接管镜像存储之后,Docker 镜像被解压到指定路径并非意味着“任务完成”。Docker Daemon 还额外做了镜像所占空间大小统计的空间,以便记录镜像信息,最终将这类信息传递给 Docker 用户。

镜像所占磁盘空间大小的统计与记录,实现过程简单且有效,源代码位于./docker/docker/image/image.go#L122-L125 ,如下:

	img.Size = size
	if err := img.SaveSize(root); err != nil {
		return err
	}

首先 Docker Daemon 将镜像大小收集起来,更新 Image 类型实例 img 的 Size 属性,然后通过 img.SaveSize(root) 将镜像大小写入 root 目录,由于传入的 root 参数为临时目录 _tmp,即写入临时目录 _tmp 下。深入 SaveSize 函数的实现,如以下源码:

func (img *Image) SaveSize(root string) error {
	if err := ioutil.WriteFile(path.Join(root, "layersize"), []
byte(strconv.Itoa(int(img.Size))), 0600); err != nil {
		return fmt.Errorf("Error storing image size in %s/layersize: %s", root, err)
	}
	return nil
}

SaveSize 函数在 root 目录(临时目录 /var/lib/docker/graph/_tmp)下创建文件 layersize,并写入镜像大小的值 img.Size。

5.3 存储 jsonData 信息

Docker 镜像中 jsonData 是一个非常重要的概念。在笔者看来,Docker 的镜像并非只是 Docker 容器文件系统中的文件内容,同时还包括 Docker 容器运行的动态信息。这里的动态信息更多的是为了适配 Dockerfile 的标准。以 Dockerfile 中的 ENV 参数为例,ENV 指定了 Docker 容器运行时,内部进程的环境变量。而这些只有容器运行时才存在的动态信息,并不会被记录在静态的镜像文件系统中,而是存储在以 jsonData 的形式先存储在宿主机的文件系统中,并与镜像文件系统做清楚的区分,存储在不同的位置。当 Docker Daemon 启动 Docker 容器时,Docker Daemon 会准备好 mount 完毕的镜像文件系统环境;接着加载 jsonData 信息,并在运行 Docker 容器内部进程时,使用动态的 jsonData 内部信息为容器内部进程配置环境。

当 Docker Daemon 下载 Docker 镜像时,关于每一个镜像的 jsonData 信息均会被下载至宿主机。通过以上 jsonData 的功能描述可以发现,这部分信息的存储同样扮演重要的角色。Docker Daemon 如何存储 jsonData 信息,实现源码位于./docker/docker/image/image.go#L128-L139 ,如下:

	if jsonData != nil {
		if err := ioutil.WriteFile(jsonPath(root), jsonData, 0600); err != nil {
			return err
		}
	} else {
		if jsonData, err = json.Marshal(img); err != nil {
			return err
		}
		if err := ioutil.WriteFile(jsonPath(root), jsonData, 0600); err != nil {
			return err
		}
	}

可见 Docker Daemon 将 jsonData 写入了文件 jsonPath(root) 中,并为该文件设置的权限为 0600。而 jsonPath(root) 的实现如下,即在 root 目录(/var/lib/docker/graph/_tmp 目录)下创建文件 json:

	func jsonPath(root string) string {
		return path.Join(root, "json")
}

镜像大小信息 layersize 信息统计完毕,jsonData 信息也成功记录,两者的存储文件均位于 /var/lib/docker/graph/_tmp 下,文件名分别为 layersize 和 json。使用临时文件夹来存储这部分信息并非偶然,11.6 节将阐述其中的原因。

6. 注册镜像 ID

Docker Daemon 执行完镜像的 StoreImage 操作,回到 Register 函数之后,执行镜像的 commit 操作,即完成镜像在 graph 中的注册。

注册镜像的代码实现位于./docker/docker/graph/graph.go#L212-L216 ,如下:

	// Commit
	if err := os.Rename(tmp, graph.ImageRoot(img.ID)); err != nil {
		return err
	}
	graph.idIndex.Add(img.ID)

11.5 节 StoreImage 过程中使用到的临时文件 _tmp 在注册镜像环节有所体现。镜像的注册行为,第一步就是将 tmp 文件(/var/lib/docker/graph/_tmp )重命名为 graph.ImageRoot(img.ID),实则为 /var/lib/docker/graph/<img.ID>。使得 Docker Daemon 在而后的操作中可以通过 img.ID 在 /var/lib/docker/graph 目录下搜索到相应镜像的 json 文件与 layersize 文件。

成功为 json 文件与 layersize 文件配置完正确的路径之后,Docker Daemon 执行的最后一个步骤为:添加镜像 ID 至 graph.idIndex。源代码实现是 graph.idIndex.Add(img.ID),graph 中 idIndex 类型为 *truncindex.TruncIndex, TruncIndex 的定义位于./docker/docker/pkg/truncindex/truncindex.go#L22-L28 ,如下:

// TruncIndex allows the retrieval of string identifiers by any of their unique prefixes.
// This is used to retrieve image and container IDs by more convenient shorthand prefixes.
type TruncIndex struct {
		sync.RWMutex
		trie *patricia.Trie
		ids  map[string]struct{}
}

Docker 用户使用 Docker 镜像时,一般可以通过指定镜像 ID 来定位镜像,如 Docker 官方的 mongo:2.6.1 镜像 id 为 c35c0961174d51035d6e374ed9815398b779296b5f0ffceb7613c8199383f4b1​,该 ID 长度为 64。当 Docker 用户指定运行这个 mongo 镜像 Repository 中 tag 为 2.6.1 的镜像时,完全可以通过 64 为的镜像 ID 来指定,如下:

docker run –it c35c0961174d51035d6e374ed9815398b779296b5f0ffceb7613c8199383f4b1​ /bin/bash

然而,记录如此长的镜像 ID,对于 Docker 用户来说稍显不切实际,而 TruncIndex 的概念则大大帮助 Docker 用户可以通过简短的 ID 定位到指定的镜像,使得 Docker 镜像的使用变得尤为方便。原理是:Docker 用户指定镜像 ID 的前缀,只要前缀满足在全局所有的镜像 ID 中唯一,则 Docker Daemon 可以通过 TruncIndex 定位到唯一的镜像 ID。而 graph.idIndex.Add(img.ID) 正式完成将 img.ID 添加保存至 TruncIndex 中。

为了达到上一条命令的效果,Docker 用户完全可以使用 TruncIndex 的方式,当然前提是 c35 这个字符串作为前缀全局唯一,命令如下:

docker run –it c35 /bin/bash

至此,Docker 镜像存储的整个流程已经完成。概括而言,主要包含了验证镜像、存储镜像、注册镜像三个步骤。

7. 总结

Docker 镜像的存储,使得 Docker Hub 上的镜像能够传播于世界各地变为现实。Docker 镜像在 Docker Registry 中的存储方式与本地化的存储方式并非一致。Docker Daemon 必须针对自身的 graphdriver 类型,选择适配的存储方式,实施镜像的存储。本章的分析,也在不断强调一个事实,即 Docker 镜像并非仅仅包含文件系统中的静态文件,除此之外还包含了镜像的 json 信息,json 信息中有 Docker 容器的配置信息,如暴露端口,环境变量等。

可以说 Docker 容器的运行强依赖于 Docker 镜像,Docker 镜像的由来就变得尤为重要。Docker 镜像的下载,Docker 镜像的 commit 以及 docker build 新的镜像,都无法跳出镜像存储的范畴。Docker 镜像的存储知识,也会有助于 Docker 其他概念的理解,如 docker commit、docker build 等。

8. 作者介绍

孙宏亮 DaoCloud 初创团队成员,软件工程师,浙江大学 VLIS 实验室应届研究生。读研期间活跃在 PaaS 和 Docker 开源社区,对 Cloud Foundry 有深入研究和丰富实践,擅长底层平台代码分析,对分布式平台的架构有一定经验,撰写了大量有深度的技术博客。2014 年末以合伙人身份加入 DaoCloud 团队,致力于传播以 Docker 为主的容器的技术,推动互联网应用的容器化步伐。邮箱: allen.sun@daocloud.io

参考文献

http://aufs.sourceforge.net/aufs.html


感谢郭蕾对本文的策划和审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

收藏

评论

微博

用户头像
发表评论

注册/登录 InfoQ 发表评论