Harbor简介
Harbor 是 VMWare 开源的一个镜像仓库平台,镜像分发和存储逻辑是基于 docker 开源的 registry (后更名为 distribution
)实现的,Harbor 主要在其之上扩展了权限管理、镜像扫描、可视化管理后台等平台化功能,和大部分的云原生组件一样,Harbor 也是基于 Go 语言开发的。
Harbor 的系统架构如下图所示,箭头所指方向为数据流向:
一个镜像仓库最核心的功能就是推送
和拉取
,本文主要讨论 Harbor
镜像拉取具体的实现细节。
在开始之前
我们先申明一些基本信息,避免版本更迭引入的改动给大家带来困扰:
分析基于
Harbor
版本:v2.1.5
,源代码地址:https://github.com/goharbor/harbor/tree/v2.1.5分析基于
registry
版本:v2.7.1
,源代码地址:https://github.com/distribution/distribution/tree/v2.7.1,`registry`的后端存储使用对象存储(AWS-S3接口)基于一个具体的镜像分析会更加直观,因此本文会基于nginx:1.20.2-alpine镜像,复制到 harbor 仓库地址:
harbor.huweicai.com/test/nginx:1.20.2-alpine
进行拉取分析docker pull nginx:1.20.2-alpine docker tag nginx:1.20.2-alpine harbor.huweicai.com/test/nginx:1.20.2-alpine docker push harbor.huweicai.com/test/nginx:1.20.2-alpine
Harbor Pull 实现原理
我们可以先尝试pull
harbor.huweicai.com/test/nginx:1.20.2-alpine
这个镜像,从外部观察一下 pull
的行为,我们可以在终端看到一段这样的输出,直观的看起来就是,这个镜像的被存储在多个对象中,可以并行拉取:
为了进一步的了解其中发生了什么,我们可以给docker daemon
进程(不是 docker cli 命令行工具,真正执行镜像拉取动作的是 daemon 进程所以给 cli 设置代理没有用)设置代理到 Charles
或者 Fiddler
等七层抓包工具来分析,可以看到docker
向harbor
共发送了如下请求:
harbor 这边对于请求的处理的链路大概是这样的:流量从 nginx 代理进来匹配规则后转发到 core
服务(还有一部分前端页面流量会被转发到portal
服务),然后core
会从 redis
和 pg
中查询它所想要的数据,同时有可能会将流量再进一步转发给 registry
,registry
则会再继续调用s3
和redis
完成整个请求的处理逻辑。
这些请求可以被大致被归类一下,整理成下面四个接口:
/v2/
/service/token
/v2/*/manifests/*
/v2/*/blobs/*
下面我们就顺着这些接口,探究一下每一个接口背后harbor
都是怎么实现的。
/v2/接口
/v2/
接口harbor
并不会做任何的处理,会直接透传给registry
,而registry
这一侧也没有什么特殊的逻辑,铁定会返回 401
,这里稍微展开一下讲一下整体的认证流程:
其实整个 harbor
系统里面是分为两层认证的,首先 harbor
自己有一套账号系统,存在数据库中,然后 registry
是一套独立的组件,也有一套自己的认证,不过用户只会感知到第一层harbor
的账号,然后habor
初始化的时候会和registry
一起配置一个固定密码的用户进行它到registry
的认证。
所以当用户的请求透传到 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
的事情,即鉴别用户的身份,然后返回一个JWT
Token 做为凭证进行接下来的 pull
操作。
你在 docker login 时传入的账号和密码,会通过 BasicAuth 的方式传递给 harbor,harbor会根据预先配置好的鉴权方式,如:LDAP、数据库、OIDC等,对账号密码进行比对,通过之后就会将用户名签在JWT
Token中返回,整体请求截图如下:
解析出来的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
判断对象是否存在,避免重复推送,所以一开始的读请求有权限,而之后真正尝试写入对象文件的时候就报错了。
/v2/*/manifests/*接口
manifest
完成认证之后,拉取镜像的下一步就是拉取镜像的描述文件了,专有名词叫做:manifest
,manifest 主要包含了镜像的元信息配置和文件层对象的摘要,现在基本都是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 中返回摘要,所以第二次请求的时候就会直接使用摘要来作为入参提升性能:
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 起来了。