Docker 源码分析(十):Docker 镜像下载

阅读数:5866 2015 年 4 月 13 日

1. 前言

说 Docker Image 是 Docker 体系的价值所在,没有丝毫得夸大其词。Docker Image 作为容器运行环境的基石,彻底解放了 Docker 容器创建的生命力,也激发了用户对于容器运用的无限想象力。

玩转 Docker,必然离不开 Docker Image 的支持。然而“万物皆有源”,Docker Image 来自何方,Docker Image 又是通过何种途径传输到用户机器,以致用户可以通过 Docker Image 创建容器?回忆初次接触 Docker 的场景,大家肯定对两条命令不陌生:docker pull 和 docker run。这两条命令中,正是前者实现了 Docker Image 的下载。Docker Daemon 在执行这条命令时,会将 Docker Image 从 Docker Registry 下载至本地,并保存在本地 Docker Daemon 管理的 graph 中。

谈及 Docker Registry,Docker 爱好者首先联想到的自然是Docker Hub。Docker Hub 作为 Docker 官方支持的 Docker Registry,拥有全球成千上万的 Docker Image。全球的 Docker 爱好者除了可以下载 Docker Hub 开放的镜像资源之外,还可以向 Docker Hub 贡献镜像资源。在 Docker Hub 上,用户不仅可以享受公有镜像带来的便利,而且可以创建私有镜像库。Docker Hub 是全国最大的 Public Registry,另外 Docker 还支持用户自定义创建 Private Registry。Private Registry 主要的功能是为私有网络提供 Docker 镜像的专属服务,一般而言,镜像种类适应用户需求,私密性较高,且不会占用公有网络带宽。

2. 本文分析内容安排

本文作为《Docker 源码分析》系列的第十篇——Docker 镜像下载篇,主要从源码的角度分析 Docker 下载 Docker Image 的过程。分析流程中,docker 的版本均为 1.2.0。

分析内容的安排如以下 4 部分:

(1) 概述 Docker 镜像下载的流程,涉及 Docker Client、Docker Server 与 Docker Daemon;

(2) Docker Client 处理并发送 docker pull 请求;

(3) Docker Server 接收 docker pull 请求,并创建镜像下载任务并触发执行;

(4) Docker Daemon 执行镜像下载任务,并存储镜像至 graph。

3.Docker 镜像下载流程

Docker Image 作为 Docker 生态中的精髓,下载过程中需要 Docker 架构中多个组件的协作。Docker 镜像的下载流程如图 3.1:

图 3.1 Docker 镜像下载流程图

如上图,下载流程,可以归纳为以上 3 个步骤:

(1) 用户通过 Docker Client 发送 pull 请求,作用为:让 Docker Daemon 下载指定名称的镜像;

(2) Docker Daemon 中负责 Docker API 请求的 Docker Server,接收 Docker 镜像的 pull 请求,创建下载镜像任务并触发执行;

(3) Docker Daemon 执行镜像下载任务,从 Docker Registry 中下载指定镜像,并将其存储与本地的 graph 中。

下文即从三个方面分析 docker pull 请求执行的流程。

4.Docker Client

Docker 架构中,Docker 用户的角色绝大多数由 Docker Client 来扮演。因此,用户对 Docker 的管理请求全部由 Docker Client 来发送,Docker 镜像下载请求自然也不例外。

为了更清晰的描述 Docker 镜像下载,本文结合具体的命令进行分析,如下:

docker pull ubuntu:14.04

以上的命令代表:用户通过 docker 二进制可执行文件,执行 pull 命令,镜像参数为 ubuntu:14.04,镜像名称为 ubuntu,镜像标签为 14.04。此命令一经触发,第一个接受并处理的 Docker 组件为 Docker Client,执行内容包括以下三个步骤:

(1) 解析命令中与 Docker 镜像相关的参数;

(2) 配置 Docker 下载镜像时所需的认证信息;

(3) 发送 RESTful 请求至 Docker Daemon。

4.1 解析镜像参数

通过 docker 二进制文件执行 docker pull ubuntu:14.04 时,Docker Client 首先会被创建,随后通过参数处理分析出请求类型 pull,最终执行 pull 请求相应的处理函数。关于 Docker Client 的创建与命令执行可以参见《Docker 源码分析》系列第二篇——Docker Client 篇

Docker Client 执行 pull 请求相应的处理函数,源码位于./docker/api/client/command.go#L1183-L1244,有关提取镜像参数的源码如下:

func (cli *DockerCli) CmdPull(args ...string) error {
	cmd := cli.Subcmd("pull", "NAME[:TAG]", "Pull an image or a repository from the registry")
	tag := cmd.String([]string{"#t", "#-tag"}, "", "Download tagged image in a repository")
	if err := cmd.Parse(args); err != nil {
		return nil
	}

	if cmd.NArg() != 1 {
		cmd.Usage()
		return nil
	}
	var (
		v      = url.Values{}
		remote = cmd.Arg(0)
	)

	v.Set("fromImage", remote)

	if *tag == "" {
		v.Set("tag", *tag)
	}

	remote, _ = parsers.ParseRepositoryTag(remote)
	// Resolve the Repository name from fqn to hostname + name
	hostname, _, err := registry.ResolveRepositoryName(remote)
	if err != nil {
		return err
	}
	……
}

结合命令 docker pull ubuntu:14.04,来分析 CmdPull 函数的定义,可以发现,该函数传入的形参为 args,实参只有一个字符串 ubuntu:14.04。另外,纵观以上源码,可以发现 Docker Client 解析的镜像参数无外乎 4 个:tag、remote、v 和 hostname,四者各自的作用如下:

  • tag:带有 Docker 镜像的标签;
  • remote:带有 Docker 镜像的名称与标签;
  • v:类型为 url.Values,实质是一个 map 类型,用于配置请求中 URL 的查询参数;
  • hostname:Docker Registry 的地址,代表用户希望从指定的 Docker Registry 下载 Docker 镜像。

4.1.1 解析 tag 参数

Docker 镜像的 tag 参数,是第一个被 Docker Client 解析的镜像参数,代表用户所需下载 Docker 镜像的标签信息,如:docker pull ubuntu:14.04 请求中镜像的 tag 信息为 14.04,若用户使用 docker pull ubuntu 请求下载镜像,没有显性指定 tag 信息时,Docker Client 会默认该镜像的 tag 信息为 latest。

Docker 1.2.0 版本除了以上的 tag 信息传入方式,依旧保留着代表镜像标签的 flag 参数 tag,而这个 flag 参数在 1.2.0 版本的使用过程中已经被遗弃,并会在之后新版本的 Docker 中被移除,因此在使用 docker 1.2.0 版本下载 Docker 镜像时,不建议使用 flag 参数 tag。传入 tag 信息的方式,建议使用 docker pull NAME[:TAG] 的形式。

Docker 1.2.0 版本依旧保留的 flag 参数 tag,其定义与解析的源码位于:./docker/api/client/commands.go#1185-L1188,如下:

tag := cmd.String([]string{"#t", "#-tag"}, "", "Download tagged image in a repository")
if err := cmd.Parse(args); err != nil {
		return nil
}

以上的源码说明:CmdPull 函数解析 tag 参数时,Docker Client 首先定义一个 flag 参数,flag 参数的名称为”#t”或者 “#-tag”,用途为:指定 Docker 镜像的 tag 参数,默认值为空字符串;随后通过 cmd.Parse(args) 的执行,解析 args 中的 tag 参数。

4.1.2 解析 remote 参数

Docker Client 解析完 tag 参数之后,同样需要解析出 Docker 镜像所属的 repository,如请求 docker pull ubuntu:14.04 中,Docker 镜像为 ubuntu:14.04,镜像的 repository 信息为 ubuntu,镜像的 tag 信息为 14.04。

Docker Client 通过解析 remote 参数,使得 remote 参数携带 repository 信息和 tag 信息。Docker Client 解析 remote 参数的第一个步骤,源码如下:

remote = cmd.Arg(0)

其中,cmd 的第一个参数赋值给 remote,以 docker pull ubuntu:14.04 为例,cmd.Arg(0) 为 ubuntu:14.04,则赋值后 remote 值为 ubuntu:14.04。此时 remote 参数即包含 Docker 镜像的 repository 信息也包含 tag 信息。若用户请求中带有 Docker Registry 的信息,如 docker pull localhost.localdomain:5000/docker/ubuntu:14.04,cmd.Arg(0) 为 localhost.localdomain:5000/docker/ubuntu:14.04,则赋值后 remote 值为 localhost.localdomain:5000/docker/ubuntu:14.04,此时 remote 参数同时包含 repository 信息、tag 信息以及 Docker Registry 信息。

随后,在解析 remote 参数的第二个步骤中,Docker Client 通过解析赋值完毕的 remote 参数,从中解析中 repository 信息,并再次覆写 remote 参数的值,源码如下:

remote, _ = parsers.ParseRepositoryTag(remote)

ParseRepositoryTag 的作用是:解析出 remote 参数的 repository 信息和 tag 信息,该函数的实现位于./docker/pkg/parsers/parsers.go#L72-L81,源码如下:

func ParseRepositoryTag(repos string) (string, string) {
		n := strings.LastIndex(repos, ":")
		if n < 0 {
			return repos, ""
		}
		if tag := repos[n+1:]; !strings.Contains(tag, "/") {
			return repos[:n], tag
		}
		return repos, ""
}

以上函数的实现过程,充分考虑了多种不同 Docker Registry 的情况,如:请求 docker pull ubuntu:14.04 中 remote 参数为 ubuntu:14.04,而请求 docker pull localhost.localdomain:5000/docker/ubuntu:14.04 中用户指定了 Docker Registry 的地址 localhost.localdomain:5000/docker,故 remote 参数还携带了 Docker Registry 信息。

ParseRepositoryTag 函数首先从 repos 参数的尾部往前寻找”:”,若不存在,则说明用户没有显性指定 Docker 镜像的 tag,返回整个 repos 作为 Docker 镜像的 repository;若”:”存在,则说明用户显性指定了 Docker 镜像的 tag,”:”前的内容作为 repository 信息,”:”后的内容作为 tag 信息,并返回两者。

ParseRepositoryTag 函数执行完,回到 CmdPull 函数,返回内容的 repository 信息将覆写 remote 参数。对于请求 docker pull localhost.localdomain:5000/docker/ubuntu:14.04,remote 参数被覆写后,值为 localhost.localdomain:5000/docker/ubuntu,携带 Docker Registry 信息以及 repository 信息。

4.1.3 配置 url.Values

Docker Client 发送请求给 Docker Server 时,需要为请求配置 URL 的查询参数。CmdPull 函数的执行过程中创建 url.Value 并配置的源码实现位于./docker/api/client/commands.go#L1194-L1203,如下:

var (
		v      = url.Values{}
		remote = cmd.Arg(0)
	)

	v.Set("fromImage", remote)

	if *tag == "" {
		v.Set("tag", *tag)
	}

其中,变量 v 的类型 url.Values,配置的 URL 查询参数有两个,分别为”fromImage”与”tag”,”fromImage”的值是 remote 参数没有被覆写时值,”tag”的值一般为空,原因是一般不使用 flag 参数 tag。

4.1.4 解析 hostname 参数

Docker Client 解析镜像参数时,还有一个重要的环节,那就是解析 Docker Registry 的地址信息。这意味着用户希望从指定的 Docker Registry 中下载 Docker 镜像。

解析 Docker Registry 地址的代码实现位于./docker/api/client/commands.go#L1207,如下:

hostname, _, err := registry.ResolveRepositoryName(remote)

Docker Client 通过包 registry 中的函数 ResolveRepositoryName 来解析 hostname 参数,传入的实参为 remote,即去 tag 化的 remote 参数。ResolveRepositoryName 函数的实现位于./docker/registry/registry.go#L237-L259,如下:

func ResolveRepositoryName(reposName string) (string, string, error) {
		if strings.Contains(reposName, "://") {
			// It cannot contain a scheme!
			return "", "", ErrInvalidRepositoryName
		}
		nameParts := strings.SplitN(reposName, "/", 2)
		if len(nameParts) == 1 
|| (!strings.Contains(nameParts[0], ".") && !strings.Contains(nameParts[0], ":") &&
		nameParts[0] != "localhost") {
			// This is a Docker Index repos (ex: samalba/hipache or ubuntu)
			err := validateRepositoryName(reposName)
			return IndexServerAddress(), reposName, err
		}
		hostname := nameParts[0]
		reposName = nameParts[1]
		if strings.Contains(hostname, "index.docker.io") {
			return "", "", fmt.Errorf("Invalid repository name, try \"%s\" instead", reposName)
		}
		if err := validateRepositoryName(reposName); err != nil {
			return "", "", err
		}

		return hostname, reposName, nil
}

ResolveRepositoryName 函数首先通过”/”分割字符串 reposName,如下:

nameParts := strings.SplitN(reposName, "/", 2)

如果 nameParts 的长度为 1,则说明 reposName 中不含有字符”/”,意味着用户没有指定 Docker Registry。另外,形如”samalba/hipache”的 reposName 同样说明用户并没有指定 Docker Registry。当用户没有指定 Docker Registry 时,Docker Client 默认返回 IndexServerAddress(),该函数返回常量 INDEXSERVER,值为”https://index.docker.io/v1”。也就是说,当用户下载 Docker 镜像时,若不指定 Docker Registry,默认情况下,Docker Client 通知 Docker Daemon 去 Docker Hub 上下载镜像。例如:请求 docker pull ubuntu:14.04,由于没有指定 Docker Registry,Docker Client 默认使用全球最大的 Docker Registry——Docker Hub。

当不满足返回默认 Docker Registry 时,Docker Client 通过解析 reposNames,得出用户指定的 Docker Registry 地址。例如:请求 docker pull localhost.localdomain:5000/docker/ubuntu:14.04 中,解析出的 Docker Registry 地址为 localhost.localdomain:5000。

至此,与 Docker 镜像相关的参数已经全部解析完毕,Docker Client 将携带这部分重要信息,以及用户的认证信息,构建 RESTful 请求,发送给 Docker Server。

4.2 配置认证信息

用户下载 Docker 镜像时,Docker 同样支持用户信息的认证。用户认证信息由 Docker Client 配置;Docker Client 发送请求至 Docker Server 时,用户认证信息也被一并发送;随后,Docker Daemon 处理下载 Docker 镜像请求时,用户认证信息在 Docker Registry 被验证。

Docker Client 配置用户认证信息包含两个步骤,实现源码如下:

cli.LoadConfigFile()
	// Resolve the Auth config relevant for this server
	authConfig := cli.configFile.ResolveAuthConfig(hostname)

可见,第一个步骤是使 cli(Docker Client)加载 ConfigFile,ConfigFile 是 Docker Client 用来存放有关 Docker Registry 的用户认证信息的对象。DockerCli、ConfigFile 以及 AuthConfig 三种数据结构之间的关系如图 4.1:

图 4.1 DockerCli、ConfigFile 以及 AuthConfig 关系图

DockerCli 结构体的属性 configFile 为一个指向 registry.ConfigFile 的指针,而 ConfigFile 结构体的属性 Configs 属于 map 类型,其中 key 为 string,代表 Docker Registry 的地址,value 的类型为 AuthConfig。AuthConfig 类型具体含义为用户在某个 Docker Registry 上的认证信息,包含用户名,密码,认证信息,邮箱地址等。

加载完用户所有的认证信息之后,Docker Client 第二个步骤是:通过用户指定的 Docker Registry,即之前解析出的 hostname 参数,从用户所有的认证信息中找出与指定 hostname 相匹配的认证信息。新创建的 authConfig,类型即为 AuthConfig,将会作为用户在指定 Docker Registry 上的认证信息,发送至 Docker Server。

4.3 发送 API 请求

Docker Client 解析完所有的 Docker 镜像参数,并且配置完毕用户的认证信息之后,Docker Client 需要使用这些信息正式发送镜像下载的请求至 Docker Server。

Docker Client 定义了 pull 函数,来实现发送镜像下载请求至 Docker Server,源码位于./docker/api/client/commands.go#L1217-L1229,如下:

pull := func(authConfig registry.AuthConfig) error {
		buf, err := json.Marshal(authConfig)
		if err != nil {
			return err
		}
		registryAuthHeader := []string{
			base64.URLEncoding.EncodeToString(buf),
		}

		return cli.stream("POST", "/images/create?"+v.Encode(), nil, cli.out, map[string][]string{
			"X-Registry-Auth": registryAuthHeader,
		})
	}

pull 函数的实现较为简单,首先通过 authConfig 对象,创建 registryAuthHeader,最后发送 POST 请求,请求的 URL 为"/images/create?"+v.Encode(),在 URL 中传入查询参数包括”fromImage”与”tag”,另外在请求的 HTTP Header 中添加认证信息 registryAuthHeader,。

执行以上的 pull 函数时,Docker 镜像下载请求被发送,随后 Docker Client 等待 Docker Server 的接收、处理与响应。

5.Docker Server

Docker Server 作为 Docker Daemon 的入口,所有 Docker Client 发送请求都由 Docker Server 接收。Docker Server 通过解析请求的 URL 与请求方法,最终路由分发至相应的 handler 来处理。Docker Server 的创建与请求处理,可以参看《Docker 源码分析》系列之 Docker Server 篇

Docker Server 接收到镜像下载请求之后,通过路由分发最终由具体的 handler——postImagesCreate 来处理。postImagesCreate 的实现位于./docker/api/server/server.go#L466-L524,的、其执行流程主要分为 3 个部分:

(1) 解析 HTTP 请求中包含的请求参数,包括 URL 中的查询参数、HTTP header 中的认证信息等;

(2) 创建镜像下载 job,并为该 job 配置环境变量;

(3) 触发执行镜像下载 job。

5.1 解析请求参数

Docker Server 接收到 Docker Client 发送的镜像下载请求之后,首先解析请求参数,并未后续 job 的创建与运行提供参数依据。Docker Server 解析的请求参数,主要有:HTTP 请求 URL 中的查询参数”fromImage”、”repo”以及”tag”,以及有 HTTP 请求的 header 中的”X-Registry-Auth”。

请求参数解析的源码如下:

var (
		image = r.Form.Get("fromImage")
		repo  = r.Form.Get("repo")
		tag   = r.Form.Get("tag")
		job   *engine.Job
	)
	authEncoded := r.Header.Get("X-Registry-Auth")

需要特别说明的是:通过”fromImage”解析出的 image 变量包含镜像 repository 名称与镜像 tag 信息。例如用户请求为 docker pull ubuntu:14.04,那么通过”fromImage”解析出的 image 变量值为 ubuntu:14.04,并非只有 Docker 镜像的名称。

另外,Docker Server 通过 HTTP header 中解析出 authEncoded,还原出类型为 registry.AuthConfig 的对象 authConfig,源码实现如下:

authConfig := &registry.AuthConfig{}
	if authEncoded != "" {
		authJson := base64.NewDecoder(base64.URLEncoding, strings.NewReader(authEncoded))
		if err := json.NewDecoder(authJson).Decode(authConfig); err != nil {
			// for a pull it is not an error if no auth was given
			// to increase compatibility with the existing api it is defaulting to be empty
			authConfig = &registry.AuthConfig{}
		}
	}

解析出 HTTP 请求中的参数之后,Docker Server 对于 image 参数,再次进行解析,从中解析出属于 repository 与 tag 信息,其中 repository 有可能暂时包含 Docker Registry 信息,源码实现如下:

if tag == "" {
		image, tag = parsers.ParseRepositoryTag(image)
	}

Docker Server 的参数解析工作至此全部完成,在这之后 Docker Server 将创建镜像下载任务并开始执行。

5.2 创建并配置 job

Docker Server 只负责接收 Docker Client 发送的请求,并将其路由分发至相应的 handler 来处理,最终的请求执行还是需要 Docker Daemon 来协作完成。Docker Server 在 handler 中,通过创建 job 并触发 job 执行的形式,把控制权交于 Docker Daemon。

Docker Server 创建镜像下载 job 并配置环境变量的源码实现如下:

job = eng.Job("pull", image, tag)
	job.SetenvBool("parallel", version.GreaterThan("1.3"))
	job.SetenvJson("metaHeaders", metaHeaders)
	job.SetenvJson("authConfig", authConfig)

其中,创建的 job 名为 pull,含义是下载 Docker 镜像,传入参数为 image 与 tag,配置的环境变量有 parallel、metaHeaders 与 authConfig。

5.3 触发执行 job

Docker Server 创建完 Docker 镜像下载 job 之后,需要触发执行该 job,实现将控制权交于 Docker Daemon。

Docker Server 触发执行 job 的源码如下:

if err := job.Run(); err != nil {
		if !job.Stdout.Used() {
			return err
		}
		sf := utils.NewStreamFormatter(version.GreaterThan("1.0"))
		w.Write(sf.FormatError(err))
	}

由于 Docker Daemon 在启动时,已经配置了名为”pull”的 job 所对应的 handler,实际为 graph 包中的 CmdPull 函数,故一旦该 job 被触发执行,控制权将直接交于 Docker Daemon 的 CmdPull 函数。Docker Daemon 启动时 Engine 的 handler 注册,可以参见《Docker 源码分析》系列的第三篇——Docker Daemon 启动篇

6.Docker Daemon

Docker Daemon 是完成 job 执行的主要载体。Docker Server 为镜像下载 job 准备好所有的参数配置之后,只等 Docker Daemon 来完成执行,并返回相应的信息,Docker Server 再将响应信息返回至 Docker Client。Docker Daemon 对于镜像下载 job 的执行,涉及的内容较多:首先解析 job 参数,获取 Docker 镜像的 repository、tag、Docker Registry 信息等;随后与 Docker Registry 建立 session;然后通过 session 下载 Docker 镜像;接着将 Docker 镜像下载至本地并存储于 graph;最后在 TagStore 标记该镜像。

Docker Daemon 对于镜像下载 job 的执行主要依靠 CmdPull 函数。这个 CmdPull 函数与 Docker Client 的 CmdPull 函数完全不同,前者是为了代替用户发送镜像下载的请求至 Docker Daemon,而 Docker Daemon 的 CmdPull 函数则是实现代替用户真正完全镜像下载的任务。调用 CmdPull 函数的对象类型为 TagStore,其源码实现位于./docker/graph/pull.go

6.1 解析 job 参数

正如 Docker Client 与 Docker Server,Docker Daemon 执行镜像下载 job 时的第一个步骤也是解析参数。解析工作一方面确保传入参数无误,另一方面按需为 job 提供参数依据。表 6.1 罗列 Docker Daemon 解析的 job 参数,如下:

表 6.1 Docker Daemon 解析 job 参数列表

参数名称

参数描述

localName

代表镜像的 repository 信息,有可能携带 Docker Registry 信息

tag

代表镜像的标签信息,默认为 latest

authConfig

代表用户在指定 Docker Registry 上的认证信息

metaHeaders

代表请求中的 header 信息

hostname

代表 Docker Registry 信息,从 localName 解析获得,默认为 Docker Hub 地址

remoteName

代表 Docker 镜像的 repository 名称信息,不携带 Docker Registry 信息

endpoint

代表 Docker Registry 完整的 URL,从 hostname 扩展获得

参数解析过程中,Docker Daemon 还添加了一些精妙的设计。如:在 TagStore 类型中设计了 pullingPool 对象,用于保存正在被下载的 Docker 镜像,下载完毕之前禁止其他 Docker Client 发起相同镜像的下载请求,下载完毕之后 pullingPool 中的该记录被清除。Docker Daemon 一旦解析出 localName 与 tag 两个参数信息,则立即检测 pullingPool,实现源码位于./docker/graph/pull.go#L36-L46,如下:

c, err := s.poolAdd("pull", localName+":"+tag)
	if err != nil {
		if c != nil {
			// Another pull of the same repository is already taking place; just wait for it to finish
			job.Stdout.Write(sf.FormatStatus("", "Repository %s already being pulled by another client. Waiting.", localName))
			<-c
			return engine.StatusOK
		}
		return job.Error(err)
	}
	defer s.poolRemove("pull", localName+":"+tag)

6.2 创建 session 对象

下载 Docker 镜像,Docker Daemon 与 Docker Registry 需要建立通信。为了保障两者通信的可靠性,Docker Daemon 采用了 session 机制。Docker Daemon 每收到一个 Docker Client 的镜像下载请求,都会创建一个与相应 Docker Registry 的 session,之后所有的网络数据传输都在该 session 上完成。包 registry 定义了 session,位于./docker/registry/registry.go,如下:

type Session struct {
		authConfig    *AuthConfig
		reqFactory    *utils.HTTPRequestFactory
		indexEndpoint string
		jar           *cookiejar.Jar
		timeout       TimeoutType
}

CmdPull 函数中创建 session 的源码实现如下:

r, err := registry.NewSession(authConfig, registry.HTTPRequestFactory
(metaHeaders), endpoint, true)

创建的 session 对象为 r,在下一阶段的镜像下载过程中,多数与镜像相关的数据传输均在 r 这个 seesion 的基础上完成。

6.3 执行镜像下载

Docker Daemon 之前所有的操作,都属于配置阶段,从解析 job 参数,到建立 session 对象,而并未与 Docker Registry 建立实际的连接,并且也还未真正传输过有关 Docker 镜像的内容。

完成所有的配置之后,Docker Daemon 进入 Docker 镜像下载环节,实现 Docker 镜像下载的源码位于./docker/graph/pull.go#L69-L71,如下:

if err = s.pullRepository(r, job.Stdout, localName, remoteName, tag, sf, 
job.GetenvBool("parallel")); err != nil {
		return job.Error(err)
	}

以上代码中 pullRepository 函数包含了镜像下载整个流程的林林总总,该流程可以参见图 6.1:

图 6.1 pullRepository 流程图

关于上图的各个环节,下表给出简要的功能介绍:

表 6.2 pullRepository 各环节功能介绍表

函数名称

功能介绍

r.GetRepositoryData()

获取指定 repository 中所有 image 的 id 信息

r.GetRemoteTags()

获取指定 repository 中所有的 tag 信息

r.pullImage()

从 Docker Registry 下载 Docker 镜像

r.GetRemoteHistory()

获取指定 image 所有祖先 image id 信息

r.GetRemoteImageJSON()

获取指定 image 的 json 信息

r.GetRemoteImageLayer()

获取指定 image 的 layer 信息

s.graph.Register()

将下载的镜像在 TagStore 的 graph 中注册

s.Set()

在 TagStore 中添加新下载的镜像信息

分析 pullRepository 的整个流程之前,很有必要了解下 pullRepository 函数调用者的类型 TagStore。TagStore 是 Docker 镜像方面涵盖内容最多的数据结构:一方面 TagStore 管理 Docker 的 Graph,另一方面 TagStore 还管理 Docker 的 repository 记录。除此之外,TagStore 还管理着上文提到的对象 pullingPool 以及 pushingPool,保证 Docker Daemon 在同一时刻,只为一个 Docker Client 执行同一镜像的下载或上传。TagStore 结构体的定义位于./docker/graph/tags.go#L20-L29,如下:

type TagStore struct {
		path         string
		graph        *Graph
		Repositories map[string]Repository
		sync.Mutex
		// FIXME: move push/pull-related fields
		// to a helper type
		pullingPool map[string]chan struct{}
		pushingPool map[string]chan struct{}
}

以下将重点分析 pullRepository 的整个流程。

6.3.1 GetRepositoryData

使用 Docker 下载镜像时,用户往往指定的是 Docker 镜像的名称,如:请求 docker pull ubuntu:14.04 中镜像名称为 ubuntu。GetRepositoryData 的作用则是获取镜像名称所在 repository 中所有 image 的 id 信息。

GetRepositoryData 的源码实现位于./docker/registry/session.go#L255-L324。获取 repository 中 image 的 ID 信息的目标 URL 地址如以下源码:

repositoryTarget := fmt.Sprintf("%srepositories/%s/images", indexEp, remote)

因此,docker pull ubuntu:14.04 请求被执行时,repository 的目标 URL 地址为https://index.docker.io/v1/repositories/ubuntu/images,访问该 URL 可以获得有关 ubuntu 这个 repository 中所有 image 的 id 信息,部分 image 的 id 信息如下:

[{"checksum": "", "id": "
2427658c75a1e3d0af0e7272317a8abfaee4c15729b6840e3c2fca342fe47bf1"}, 
{"checksum": "", "id": 
"81fbd8fa918a14f4ebad9728df6785c537218279081c7a120d72399d3a5c94a5"
}, {"checksum": "", "id": 
"ec69e8fd6b0236b67227869b6d6d119f033221dd0f01e0f569518edabef3b72c"
}, {"checksum": "", "id": 
"9e8dc15b6d327eaac00e37de743865f45bee3e0ae763791a34b61e206dd5222e"
}, {"checksum": "", "id": 
"78949b1e1cfdcd5db413c300023b178fc4b59c0e417221c0eb2ffbbd1a4725cc"
},……]

获取以上信息之后,Docker Daemon 通过 RepositoryData 和 ImgData 类型对象来存储 ubuntu 这个 repository 中所有 image 的信息,RepositoryData 和 ImgData 的数据结构关系如图 6.2:

图 6.2 RepositoryData 和 ImgData 的数据结构关系图

GetRepositoryData 执行过程中,会为指定 repository 中的每一个 image 创建一个 ImgData 对象,并最终将所有 ImgData 存放在 RepositoryData 的 ImgList 属性中,ImgList 的类型为 map,key 为 image 的 ID,value 指向 ImgData 对象。此时 ImgData 对象中只有属性 ID 与 Checksum 有内容。

6.3.2 GetRemoteTags

使用 Docker 下载镜像时,用户除了指定 Docker 镜像的名称之外,一般还需要指定 Docker 镜像的 tag,如:请求 docker pull ubuntu:14.04 中镜像名称为 ubuntu,镜像 tag 为 14.04,假设用户不显性指定 tag,则默认 tag 为 latest。GetRemoteTags 的作用则是获取镜像名称所在 repository 中所有 tag 的信息。

GetRemoteTags 的源码实现位于./docker/registry/session.go#L195-234。获取 repository 中所有 tag 信息的目标 URL 地址如以下源码:

endpoint := fmt.Sprintf("%srepositories/%s/tags", host, repository)

获取指定 repository 中所有 tag 信息之后,Docker Daemon 根据 tag 对应 layer 的 ID,找到 ImgData,并对填充 ImgData 中的 Tag 属性。此时,RepositoryData 的 ImgList 属性中,有的 ImgData 对象有 Tag 内容,有的 ImgData 对象中没有 Tag 内容。这也和实际情况相符,如下载一个 ubuntu:14.04 镜像,该镜像的 rootfs 中只有最上层的 layer 才有 tag 信息,这一层 layer 的 parent Image 并不一定存在 tag 信息。

6.3.3 pullImage

Docker Daemon 下载 Docker 镜像时是通过 image id 来完成。GetRepositoryData 和 GetRemoteTags 则成功完成了用户传入的 repository 和 tag 信息与 image id 的转换。如请求 docker pull ubuntu:14.04 中,repository 为 ubuntu,tag 为 14.04,则对应的 image id 为 2d24f826。

Docker Daemon 获得下载镜像的 image id 之后,首先查验 pullingPool,判断是否有其他 Docker Client 同样发起了该镜像的下载请求,如果没有的话 Docker Daemon 才继续下载任务。

执行 pullImage 函数的源码实现位于./docker/graph/pull.go#L159,如下:

s.pullImage(r, out, img.ID, ep, repoData.Tokens, sf)

而 pullImage 函数的定义位于./docker/graph/pull.go#L214-L301。图 6.1 中,可以看到 pullImage 函数的执行可以分为 4 个步骤:GetRemoteHistory、GetRemoteImageJson、GetRemoteImageLayer 与 s.graph.Register()。

GetRemoteHistory 的作用很好理解,既然 Docker Daemon 已经通过 GetRepositoryData 和 GetRemoteTags 找出了指定 tag 的 image id,那么 Docker Daemon 所需完成的工作为下载该 image 及其所有的祖先 image。GetRemoteHistory 正是用于获取指定 image 及其所有祖先 iamge 的 id。

GetRemoteHistory 的源码实现位于./docker/registry/session.go#L72-L101

获取所有的 image id 之后,对于每一个 image id,Docker Daemon 都开始下载该 image 的全部内容。Docker Image 的全部内容包括两个方面:image json 信息以及 image layer 信息。Docker 所有 image 的 json 信息都由函数 GetRemoteImageJSON 来完成。分析 GetRemoteImageJSON 之前,有必要阐述清楚什么是 Docker Image 的 json 信息。

Docker Image 的 json 信息是一个非常重要的概念。这部分 json 唯一的标志了一个 image,不仅标志了 image 的 id,同时也标志了 image 所在 layer 对应的 config 配置信息。理解以上内容,可以举一个例子:docker build。命令 docker build 用以通过指定的 Dockerfile 来创建一个 Docker 镜像;对于 Dockerfile 中所有的命令,Docker Daemon 都会为其创建一个新的 image,如:RUN apt-get update, ENV path=/bin, WORKDIR /home 等。对于命令 RUN apt-get update,Docker Daemon 需要执行 apt-get update 操作,对应的 rootfs 上必定会有内容更新,导致新建的 image 所代表的 layer 中有新添加的内容。而如 ENV path=/bin, WORKDIR /home 这样的命令,仅仅是配置了一些容器运行的参数,并没有镜像内容的更新,对于这种情况,Docker Daemon 同样创建一层新的 layer,并且这层新的 layer 中内容为空,而命令内容会在这层 image 的 json 信息中做更新。总结而言,可以认为 Docker 的 image 包含两部分内容:image 的 json 信息、layer 内容。当 layer 内容为空时,image 的 json 信息被更新。

清楚了 Docker image 的 json 信息之后,理解 GetRemoteImageJSON 函数的作用就变得十分容易。GetRemoteImageJSON 的执行代码位于./docker/graph/pull.go#L243,如下:

imgJSON, imgSize, err = r.GetRemoteImageJSON(id, endpoint, token)

GetRemoteImageJSON 返回的两个对象 imgJSON 代表 image 的 json 信息,imgSize 代表镜像的大小。通过 imgJSON 对象,Docker Daemon 立即创建一个 image 对象,创建 image 对象的源码实现位于./docker/graph/pull.go#L251,如下:

img, err = image.NewImgJSON(imgJSON)

而 NewImgJSON 函数位于包 image 中,函数返回类型为一个 Image 对象,而 Image 类型的定义而下:

type Image struct {
	ID             string            `json:"id"`
	Parent          string            `json:"parent,omitempty"`
	Comment       string            `json:"comment,omitempty"`
	Created         time.Time         `json:"created"`
	Container       string            `json:"container,omitempty"`
	ContainerConfig  runconfig.Config  `json:"container_config,omitempty"`
	DockerVersion    string            `json:"docker_version,omitempty"`
	Author          string            `json:"author,omitempty"`
	Config          *runconfig.Config `json:"config,omitempty"`
	Architecture     string            `json:"architecture,omitempty"`
	OS             string            `json:"os,omitempty"`
	Size            int64

	graph Graph
}

返回 img 对象,则说明关于该 image 的所有元数据已经保存完毕,由于还缺少 image 的 layer 中包含的内容,因此下一个步骤即为下载镜像 layer 的内容,调用函数为 GetRemoteImageLayer,函数执行位于./docker/graph/pull.go#L270,如下:

layer, err := r.GetRemoteImageLayer(img.ID, endpoint, token, int64(imgSize))

GetRemoteImageLayer 函数返回当前 image 的 layer 内容。Image 的 layer 内容指的是:该 image 在 parent image 之上做的文件系统内容更新,包括文件的增添、删除、修改等。至此,image 的 json 信息以及 layer 内容均被 Docker Daemon 获取,意味着一个完整的 image 已经下载完毕。下载 image 完毕之后,并不意味着 Docker Daemon 关于 Docker 镜像下载的 job 就此结束,Docker Daemon 仍然需要对下载的 image 进行存储管理,以便 Docker Daemon 在执行其他如创建容器等 job 时,能够方便使用这些 image。

Docker Daemon 在 graph 中注册 image 的源码实现位于./docker/graph/pull.go#L283-L285,如下:

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

Docker Daemon 通过 graph 存储 image 是一个很重要的环节。Docker 在 1.2.0 版本中可以通过 AUFS、DevMapper 以及 BTRFS 来进行 image 的存储。在 Linux 3.18-rc2 版本中,OverlayFS 已经被内核合并,故从 1.4.0 版本开始,Docker 的 image 支持 OverlayFS 的存储方式。

Docker 镜像的存储在 Docker 中是较为独立且重要的内容,故将在《Docker 源码分析》系列的第十一篇专文分析。

6.3.4 配置 TagStore

Docker 镜像下载完毕之后,Docker Daemon 需要在 TagStore 中指定的 repository 中添加相应的 tag。每当用户查看本地镜像时,都可以从 TagStore 的 repository 中查看所有含有 tag 信息的 image。

Docker Daemon 配置 TagStore 的源码实现位于./docker/graph/pull.go#L206,如下:

if err := s.Set(localName, tag, id, true); err != nil {
		return err
	}

TagStore 类型的 Set 函数定义位于./docker/graph/tags.go#L174-L205。Set 函数的指定流程与简要介绍如图 6.3:

图 6.3 TagStore 中 Set 函数执行流程图

当 Docker Daemon 将已下载的 Docker 镜像信息同步到 repository 之后,Docker 下载镜像的 job 就全部完成,Docker Daemon 返回响应至 Docker Server,Docker Server 返回相应至 Docker Client。注:本地的 repository 文件位于 Docker 的根目录,根目录一般为 /var/lib/docker,如果使用 aufs 的 graphdriver,则 repository 文件名为 repositories-aufs。

7. 总结

Docker 镜像给 Docker 容器的运行带来了无限的可能性,诸如 Docker Hub 之类的 Docker Registry 又使得 Docker 镜像在全球的开发者之间共享。Docker 镜像的下载,作为使用 Docker 的第一个步骤,Docker 爱好者若能熟练掌握其中的原理,必定能对 Docker 的很多概念有更为清晰的认识,对 Docker 容器的运行、管理等均是有百利而无一害。

Docker 镜像的下载需要 Docker Client、Docker Server、Docker Daemon 以及 Docker Registry 四者协同合作完成。本文从源码的角度分析了四者各自的扮演的角色,分析过程中还涉及多种 Docker 概念,如 repository、tag、TagStore、session、image、layer、image json、graph 等。

8. 作者介绍

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

9. 参考文献

https://docs.docker.com/terms/image/

https://docs.docker.com/terms/layer

http://docs.studygolang.com/pkg/


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

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

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论