containerd如何拉取镜像

前言

之前看了 containerd如何创建Pod,其中创建 Sandbox / 业务容器前,kubelet 都会先 ImageStatus,镜像不存在则 PullImage。本章顺着这条线,把镜像从仓库拉到本地、解压成可挂载 rootfs、再给容器用的完整链路拆开。

  1. kubelet:ImageStatus 查本地是否已有镜像;没有则 PullImage,再 CreateContainer / StartContainer;
  2. CRI PullImage:containerd 的镜像入口,携带镜像引用(tag/digest)和可选认证信息;
  3. OCI Image Spec:镜像静态结构 ------ Descriptor / Index / Manifest / Config / Layers;
  4. OCI Distribution Spec:客户端与 Registry 的 HTTP 协议------鉴权、拉 Manifest、拉 Blob;
  5. content store:按 digest 存压缩层、Config、清单等原始内容(blob);
  6. Snapshotter(默认 overlayfs):把压缩层 Unpack 成可叠的只读快照目录,创建容器时再叠一层可写 upper;
  7. 使用镜像:CreateContainer 基于镜像 ChainID 准备可写 snapshot;StartContainer 时 shim 把 lowerdir+upperdir overlay 挂到 Bundle/rootfs。

注:

  1. containerd:2.3.1

一、镜像规范 OCI Image Spec

定义了容器镜像的格式和内容。它规定了镜像的静态结构,比如镜像清单(Manifest)、文件系统层(Layers)、配置(Config)应该如何组织和描述。

参考:github.com/opencontain...

1.1. Descriptor

Descriptor 是 OCI 镜像规范里的内容指针,不存真实数据,只描述「某处有一块内容、它是什么、怎么校验、多大」。

go 复制代码
// vendor/github.com/opencontainers/image-spec/specs-go/v1/descriptor.go
type Descriptor struct {
	// 类型,如manifest、index、layer、config...
	MediaType string `json:"mediaType"`
	// 摘要 string sha256:xxxx
	Digest digest.Digest `json:"digest"`
	// 字节大小
	Size int64 `json:"size"`
	// 平台信息
	Platform *Platform `json:"platform,omitempty"`
}
type Platform struct {
	// `amd64` or `arm64`...
	Architecture string `json:"architecture"`
	// `linux` or `windows`...
	OS string `json:"os"`
	// ...
}

1.2. Manifest

Manifest 是单个平台镜像的清单:说明「这一个镜像」由哪些部件组成:

  1. Config:一个 Descriptor,指向镜像配置 JSON(入口点、环境变量、工作目录等,运行时要用);
  2. Layers:一组 Descriptor,按顺序指向各层文件系统 blob(rootfs 的叠加层),是一些压缩文件,需要解压后才能使用。所有Layers合在一起,才是完整的镜像;
go 复制代码
// vendor/github.com/opencontainers/image-spec/specs-go/v1/manifest.go
type Manifest struct {
	// application/vnd.oci.image.manifest.v1+json
	MediaType string `json:"mediaType,omitempty"`
	// 配置
	Config Descriptor `json:"config"`
	// 层
	Layers []Descriptor `json:"layers"`
	// ...
}

比如下面是 nginx:1.27 对应 linux/amd64 平台的 manifest:

json 复制代码
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "config": {
    "mediaType": "application/vnd.oci.image.config.v1+json",
    "digest": "sha256:1e5f3c5b981a9f91ca91cf13ce87c2eedfc7a083f4f279552084dd08fc477512",
    "size": 8584
  },
  "layers": [
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:dad67da3f26bce15939543965e09c4059533b025f707aad72ed3d3f3a09c66f8",
      "size": 28230129
    },
    {
      "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
      "digest": "sha256:3b00567da96412a0298328fad6b96aadd3bc9ca97e6683534da152b0b9e8a977",
      "size": 44151007
    },
	// ...其他层
  ]
}

1.3. Index

如果一个镜像是多平台,则会有一个Index,指向多个平台的Manifest。

go 复制代码
// vendor/github.com/opencontainers/image-spec/specs-go/v1/index.go
type Index struct {
	// application/vnd.oci.image.index.v1+json
	MediaType string `json:"mediaType,omitempty"`
	// 多个平台的manifest的Descriptor
	Manifests []Descriptor `json:"manifests"`
}

比如下面是 nginx:1.27 的 Index:

json 复制代码
{
    "manifests": [
        {
            "digest": "sha256:fafd5f55296511cd0f1c991efa0cb85f323449bf643f3b6d760c6542f020d6e5",
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "platform": {
                "architecture": "amd64",
                "os": "linux"
            },
            "size": 2292
        },
		// 其他平台 ...
    ],
    "mediaType": "application/vnd.oci.image.index.v1+json",
    "schemaVersion": 2
}

1.4. Config

Config 是镜像的配置文件,描述镜像的入口点、环境变量、工作目录等,运行时要用。

go 复制代码
type Image struct {
	Created *time.Time `json:"created,omitempty"`
	Author string `json:"author,omitempty"`
	Platform
	Config ImageConfig `json:"config,omitempty"`
	// 层
	RootFS RootFS `json:"rootfs"`
	// 层历史
	History []History `json:"history,omitempty"`
}
type ImageConfig struct {
	User string `json:"User,omitempty"`
	ExposedPorts map[string]struct{} `json:"ExposedPorts,omitempty"`
	Env []string `json:"Env,omitempty"`
	Entrypoint []string `json:"Entrypoint,omitempty"`
	Cmd []string `json:"Cmd,omitempty"`
	WorkingDir string `json:"WorkingDir,omitempty"`
	// ...
}

比如下面是 nginx:1.27 对应 linux/amd64 平台的 Config:

json 复制代码
{
    "architecture": "amd64",
    "config": {
        "ExposedPorts": {
            "80/tcp": {}
        },
        "Env": [
            "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
            "NGINX_VERSION=1.27.5",
			...
        ],
        "Entrypoint": [
            "/docker-entrypoint.sh"
        ],
        "Cmd": [
            "nginx",
            "-g",
            "daemon off;"
        ],
        "StopSignal": "SIGQUIT"
    },
    "created": "2025-04-16T14:50:31Z",
    "history": [
        {
            "created": "2025-04-16T14:50:31Z",
            "created_by": "# debian.sh --arch 'amd64' out/ 'bookworm' '@1749513600'",
            "comment": "debuerreotype 0.15"
        },
		// ...
    ],
    "os": "linux",
    "rootfs": {
        "type": "layers",
        "diff_ids": [
            "sha256:7fb72a7d1a8e984ccd01277432de660162a547a00de77151518dc9033cfb8cb4",
            "sha256:626ab8a5d57bb70a67c14c62eed096ee3a141f78f41aa575ccb2b4566eecc5b7",
			// ...
        ]
    }
}

二、分发规范 OCI Distribution Spec

定义了容器镜像的分发和传输协议。它规定了客户端(如 Docker CLI)如何与镜像仓库(如 Docker Hub、Harbor)进行交互,包括如何拉取(Pull)、推送(Push)、发现(Discovery)和删除(Deletion)内容。

参考:github.com/opencontain...

2.1. PullImage

拉取一个镜像的过程,核心在于获取两个组成部分:清单 和 一个或多个 blob。

拉取清单,GET /v2/<name>/manifests/<tag-or-digest>,根据响应头Content-Type,区分清单类型:

  1. application/vnd.oci.image.index.v1+json,返回资源为Index,需要通过Index过滤出可用平台的Manifest发起二次请求;
  2. application/vnd.oci.image.manifest.v1+json,返回资源是Manifest,如果是单平台镜像,第一次就会返回Manifest,不会经过Index;
sh 复制代码
curl 'https://registry-1.docker.io/v2/library/nginx/manifests/1.27' \
	--header 'Authorization: Bearer {TOKEN}'

当获取清单后,通过Manifest里的Config和Layers,使用 GET /v2/<name>/blobs/<digest> 获取对应资源,Config是json配置,Layers是压缩包需要解压。

2.2. 认证

参考官方定义:distribution.github.io/distributio...

  1. 客户端请求Registry任意端点,返回401,并携带响应头Www-Authenticate,如:Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull",error="invalid_token"。

  2. 客户端请求认证端点,返回Token。如果是私有仓库,携带认证配置,如用户名密码。

sh 复制代码
curl 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/nginx:pull' \
	--header 'Authorization: Basic base64(username:password)'
{"access_token":"XXX","expires_in":300}
  1. 客户端携带Token重新请求Registry端点。
sh 复制代码
curl 'https://registry-1.docker.io/v2/library/nginx/manifests/1.27' --header 'Authorization: Bearer XXX'

containerd代码实现。

internal/cri/server/images/image_pull.go:PullImageRequest入口可以获取kubelet传入的认证配置(username和password),如果公开仓库,则不需要。

go 复制代码
func (c *GRPCCRIImageService) PullImage(ctx context.Context, r *runtime.PullImageRequest) (_ *runtime.PullImageResponse, err error) {
    // 镜像 如 nginx:1.27
	imageRef := r.GetImage().GetImage()
    // 获取认证信息function
	credentials := func(host string) (string, string, error) {
        // kubelet解析secret得到认证用户名密码
		hostauth := r.GetAuth()
		return ParseAuth(hostauth, host)
	}
    ref, err := c.CRIImageService.PullImage(ctx, imageRef, credentials, r.SandboxConfig...)
	return &runtime.PullImageResponse{ImageRef: ref}, nil
}
type PullImageRequest struct {
	Image *ImageSpec
	Auth *AuthConfig 
}
type AuthConfig struct {
	Username      string
	Password      string 
}

core/remotes/docker/authorizer.go:服务端返回401,解析Www-Authenticate,利用认证配置创建认证handler。

go 复制代码
func (a *dockerAuthorizer) AddResponses(ctx context.Context, responses []*http.Response) error {
	last := responses[len(responses)-1]
	host := last.Request.URL.Host
	// 解析Www-Authenticate
	for _, c := range auth.ParseAuthHeader(last.Header) {
		if c.Scheme == auth.BearerAuth {
			// ...如果host对应handler存在,则忽略
			// 从PullImageRequest获取认证信息
			var username, secret string
			if a.credentials != nil {
				username, secret, err = a.credentials(host)
			}
			// 创建handler
			common, err := auth.GenerateTokenOptions(ctx, host, username, secret, c)
			a.handlers[host] = newAuthHandler(a.client, a.header, c.Scheme, common)
			return nil
		}
	}
}

core/remotes/docker/resolver.go:对每个请求添加认证信息,如果缓存Token不存在或过期,则请求认证服务获取Token。

go 复制代码
func (r *request) do(ctx context.Context) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, r.method, r.String(), nil)
	// 添加认证信息
	r.authorize(ctx, req)
	// 发起请求
	resp, err := client.Do(req)
	return resp, nil
}
// core/remotes/docker/authorizer.go
func (a *dockerAuthorizer) Authorize(ctx context.Context, req *http.Request) error {
	// 根据域名获取认证handler(前面401添加的)
	ah := a.getAuthHandler(req.URL.Host)
	// 没有handler跳过...
    // 获取认证头(缓存/请求认证服务)
	auth, refreshToken, err := ah.authorize(ctx)
	// 设置认证头
	req.Header.Set("Authorization", auth)
	return nil
}
func (ah *authHandler) authorize(ctx context.Context) (string, string, error) {
	switch ah.scheme {
	case auth.BearerAuth:
		return ah.doBearerAuth(ctx)
	}
}
func (ah *authHandler) doBearerAuth(ctx context.Context) (token, refreshToken string, err error) {
	to := ah.common
	to.Scopes = GetTokenScopes(ctx, to.Scopes)
	scoped := strings.Join(to.Scopes, " ")
	var expirationTime *time.Time
	// 缓存命中,直接返回token
	if r, exist := ah.scopedTokens[scoped]; exist 
		&& (r.expirationTime == nil || r.expirationTime.After(time.Now())) {
		return r.token, r.refreshToken, r.err
	}
	// 加缓存
	r := new(authResult)
	ah.scopedTokens[scoped] = r
	defer func() {
		// 加缓存r...
	}()
	if to.Secret != "" {
		// 请求认证服务,获取token
		resp, err := auth.FetchTokenWithOAuth(ctx, ah.client, ah.header, "containerd-client", to)
		return resp.AccessToken, resp.RefreshToken, nil
	}
}

三、下载镜像

client/pull.go:下载镜像主流程

  1. Resolve:解析ref,如docker.io/library/nginx:1.27 或 docker.io/library/nginx@sha256:abc 判断镜像是否存在,返回对应的Descriptor,包括digest=sha256:abc和mediaType;
  2. 构建 FetcherHandler + ChildrenHandler;
  3. Dispatch 执行handlers,递归下载和解析Index->Manifest->Config/Layers;
go 复制代码
func (c *Client) fetch(ctx context.Context, rCtx *RemoteContext, ref string, limit int) (images.Image, error) {
	store := c.ContentStore()
	// 1. 解析ref 到 name 和 ocispec.Descriptor(digest)
	name, desc, err := rCtx.Resolver.Resolve(ctx, ref)
	// 2. 构造Fetcher
	fetcher, err := rCtx.Resolver.Fetcher(ctx, name)
	// 3. 构造ChildrenHandler
	// 3-1. 解析当前Descriptor,可能生成N个Descriptor,用于后续递归
	childrenHandler := images.ChildrenHandler(store)
	// 3-2. 如果Descriptor是Index,根据当前平台过滤Manifest
	childrenHandler = images.FilterPlatforms(childrenHandler, rCtx.PlatformMatcher)
	// 3-3. 如果Descriptor是Index,3-2过滤完了之后只保留1个
	childrenHandler = images.LimitManifests(childrenHandler, rCtx.PlatformMatcher, limit)
	// 组合n个Handler
	handlers := append(rCtx.BaseHandlers,
	 	// 根据 ocispec.Descriptor 获取内容到本地content store
		remotes.FetchHandler(store, fetcher),
		// 根据 ocispec.Descriptor 类型 加载 子内容,递归Dispatch
		childrenHandler,
	)
	handler = images.Handlers(handlers...)
    // 4. 执行
    images.Dispatch(ctx, handler, limiter, desc)
    // 5. 返回镜像name+Descriptor
	return images.Image{
		Name:   name,
		Target: desc,
		Labels: rCtx.Labels,
	}, nil
}

func Dispatch(ctx context.Context, handler Handler, limiter *semaphore.Weighted, descs ...ocispec.Descriptor) error {
	eg, ctx2 := errgroup.WithContext(ctx)
	// 循环所有Descriptor
	for _, desc := range descs {
		eg.Go(func() error {
			desc := desc
			// 对一个Descriptor使用所有Handler
			children, err := handler.Handle(ctx2, desc)
			// ChildrenHandler会再次生成多个 children Descriptor
			if len(children) > 0 {
				// 递归 Despatch children Descriptor
				return Dispatch(ctx2, handler, limiter, children...)
			}
			return nil
		})
	}
	return eg.Wait()
}

3.1. Resolve

镜像引用(ref)有两种写法:

方式 示例 含义
tag docker.io/library/nginx:1.27 可变指针,指向当前该 tag 对应的根内容
digest docker.io/library/nginx@sha256:xxx 不可变引用,直接锁定根 Descriptor 的内容哈希

@sha256:xxx 里的 digest 就是 Resolve 返回的根 Descriptor 的哈希:

  • 多平台镜像:根是 Index,digest = Index 内容的哈希;
  • 单平台镜像:根是 Manifest,digest = Manifest 内容的哈希。

以 nginx 1.27 为例,拉取其 Index 再算哈希,即可得到该 tag 当前的根 digest:

bash 复制代码
curl -sL 'https://registry-1.docker.io/v2/library/nginx/manifests/1.27' \
  -H "Authorization: Bearer $TOKEN" \
  | sha256sum
# → sha256:6784fb0834aa7dbbe12e3d7471e69c290df3e6ba810dc38b34ae33d3c1c05f7d

core/remotes/docker/resolver.go:Resolve 校验镜像ref存在,并返回根Descriptor。

  1. 发送 HEAD https://<domain>/v2/<repo>/manifests/<tag/digest>;
  2. 响应头 Docker-Content-Digest 为 digest,如sha256:xxx;
  3. 响应头 Content-Type 为 mediaType,如application/vnd.oci.image.index.v1+json,代表这是一个Index;
go 复制代码
// core/remotes/docker/resolver.go
func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocispec.Descriptor, error) {
    // 解析镜像ref到refspec
    // ref = docker.io/library/nginx:1.27
	base, err := r.resolveDockerBase(ref)
	refspec := base.refspec
	// 有可能 ref = docker.io/library/nginx@sha256:xxx 直接包含digest
	dgst  = refspec.Digest()
	// HEAD https://registry-1.docker.io/v2/library/nginx/manifests/1.27
	req := base.request(host, http.MethodHead, "manifests")
	resp, err := req.doWithRetries(...)
    // status=200
	size := resp.ContentLength
    // Content-Type -> MediaType
	contentType := getManifestMediaType(resp)
	if dgst == "" {
		// Docker-Content-Digest -> Digest(sha256:xxx)
		dgstHeader := digest.Digest(resp.Header.Get("Docker-Content-Digest"))
		dgst = dgstHeader
	}
	desc := ocispec.Descriptor{
		Digest:    dgst,
		MediaType: contentType,
		Size:      size,
	}
	return ref, desc, nil
}

3.2. Fetch

Fetch 不关心Descriptor 里的 mediaType 资源类型,Index or Manifest or Config or Layers。

Fetch 只关心 Descriptor 里的 digest。

core/remotes/handlers.go:Fetch 发送 GET https://<domain>/v2/<repo>/manifests/<digest>,下载内容到本地content store。

go 复制代码
func Fetch(ctx context.Context, ingester content.Ingester, fetcher Fetcher, desc ocispec.Descriptor) error {
	// cw 创建Writer content store
	cw, err := content.OpenWriter(ctx, ingester, content.WithRef(MakeRefKey(ctx, desc)), content.WithDescriptor(desc))
	// rc 创建reader GET请求
	rc, err := fetcher.Fetch(ctx, desc)
	// 下载写入本地content store,记录content元数据到boltdb
	return content.Copy(ctx, cw, rc, desc.Size, desc.Digest)
}

core/content/helpers.go:下载到injest -> rename到blob -> 写录元数据到boltdb。

  1. copyWithBuffer,GET响应数据先写入ingest,/var/lib/containerd/io.containerd.content.v1.content/ingest/<digest>/data;
  2. Commit,ingest rename 到 blob /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/<digest>,记录元数据到boltdb;
go 复制代码
func Copy(ctx context.Context, cw Writer, or io.Reader, size int64, expected digest.Digest, opts ...Opt) error {
	r := or
	// GET请求 先写 ingest
	copied, err := copyWithBuffer(cw, r)
	// ingest写入完整,rename到blob,记录元数据到boltdb
	cw.Commit(ctx, size, expected, opts...)
}

比如这是 nginx:1.27 的 Index,下载到本地content store。

shell 复制代码
root@xxx:/var/lib/containerd/io.containerd.content.v1.content/blobs/sha256# cat 6784fb0834aa7dbbe12e3d7471e69c290df3e6ba810dc38b34ae33d3c1c05f7d
{"manifests":[{...}]}

3.3. Children

针对上面Fetch到的资源,ChildrenHandler 会返回该资源所包含的子资源。

core/images/image.go:先从content store读出Fetch下载的数据:

  1. 如果是Index,返回Index下的Manifest的Descriptor;
  2. 如果是Manifest,返回Manifest的Config和Layers的Descriptor;
go 复制代码
func Children(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
	if IsManifestType(desc.MediaType) {
		p, err := content.ReadBlob(ctx, provider, desc)
		var manifest ocispec.Manifest
		json.Unmarshal(p, &manifest);
		return append([]ocispec.Descriptor{manifest.Config}, manifest.Layers...), nil
	} else if IsIndexType(desc.MediaType) {
		p, err := content.ReadBlob(ctx, provider, desc)
		var index ocispec.Index
		json.Unmarshal(p, &index);
		return append([]ocispec.Descriptor{}, index.Manifests...), nil
	} 
	return nil, nil
}

core/images/handlers.go:按照当前平台过滤Children产生的子资源。比如Index会返回N个Manifest,这里过滤剩下匹配当前平台的Manifest。

go 复制代码
func FilterPlatforms(f HandlerFunc, m platforms.Matcher) HandlerFunc {
	return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
		// ChildrenHandler产出的children
		children, err := f(ctx, desc)
		var descs []ocispec.Descriptor
		for _, d := range children {
			// 平台为空 或 平台匹配 append
			if d.Platform == nil || m.Match(*d.Platform) {
				descs = append(descs, d)
			}
		}
		return descs, nil
	}
}

core/images/handlers.go:对于Index下的Manifest,按照平台排序,返回前N个,这里N=1。

go 复制代码
func LimitManifests(f HandlerFunc, m platforms.MatchComparer, n int) HandlerFunc {
	return func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
		children, err := f(ctx, desc)
		// 仅针对Index,需要过滤产出的children
		if IsIndexType(desc.MediaType) {
			sort.SliceStable(children, func(i, j int) bool {
				if children[i].Platform == nil {
					return false
				}
				if children[j].Platform == nil {
					return true
				}
				return m.Less(*children[i].Platform, *children[j].Platform)
			})
			if len(children) > n {
				children = children[:n]
			}
		}
		return children, nil
	}
}

四、Snapshotter

Snapshotter 是 containerd 里负责「可挂载文件系统快照」的插件:把一层层解压后的目录树管成可叠、可复用的快照,供容器当 rootfs 用。

下载到 content store 的是 压缩包(layer tar.gz)。容器进程需要的是 目录树(有 /bin、/etc 等)。 Unpack = 把压缩层解到 snapshotter 管理的快照里;不是解到随便一个临时目录。

比如 nginx:1.27 linux/amd64 的 Config 其中一个 Layer:

json 复制代码
{
    "architecture": "amd64",
    "history": [
		//...
        {
            "created": "2025-04-16T14:50:31Z",
            "created_by": "COPY 20-envsubst-on-templates.sh /docker-entrypoint.d # buildkit",
            "comment": "buildkit.dockerfile.v0"
        },
		//...
    ],
    "os": "linux",
    "rootfs": {
        "type": "layers",
        "diff_ids": [
        	// ...
            "sha256:7fb72a7d1a8e984ccd01277432de660162a547a00de77151518dc9033cfb8cb4",
            //...
        ]
    }
}

经过Unpack解压后,在文件系统里对应:

id是boltdb里的id

sh 复制代码
# tree /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/423/fs
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/423/fs
`-- docker-entrypoint.d
    `-- 20-envsubst-on-templates.sh

Snapshotter 是一个 接口实现(插件),常见实现:

  • overlayfs(默认):每层一个目录,用 overlay 叠起来
  • native:每层直接拷贝目录
  • btrfs / zfs / erofs 等:用对应文件系统特性做快照

4.1. 主流程

client/pull.go:下载镜像 Dispatch 的 Handlers 外面还包了一层 Unpacker,目的是解压。

go 复制代码
unpacker, err = unpack.NewUnpacker(ctx, c.ContentStore(), uopts...)
pullCtx.HandlerWrapper = func(h images.Handler) images.Handler {
	return unpacker.Unpack(h)  // 包一层
}
img, err := c.fetch(...)          // Dispatch 下载,中间触发异步 unpack
ur, err := unpacker.Wait()        // 等所有层解完再建 image 记录

core/unpack/unpacker.go:Unpack 包住原 handler。对每个 Descriptor 先走下层(Fetch + Children),再按类型分流:

  1. Manifest :Children 拆出 layers 与 nonLayers(主要是 Config);只把 Config 继续交给 Dispatch 下载;layers 记到 layers[configDigest],延后 Fetch;
  2. Config :用记下的 layers,异步 go u.unpack(h, config, layers)。
go 复制代码
func (u *Unpacker) Unpack(h images.Handler) images.Handler {
	layers := map[digest.Digest][]ocispec.Descriptor{}
	return images.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
		 // Fetch + Children
		children, err := h.Handle(ctx, desc)

		if images.IsManifestType(desc.MediaType) {
			// 如果是 Manifest
			var nonLayers, manifestLayers []ocispec.Descriptor
			for _, child := range children {
				if images.IsLayerType(child.MediaType) {
					// Layer
					manifestLayers = append(manifestLayers, child)
				} else {
					 // 通常是 Config
					nonLayers = append(nonLayers, child)
				}
			}
			// 缓存Layers
			for _, nl := range nonLayers {
				layers[nl.Digest] = manifestLayers
			}
			// 只把Config放行给后续Fetch
			children = nonLayers
		} else if images.IsConfigType(desc.MediaType) {
			// 当下载到 Config 时,从 layers 记下的 layer 列表 Fetch
			l := layers[desc.Digest]
			if len(l) > 0 {
				u.eg.Go(func() error {
					return u.unpack(h, desc, l)
				})
			}
		}
		return children, nil
	})
}

4.2. Unpack(Prepare, Fetch, Apply, Commit)

core/unpack/unpacker.go:u.unpack 读 Config,对齐 layers(下标越小越底层)与 diffIDs,预计算 chainIDs,再从底到顶逐层处理。每层按照以下顺序执行:

  1. Prepare:在 boltdb 创建 临时snapshot记录,如果存在则跳过本层,不存在创建/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/自增id/fs目录用于存放本层解压后的内容;

  2. Fetch:下载本层 blob 到 content store;

  3. Apply:解压 Layer压缩包 到 Prepare 返回的目录,校验 Config 里 Layer 的 DiffID 与 实际一致;

  4. Commit:在 boltdb 创建 snapshot 记录;

go 复制代码
func (u *Unpacker) unpack(h images.Handler, config ocispec.Descriptor, layers []ocispec.Descriptor) error {
	p, _ := content.ReadBlob(ctx, u.content, config)
	json.Unmarshal(p, &i)
	diffIDs := i.RootFS.DiffIDs
	// 计算每层的chainID,snapshot的唯一标识
	chainIDs := identity.ChainIDs(copy(diffIDs))
	for i, desc := range layers {
		parent := ""
		if i > 0 {
			parent = chainIDs[i-1].String()
		}
		chainID := chainIDs[i].String()
		// ① Prepare:基于 parent 建临时 snapshot,返回 mounts(解压写入点)
		key := fmt.Sprintf(snapshots.UnpackKeyFormat, uniquePart(), chainID)
		mounts, err := sn.Prepare(ctx, key, parent, opts...)
		// AlreadyExists 且 Stat(chainID) 成功 → 本层已有,跳过
		// ② 确保本层 blob 已在 content store(没有则 u.fetch 下载)
		u.fetch(ctx, h, []ocispec.Descriptor{desc}, ...)
		// ③ Apply:解 tar 到 mounts,校验 DiffID
		diff, err := a.Apply(ctx, desc, mounts, unpack.ApplyOpts...)
		if diff.Digest != diffIDs[i] { abort; return err }
		// ④ Commit:boltdb 临时 key → 只读 chainID
		sn.Commit(ctx, chainID, key, opts...)
		cs.Update(desc.Digest, LabelUncompressed: diffIDs[i])
	}
}

core/diff/apply/apply.go:解压并计算哈希

go 复制代码
func (s *fsApplier) Apply(ctx context.Context, desc ocispec.Descriptor, mounts []mount.Mount, ...) (ocispec.Descriptor, error) {
	// 从 content store 按压缩 digest 打开 layer
	ra, _ := s.store.ReaderAt(ctx, desc)
	// 按 MediaType 解压链:gzip/zstd → 原始 tar
	processor := diff.NewProcessorChain(desc.MediaType, r)
	// ...直到 MediaTypeImageLayer
	// 边解压边算哈希 → DiffID
	digester := digest.Canonical.Digester()
	rc := io.TeeReader(processor, digester.Hash())
	// 解 tar 到 mounts 对应目录
	apply(ctx, mounts, rc, config.SyncFs)
	return ocispec.Descriptor{
		MediaType: ocispec.MediaTypeImageLayer,
		Digest:    digester.Digest(), // 与 Config.diff_ids[i] 比对
	}, nil
}

4.3. Layer Digest / DiffID / ChainID

三个都是 sha256:...,但哈希的对象不同:

ID 来源 哈希对象 用途
Layer Digest Manifest.Layersi.Digest 压缩包(如 tar.gz)字节 content store 的 key;Fetch 下载用
DiffID Config.rootfs.diff_idsi 解压后 tar 流字节 Apply 后校验解压是否正确
ChainID 由 DiffIDs 递推算出 当前层+之前层的DiffID snapshot 的名字(只读层身份)

4.4. 元数据树

当拉取镜像结束后,boltdb中创建了如下树结构:

  1. image:镜像名 -> 镜像元数据(包含根Descriptor);
  2. content:blob的digest -> blob元数据;
  3. snapshots:ChainID → parent/children;
yaml 复制代码
├── image/
│   └── docker.io/library/nginx:1.27
│       └── target/
│           ├── digest:    sha256:fff...   ← Manifest(或 Index)
│           ├── mediatype: application/vnd.oci.image.manifest.v1+json
│           └── size:      1234
│
├── content/
│   └── blob/
│       ├── sha256:fff...   # Manifest
│       ├── sha256:cfg...   # Config JSON
│       ├── sha256:aaa...   # Layer0 tar
│       └── sha256:bbb...   # Layer1 tar
│
└── snapshots/
    └── overlayfs/
        ├── sha256:chain0...
        │   ├── name:   k8s.io/1/sha256:chain0...
        │   ├── id:     1
        │   ├── parent: (空)
        │   └── children/
        │       └── sha256:chain1...
        └── sha256:chain1...
            ├── name:   k8s.io/2/sha256:chain1...
            ├── id:     2
            ├── parent: sha256:chain0...
            └── children: (空)

五、使用镜像

5.1. 创建容器

internal/cri/server/container_create.go:创建容器,WithNewSnapshot需要基于镜像只读层,创建可写层。

go 复制代码
func (c *criService) createContainer(r *createContainerRequest) (_ string, retErr error) {
	// OCI Runtime Spec
	spec, err := c.buildContainerSpec(...)
	sOpts, err := snapshotterOpts(r.containerConfig)

	opts := []containerd.NewContainerOpts{
		// snapshotter = overlayfs
		containerd.WithSnapshotter(c.RuntimeSnapshotter(r.ctx, ociRuntime)),
		// 准备rootfs,创建可写层
		customopts.WithNewSnapshot(r.containerID, *r.containerdImage, !c.ImageService.Config().DisableSnapshotAnnotations, sOpts...),
	}
	opts = append(opts,
		containerd.WithSpec(spec, specOpts...),
		//...
	)
	// 持久化boltdb
	c.client.NewContainer(r.ctx, r.containerID, opts...)
	return containerRootDir, nil
}

client/container_opts.go:

  1. image:镜像元数据,根据镜像名从boltdb中获取;
  2. diffIDs:通过镜像元数据的根Descriptor,从content store中获取Index->Manifest->Config,从Config中获取diffIDs;
  3. chanID:根据diffIDs递推算出,代表镜像最上层的只读层;
  4. Prepare:创建可写层snapshot,parent是镜像最上层的只读层(/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/自增id/fs),元数据持久化到boltdb snapshots;
go 复制代码
func withNewSnapshot(id string, i Image, readonly bool, opts ...snapshots.Opt) NewContainerOpts {
	return func(ctx context.Context, client *Client, c *containers.Container) error {
	    // image的根descriptor -> Index -> Manifest -> Config
		diffIDs, err := i.RootFS(ctx)
		// 根据diffID计算镜像最上层的ChanID
		parent := identity.ChainID(diffIDs).String()
		c.Snapshotter, err = client.resolveSnapshotterName(ctx, c.Snapshotter)
		// overlayfs
		s, err := client.getSnapshotter(ctx, c.Snapshotter)
		// 创建可写层snapshot,元数据持久化到boltdb snapshots
		_, err = s.Prepare(ctx, id, parent, opts...)
		// SnapshotKey = 容器id
		c.SnapshotKey = id
		c.Image = i.Name()
		return nil
	}
}

5.2. 启动容器

Pod的sandbox容器创建和启动,会启动shim进程,普通容器只需要连接shim进程发送CreateTaskRequest。

client/container.go:containerd 发送 CreateTaskRequest 给shim,其中包含Bundle路径和rootfs配置。

go 复制代码
func (c *container) handleMounts(ctx context.Context, request *tasks.CreateTaskRequest) error {
	// container
	r, err := c.get(ctx)
	s, err := c.client.getSnapshotter(ctx, r.Snapshotter)
	// overlayfs实现,返回mounts
	mounts, err := s.Mounts(ctx, r.SnapshotKey)
	spec, err := c.Spec(ctx)
	for _, m := range mounts {
		request.Rootfs = append(request.Rootfs, &types.Mount{
			Type:    m.Type,
			Source:  m.Source,
			Target:  m.Target,
			Options: m.Options,
		})
	}
	return nil
}

plugins/snapshots/overlay/overlay.go:从boltdb获取元数据,拼接overlay挂载选项,包括镜像只读层(lowerdir)和容器可写层(upperdir)。

go 复制代码
func (o *snapshotter) Mounts(ctx context.Context, key string) (_ []mount.Mount, err error) {
	var s storage.Snapshot
	var info snapshots.Info
	o.ms.WithTransaction(ctx, false, func(ctx context.Context) error {
		// 取snapshot信息
		s, err = storage.GetSnapshot(ctx, key)
		_, info, _, err = storage.GetInfo(ctx, key)
		return nil
	})
	return o.mounts(s, info), nil
}
func (o *snapshotter) mounts(s storage.Snapshot, info snapshots.Info) []mount.Mount {
	var options []string
	options = append(options,
		fmt.Sprintf("workdir=%s", o.workPath(s.ID)),
		// 容器可写层
		fmt.Sprintf("upperdir=%s", o.upperPath(s.ID)),
	)
	parentPaths := make([]string, len(s.ParentIDs))
	for i := range s.ParentIDs {
		parentPaths[i] = o.upperPath(s.ParentIDs[i])
	}
	// 镜像只读层 * N
	options = append(options, fmt.Sprintf("lowerdir=%s", strings.Join(parentPaths, ":")))
	options = append(options, o.options...)
	return []mount.Mount{
		{
			Type:    "overlay",
			Source:  "overlay",
			Options: options,
		},
	}
}

core/snapshots/storage/bolt.go:从boltdb获取snapshot信息时,因为snapshot只记录了父节点引用,所以需要递归获取所有父节点,用父节点的id拼接snapshot目录,用于lowerdir。

go 复制代码
func parents(bkt, pbkt *bolt.Bucket, parent uint64) (parents []string, err error) {
	for {
		parents = append(parents, strconv.FormatUint(parent, 10))
		parentKey := pbkt.Get(bucketKeyParent)
		if len(parentKey) == 0 {
			return
		}
		pbkt = bkt.Bucket(parentKey)
		parent = readID(pbkt)
	}
}

containerd准备好目录,shim进程执行mount:

  1. lowerdir:镜像里的N个只读层,如果是sandbox容器则只有1个;
  2. upperdir:每个容器独立1个可写层;
  3. workdir:每个容器overlay临时工作目录;
  4. mergedir:挂载点 = Bundle路径/rootfs;
sh 复制代码
mount -t overlay overlay -o lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/95/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/94/fs:/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/93/fs,\
    upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/100/fs,\
    workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/100/work \
    /run/containerd/io.containerd.runtime.v2.task/k8s.io/<container-id>/rootfs

总结

整体:

scss 复制代码
Pull(ref, WithPullUnpack)
  Resolve                    → 根 Descriptor (Index/Manifest)
  Dispatch + UnpackHandler:
    Fetch(Index/Manifest)    → content:清单
    Children → 只继续 Config → content:Config
    异步 unpack:
      fetch(Layers)          → content:压缩层
      每层 Prepare → Apply → Commit  → snapshots
  Wait                       → 全部层解完
  createImage                → image:name → Manifest digest

PullImage(规范 + 下载)

  1. 规范:Image Spec 描述 Descriptor / Index / Manifest / Config / Layers;Distribution Spec 描述鉴权与 /v2/.../manifests、/v2/.../blobs;
  2. Resolve:把 nginx:1.27 / @sha256:... 解析成根 Descriptor(多平台是 Index,单平台是 Manifest);
  3. Dispatch:按 Descriptor 图递归 Fetch------Index → 平台 Manifest → Config;Layers 走异步 Unpack 路径;
  4. content:清单、Config、压缩层全部按 digest 落入 content store;

Unpack(Snapshotter)

  1. 每层:Prepare 空快照 → Apply 解压 tar 到目录 → Commit 成只读 snapshot;
  2. 用 DiffID 递推 ChainID,父子 snapshot 链对应镜像层叠关系;
  3. 落盘路径形如 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<id>/fs;

使用镜像(Create / Start)

  1. CreateContainer:按 Config 的 diffIDs 算出顶层 ChainID,Prepare 出容器专属可写层(parent = 镜像顶层);
  2. StartContainer:overlayfs 拼 lowerdir(N 个只读层)+ upperdir(可写层),shim mount 到 Bundle/rootfs;
相关推荐
一条小小yu1 小时前
为什么使用springboot
java·spring boot·后端
Elastic 中国社区官方博客1 小时前
Kubernetes attributes processor v1:它对 EDOT Collector 意味着什么
java·大数据·elasticsearch·搜索引擎·贪心算法·kubernetes·全文检索
香瓜子rd2 小时前
MySQL InnoDB 并发控制核心原理:事务、隔离级别、MVCC 与锁机制
数据库·后端
RISCV_Explorer2 小时前
RISC-V RVV向量编程模型机制解析——vsetvl配置、掩码与尾元素策略及长度无关编程
后端·risc-v
对象存储与RustFS2 小时前
升级 RustFS 二进制不停机:一条一条换,留一条退路
后端·rust·开源
hasty2 小时前
限制写了却没生效:OpenTelemetry Go 的 Unicode 截断边界
开发语言·后端·golang
SensorFlow5 小时前
看板有漏斗阶段,为什么转化率仍可能是假的?用 ClickHouse 检查演示埋点
后端
LucianaiB5 小时前
【TextIn xParse 与 Workbuddy实践】我把答辩材料丢给 AI 审了一遍,它开始追着我要证据
后端
王中阳Go5 小时前
读者问"你用的什么 Agent":3 个 AI 员工的分工表和工具链
人工智能·后端·ai编程