镜像仓库Harbor Pull实现原理

Harbor简介

HarborVMWare 开源的一个镜像仓库平台,镜像分发和存储逻辑是基于 docker 开源的 registry (后更名为 distribution)实现的,Harbor 主要在其之上扩展了权限管理、镜像扫描、可视化管理后台等平台化功能,和大部分的云原生组件一样,Harbor 也是基于 Go 语言开发的。

Harbor 的系统架构如下图所示,箭头所指方向为数据流向:

image-20211119181205666

一个镜像仓库最核心的功能就是推送拉取,本文主要讨论 Harbor 镜像拉取具体的实现细节。

在开始之前

我们先申明一些基本信息,避免版本更迭引入的改动给大家带来困扰:

Harbor Pull 实现原理

我们可以先尝试pull harbor.huweicai.com/test/nginx:1.20.2-alpine这个镜像,从外部观察一下 pull的行为,我们可以在终端看到一段这样的输出,直观的看起来就是,这个镜像的被存储在多个对象中,可以并行拉取:

image-20211125164032898

为了进一步的了解其中发生了什么,我们可以给docker daemon进程(不是 docker cli 命令行工具,真正执行镜像拉取动作的是 daemon 进程所以给 cli 设置代理没有用)设置代理到 Charles 或者 Fiddler等七层抓包工具来分析,可以看到dockerharbor共发送了如下请求:

image-20211125174102116

harbor 这边对于请求的处理的链路大概是这样的:流量从 nginx 代理进来匹配规则后转发到 core服务(还有一部分前端页面流量会被转发到portal服务),然后core会从 redispg 中查询它所想要的数据,同时有可能会将流量再进一步转发给 registryregistry则会再继续调用s3redis完成整个请求的处理逻辑。

image-20211206005905550

这些请求可以被大致被归类一下,整理成下面四个接口:

  • /v2/
  • /service/token
  • /v2/*/manifests/*
  • /v2/*/blobs/*

下面我们就顺着这些接口,探究一下每一个接口背后harbor都是怎么实现的。

/v2/接口

/v2/接口harbor并不会做任何的处理,会直接透传给registry,而registry这一侧也没有什么特殊的逻辑,铁定会返回 401 ,这里稍微展开一下讲一下整体的认证流程:

其实整个 harbor 系统里面是分为两层认证的,首先 harbor 自己有一套账号系统,存在数据库中,然后 registry是一套独立的组件,也有一套自己的认证,不过用户只会感知到第一层harbor的账号,然后habor初始化的时候会和registry一起配置一个固定密码的用户进行它到registry的认证。

image-20211125195538356

所以当用户的请求透传到 registry,registry 肯定是不认 harbor 的凭证的,这个接口最大的意义在于和客户端协商API版本,当前版本的 registry 这里会返回一个 HTTP Header: Docker-Distribution-Api-Version: registry/2.0告知客户端使用的是 v2版本的接口,而v1版本该接口会直接404,客户端就将降级调用老版本的接口来进行之后的流程。

/service/token接口

401之后,紧接着docker客户端就会向镜像中心发起登录请求,根据约定,客户端传入自己的账号和密码调用/service/token接口,这个接口对应的代码实现在这个包内:score/service/token

这个接口的逻辑也非常简单主要是做authentication 的事情,即鉴别用户的身份,然后返回一个JWTToken 做为凭证进行接下来的 pull操作。

你在 docker login 时传入的账号和密码,会通过 BasicAuth 的方式传递给 harbor,harbor会根据预先配置好的鉴权方式,如:LDAP、数据库、OIDC等,对账号密码进行比对,通过之后就会将用户名签在JWTToken中返回,整体请求截图如下:

image-20211130002448712

解析出来的Token有效载荷如下,其中 sub 字段就是账号:

{
        "iss": "harbor-token-issuer",
        "sub": "huweicai002",
        "aud": "harbor-registry",
        "exp": 1638201912,
        "nbf": 1638200112,
        "iat": 1638200112,
        "jti": "nAP6k77dhMa25at7",
        "access": null
    }

有一种特殊的场景,当没传账号密码时harbor也会给它签发一个 Token 的,当一个项目的权限配置成公开时,其镜像这种 anonymous 的用户也是能 pull 的。

做完authentication之后,这个接口的逻辑就到此结束了,而authorization的操作则会发生在之后的每一次请求当中,harbor 里面有一个 middleware:/src/server/middleware/v2auth.auth.go,会根据 JWT Token 中的用户,以及要访问的项目,进行 rbac 权限校验,如果权限校验失败,就不会有下面的流程了。

因为鉴权的操作每次请求都是独立进行的,所以有时候我们会看到一个让人困惑的情况,比如下图中的例子,我们尝试推送一个镜像一开始提示层已存在,看起来流程是成功开始走了,却紧接着又接着来了一个提示无权限。这是因为每个对象push之前都会调用一个 HEAD判断对象是否存在,避免重复推送,所以一开始的读请求有权限,而之后真正尝试写入对象文件的时候就报错了。

image-20211130010810449

/v2/*/manifests/*接口

manifest

完成认证之后,拉取镜像的下一步就是拉取镜像的描述文件了,专有名词叫做:manifestmanifest 主要包含了镜像的元信息配置和文件层对象的摘要,现在基本都是v2版本格式,相对v1来说更通用,还可以塞入一些拓展的信息。

以我们分析的镜像为例,它的 manifest 文件内容如下,完整的 manifest 格式定义:manifest-v2-2

{
	"schemaVersion": 2,
	  // manifest 格式
	"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
	"config": {
  	  // 镜像配置文件格式
		"mediaType": "application/vnd.docker.container.image.v1+json",
			// 镜像配置文件大小,单位字节
    "size": 8881,
    	// 镜像配置文件摘要
		"digest": "sha256:7d899ed163ee9e7979519c4c00be7e38a2cd4b8b8936524f1a8e29c1cd3a0c7a" 
	},
  	// 镜像对应的所有压缩包文件地址
	"layers": [{
      	// 申明了该文件的格式:tar + gzip
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", 
    	// 该层的大小,单位字节
		"size": 2830948, 
		"digest": "sha256:5420e0d28c84ecb16748cb90cc6acf8e2a81dab10cb1f674f3eee8533e53c62a"
	}, {
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
		"size": 7702134,
		"digest": "sha256:74b3565ce580d6e680e17fead073137d59b59694df894b9690f3f9b75dfe60d7"
	}, {
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
		"size": 599,
		"digest": "sha256:dd5b8f6431c1707d7ddc8fd895e7e5619d16e83967e03eaa4aa9c06abcbbc10f"
	}, {
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
		"size": 894,
		"digest": "sha256:bed80a240c44c78e884ae1d21f37232ef15c023250092c116ffa5076ec4fefe1"
	}, {
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
		"size": 667,
		"digest": "sha256:1852ff466a070dd053cd338926ac82d0def499381ced74e6ac671d284a8a394b"
	}, {
		"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
		"size": 1393,
		"digest": "sha256:a2ca5eee54a63f8128554d3b810ce1aebba37db5c311e7093a18bf7bd93f5575"
	}]
}

所有的文件对象真实地址都是按摘要来组织的,格式:https://{{HOST}}/v2/{{REPOSITORY}}/blobs/{{DIGEST}},这样客户端就可以根据 manifest 中的内容拼接出真正的镜像文件层地址然后下载下来。

元信息配置存储在另一个单独的文件中,可以理解为把整个DokcerFile结构化的存储了起来,上文manifest中引用的配置文件内容如下:

点击展开详情

{
  "architecture": "386",
  "config": {
    "Hostname": "",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "ExposedPorts": {
      "80/tcp": {}
    },
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "NGINX_VERSION=1.20.2",
      "NJS_VERSION=0.7.0",
      "PKG_RELEASE=1"
    ],
    "Cmd": [
      "nginx",
      "-g",
      "daemon off;"
    ],
    "Image": "sha256:a4509f43c92a27f6d2a8fe06339b2445babfc36d5a2f9df99189c5199e63d270",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": [
      "/docker-entrypoint.sh"
    ],
    "OnBuild": null,
    "Labels": {
      "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
    },
    "StopSignal": "SIGQUIT"
  },
  "container": "a7c29e632638b45adad89466df474fa144b4b374e7f08e1d10f207482aad659b",
  "container_config": {
    "Hostname": "a7c29e632638",
    "Domainname": "",
    "User": "",
    "AttachStdin": false,
    "AttachStdout": false,
    "AttachStderr": false,
    "ExposedPorts": {
      "80/tcp": {}
    },
    "Tty": false,
    "OpenStdin": false,
    "StdinOnce": false,
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "NGINX_VERSION=1.20.2",
      "NJS_VERSION=0.7.0",
      "PKG_RELEASE=1"
    ],
    "Cmd": [
      "/bin/sh",
      "-c",
      "#(nop) ",
      "CMD [\"nginx\" \"-g\" \"daemon off;\"]"
    ],
    "Image": "sha256:a4509f43c92a27f6d2a8fe06339b2445babfc36d5a2f9df99189c5199e63d270",
    "Volumes": null,
    "WorkingDir": "",
    "Entrypoint": [
      "/docker-entrypoint.sh"
    ],
    "OnBuild": null,
    "Labels": {
      "maintainer": "NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e"
    },
    "StopSignal": "SIGQUIT"
  },
  "created": "2021-11-16T18:03:33.536601336Z",
  "docker_version": "20.10.7",
  "history": [
    {
      "created": "2021-11-12T16:38:42.672361231Z",
      "created_by": "/bin/sh -c #(nop) ADD file:403428c2903dd9dea10d069185873cb2c2c3149c553797807c69f22aa3d12fe3 in / "
    },
    {
      "created": "2021-11-12T16:38:42.978865393Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"/bin/sh\"]",
      "empty_layer": true
    },
    {
      "created": "2021-11-13T04:17:59.524317112Z",
      "created_by": "/bin/sh -c #(nop)  LABEL maintainer=NGINX Docker Maintainers \u003cdocker-maint@nginx.com\u003e",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T17:59:29.135060866Z",
      "created_by": "/bin/sh -c #(nop)  ENV NGINX_VERSION=1.20.2",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T17:59:29.338465998Z",
      "created_by": "/bin/sh -c #(nop)  ENV NJS_VERSION=0.7.0",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T17:59:29.560393518Z",
      "created_by": "/bin/sh -c #(nop)  ENV PKG_RELEASE=1",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T18:03:31.76653876Z",
      "created_by": "/bin/sh -c set -x     \u0026\u0026 addgroup -g 101 -S nginx     \u0026\u0026 adduser -S -D -H -u 101 -h /var/cache/nginx -s /sbin/nologin -G nginx -g nginx nginx     \u0026\u0026 apkArch=\"$(cat /etc/apk/arch)\"     \u0026\u0026 nginxPackages=\"         nginx=${NGINX_VERSION}-r${PKG_RELEASE}         nginx-module-xslt=${NGINX_VERSION}-r${PKG_RELEASE}         nginx-module-geoip=${NGINX_VERSION}-r${PKG_RELEASE}         nginx-module-image-filter=${NGINX_VERSION}-r${PKG_RELEASE}         nginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-r${PKG_RELEASE}     \"     \u0026\u0026 apk add --no-cache --virtual .checksum-deps         openssl     \u0026\u0026 case \"$apkArch\" in         x86_64|aarch64)             set -x             \u0026\u0026 KEY_SHA512=\"e7fa8303923d9b95db37a77ad46c68fd4755ff935d0a534d26eba83de193c76166c68bfe7f65471bf8881004ef4aa6df3e34689c305662750c0172fca5d8552a *stdin\"             \u0026\u0026 wget -O /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub             \u0026\u0026 if [ \"$(openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout | openssl sha512 -r)\" = \"$KEY_SHA512\" ]; then                 echo \"key verification succeeded!\";                 mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/;             else                 echo \"key verification failed!\";                 exit 1;             fi             \u0026\u0026 apk add -X \"https://nginx.org/packages/alpine/v$(egrep -o '^[0-9]+\\.[0-9]+' /etc/alpine-release)/main\" --no-cache $nginxPackages             ;;         *)             set -x             \u0026\u0026 tempDir=\"$(mktemp -d)\"             \u0026\u0026 chown nobody:nobody $tempDir             \u0026\u0026 apk add --no-cache --virtual .build-deps                 gcc                 libc-dev                 make                 openssl-dev                 pcre-dev                 zlib-dev                 linux-headers                 libxslt-dev                 gd-dev                 geoip-dev                 perl-dev                 libedit-dev                 bash                 alpine-sdk                 findutils             \u0026\u0026 su nobody -s /bin/sh -c \"                 export HOME=${tempDir}                 \u0026\u0026 cd ${tempDir}                 \u0026\u0026 curl -f -O https://hg.nginx.org/pkg-oss/archive/${NGINX_VERSION}-${PKG_RELEASE}.tar.gz                 \u0026\u0026 PKGOSSCHECKSUM=\\\"af6e7eb25594dffe2903358f7a2c5c956f5b67b8df3f4e8237c30b63e50ce28e6eada3ed453687409beef8f3afa8f551cb20df2f06bd5e235eb66df212ece2ed *${NGINX_VERSION}-${PKG_RELEASE}.tar.gz\\\"                 \u0026\u0026 if [ \\\"\\$(openssl sha512 -r ${NGINX_VERSION}-${PKG_RELEASE}.tar.gz)\\\" = \\\"\\$PKGOSSCHECKSUM\\\" ]; then                     echo \\\"pkg-oss tarball checksum verification succeeded!\\\";                 else                     echo \\\"pkg-oss tarball checksum verification failed!\\\";                     exit 1;                 fi                 \u0026\u0026 tar xzvf ${NGINX_VERSION}-${PKG_RELEASE}.tar.gz                 \u0026\u0026 cd pkg-oss-${NGINX_VERSION}-${PKG_RELEASE}                 \u0026\u0026 cd alpine                 \u0026\u0026 make all                 \u0026\u0026 apk index -o ${tempDir}/packages/alpine/${apkArch}/APKINDEX.tar.gz ${tempDir}/packages/alpine/${apkArch}/*.apk                 \u0026\u0026 abuild-sign -k ${tempDir}/.abuild/abuild-key.rsa ${tempDir}/packages/alpine/${apkArch}/APKINDEX.tar.gz                 \"             \u0026\u0026 cp ${tempDir}/.abuild/abuild-key.rsa.pub /etc/apk/keys/             \u0026\u0026 apk del .build-deps             \u0026\u0026 apk add -X ${tempDir}/packages/alpine/ --no-cache $nginxPackages             ;;     esac     \u0026\u0026 apk del .checksum-deps     \u0026\u0026 if [ -n \"$tempDir\" ]; then rm -rf \"$tempDir\"; fi     \u0026\u0026 if [ -n \"/etc/apk/keys/abuild-key.rsa.pub\" ]; then rm -f /etc/apk/keys/abuild-key.rsa.pub; fi     \u0026\u0026 if [ -n \"/etc/apk/keys/nginx_signing.rsa.pub\" ]; then rm -f /etc/apk/keys/nginx_signing.rsa.pub; fi     \u0026\u0026 apk add --no-cache --virtual .gettext gettext     \u0026\u0026 mv /usr/bin/envsubst /tmp/         \u0026\u0026 runDeps=\"$(         scanelf --needed --nobanner /tmp/envsubst             | awk '{ gsub(/,/, \"\\nso:\", $2); print \"so:\" $2 }'             | sort -u             | xargs -r apk info --installed             | sort -u     )\"     \u0026\u0026 apk add --no-cache $runDeps     \u0026\u0026 apk del .gettext     \u0026\u0026 mv /tmp/envsubst /usr/local/bin/     \u0026\u0026 apk add --no-cache tzdata     \u0026\u0026 apk add --no-cache curl ca-certificates     \u0026\u0026 ln -sf /dev/stdout /var/log/nginx/access.log     \u0026\u0026 ln -sf /dev/stderr /var/log/nginx/error.log     \u0026\u0026 mkdir /docker-entrypoint.d"
    },
    {
      "created": "2021-11-16T18:03:32.044854681Z",
      "created_by": "/bin/sh -c #(nop) COPY file:65504f71f5855ca017fb64d502ce873a31b2e0decd75297a8fb0a287f97acf92 in / "
    },
    {
      "created": "2021-11-16T18:03:32.285318989Z",
      "created_by": "/bin/sh -c #(nop) COPY file:0b866ff3fc1ef5b03c4e6c8c513ae014f691fb05d530257dfffd07035c1b75da in /docker-entrypoint.d "
    },
    {
      "created": "2021-11-16T18:03:32.505736398Z",
      "created_by": "/bin/sh -c #(nop) COPY file:0fd5fca330dcd6a7de297435e32af634f29f7132ed0550d342cad9fd20158258 in /docker-entrypoint.d "
    },
    {
      "created": "2021-11-16T18:03:32.725433102Z",
      "created_by": "/bin/sh -c #(nop) COPY file:09a214a3e07c919af2fb2d7c749ccbc446b8c10eb217366e5a65640ee9edcc25 in /docker-entrypoint.d "
    },
    {
      "created": "2021-11-16T18:03:32.924218264Z",
      "created_by": "/bin/sh -c #(nop)  ENTRYPOINT [\"/docker-entrypoint.sh\"]",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T18:03:33.111501939Z",
      "created_by": "/bin/sh -c #(nop)  EXPOSE 80",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T18:03:33.318428497Z",
      "created_by": "/bin/sh -c #(nop)  STOPSIGNAL SIGQUIT",
      "empty_layer": true
    },
    {
      "created": "2021-11-16T18:03:33.536601336Z",
      "created_by": "/bin/sh -c #(nop)  CMD [\"nginx\" \"-g\" \"daemon off;\"]",
      "empty_layer": true
    }
  ],
  "os": "linux",
  "rootfs": {
    "type": "layers",
    "diff_ids": [
      "sha256:cbce5051e6c71e171e7ca3c175773edd60bcfb2e87e2c6102326da797f3b2ba5",
      "sha256:a1609e89eb327e19c971d188b2e50a3c0e5357d0852bc7ba6babf692f51d5554",
      "sha256:8340bd9c43bfd4991b04e6a87183b07662bb1c906b6282f379e46bc61b99d65c",
      "sha256:709ec8ed56ff86e7c68e12ef44732886346434d0a3744b50d5bf39765c1bdfc1",
      "sha256:512b0c08cc883f7529facf86a9f99e12516fd00bb174e76ba8be95bb92e9c32d",
      "sha256:09d0ad8413933fe5cfcab200399022fd0641f01784065c493f43e17eb539c044"
    ]
  }
}

Harbor流程

补充完背景知识后,我们再来回过来看 harbor 对于这个接口的处理流程。首先我们可以看到获取 manifest 的逻辑分成了两次调用,先发送一次HEAD判断镜像是否存在,同时HEAD会在 header 中返回摘要,所以第二次请求的时候就会直接使用摘要来作为入参提升性能:

  1. HEAD https://10.26.26.36/v2/test/nginx/manifests/1.20.2-alpine

  2. GET https://10.26.26.36/v2/test/nginx/manifests/sha256:c3ffe58e1eb09a16b3952c2bbe92363c50084f55a0da5c2ad38d6ae798c64599

harbor 对应处理逻辑代码的入口位于 /src/server/registry/manifest.go 文件中的 getManifest 方法。

首先harbor会尝试在自己的数据库中查找这个 manifeset的信息,此时分为两种情况,入参是 TAG 或者入参是摘要,大体的逻辑提炼成SQL如下:

  • 入参是 TAG 时,此时一共会查询三张表:

    # 先根据仓库名称查到仓库ID
    SELECT id FROM "repository"  WHERE name = 'test/nginx';
    # 根据仓库ID和TAG名称查询TAG表中存储的artifact_id
    SELECT artifact_id FROM "tag" WHERE "repository_id" = ${repository.id} AND "name" = '1.20.2-alpine';
    # 根据ID查询artifact信息
    SELECT * FROM "artifact" WHERE "id" = ${artifact_id};
    
  • 入参是摘要时,只需一次查询即可:

    SELECT * FROM "artifact" WHERE "digest" = ${digest} AND "repository_name" = 'test/nginx';
    

不过查出来的这些信息并不重要,主要是用于harbor内部的回调等逻辑,以及完成TAG到摘要的转换减轻后端存储的压力。

harbor 处理完自己内部的逻辑之后,会将请求转发给 registry进行处理。

Registry流程

registry 这一块儿的处理逻辑入口在:/registry/handlers/manifests.go:GetManifest 方法,精简后的代码如下:

func (imh *manifestHandler) GetManifest(w http.ResponseWriter, r *http.Request) {
	/*
	...
	根据 Accept Header判断客户端支持的版本
	...
	*/
	
	// 如果传入的是TAG,根据TAG查找摘要
	if imh.Tag != "" {
		tags := imh.Repository.Tags(imh)
		desc, err := tags.Get(imh, imh.Tag)
		if err != nil {
			return err
		}
		imh.Digest = desc.Digest
	}

	if etagMatch(r, imh.Digest.String()) {
		w.WriteHeader(http.StatusNotModified)
		return
	}

	// 根据摘要获取文件对象
	manifest, err := manifests.Get(imh, imh.Digest, options...)
	if err != nil {
		if _, ok := err.(distribution.ErrManifestUnknownRevision); ok {
			imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithDetail(err))
		} else {
			imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
		}
		return
	}
	/*
	...
	配合 'Accpet' 返回给客户端需要的版本,
	...
	*/
	
	// 构造返回 response
	w.Header().Set("Content-Type", ct)
	w.Header().Set("Content-Length", fmt.Sprint(len(p)))
	w.Header().Set("Docker-Content-Digest", imh.Digest.String())
	w.Header().Set("Etag", fmt.Sprintf(`"%s"`, imh.Digest))
	w.Write(manifest)
}

逻辑并不复杂,关键的两步操作就是将 TAG 转化为摘要,然后根据摘要获取对应的文件对象。

再回到我们这个镜像,/v2/test/nginx/manifests/1.20.2-alpine ,第一步对应的操作理论上如下,registry会调用 S3的接口去读取路径位于:/docker/registry/v2/repositories/test/nginx/_manifests/tags/1.20.2-alpine/current/link位置的文件,下载之后会发现里面就是这个TAG指向的真实的摘要地址:

sha256:c3ffe58e1eb09a16b3952c2bbe92363c50084f55a0da5c2ad38d6ae798c64599

拿到摘要之后,registry 就能拼出这个文件的真实路径了:/docker/registry/v2/blobs/sha256/c3/c3ffe58e1eb09a16b3952c2bbe92363c50084f55a0da5c2ad38d6ae798c64599/data ,然后调用S3的接口去读取,返回给 harbor,harbor 再返回给客户端,获取manifest的流程就到此结束了。

/v2/*/blobs/*接口

客户端拿到 manifest 文件之后,就会根据其中的摘要,并行调用 blobs 接口下载其中的镜像层文件了。

blobs 接口的处理逻辑其实是获取 manifest 接口逻辑的子集,也非常简单,大家直接看下方代码即可:

func (bs *blobServer) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
	// 获取文件元信息,会尝试从Redis中读取
	desc, err := bs.statter.Stat(ctx, dgst)
	if err != nil {
		return err
	}

	// 根据摘要拼接地址:/docker/registry/v2/blobs/sha256/${DIGEST}
	path, err := bs.pathFn(desc.Digest)
	if err != nil {
		return err
	}

	// 读取文件
	br, err := newFileReader(ctx, bs.driver, path, desc.Size)
	if err != nil {
		return err
	}

	// 设置缓存相关Header
	w.Header().Set("ETag", fmt.Sprintf(`"%s"`, desc.Digest)) // If-None-Match handled by ServeContent
	w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%.f", blobCacheControlMaxAge.Seconds()))
	// 写入摘要 Header
	if w.Header().Get("Docker-Content-Digest") == "" {
		w.Header().Set("Docker-Content-Digest", desc.Digest.String())
	}

	w.Header().Set("Content-Type", desc.MediaType)
	w.Header().Set("Content-Length", fmt.Sprint(desc.Size))
	http.ServeContent(w, r, desc.Digest.String(), time.Time{}, br)
	return nil
}

总结

到这里服务端视角的镜像拉取流程我们就分析完了,客户端( docker )拿到这些文件之后,就可以根据 manifest 中配置的命令和参数,然后再将下载到的镜像层通过 overlay 等uino file system 合并成一个文件系统,就可以将这些文件 run 起来了。