前言
之前看了 containerd如何创建Pod,其中创建 Sandbox / 业务容器前,kubelet 都会先 ImageStatus,镜像不存在则 PullImage。本章顺着这条线,把镜像从仓库拉到本地、解压成可挂载 rootfs、再给容器用的完整链路拆开。
- kubelet:
ImageStatus查本地是否已有镜像;没有则PullImage,再CreateContainer/StartContainer; - CRI
PullImage:containerd 的镜像入口,携带镜像引用(tag/digest)和可选认证信息; - OCI Image Spec:镜像静态结构 ------ Descriptor / Index / Manifest / Config / Layers;
- OCI Distribution Spec:客户端与 Registry 的 HTTP 协议------鉴权、拉 Manifest、拉 Blob;
- content store:按 digest 存压缩层、Config、清单等原始内容(blob);
- Snapshotter(默认 overlayfs):把压缩层 Unpack 成可叠的只读快照目录,创建容器时再叠一层可写 upper;
- 使用镜像:
CreateContainer基于镜像 ChainID 准备可写 snapshot;StartContainer时 shim 把 lowerdir+upperdir overlay 挂到 Bundle/rootfs。
注:
- containerd:2.3.1
一、镜像规范 OCI Image Spec
定义了容器镜像的格式和内容。它规定了镜像的静态结构,比如镜像清单(Manifest)、文件系统层(Layers)、配置(Config)应该如何组织和描述。
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 是单个平台镜像的清单:说明「这一个镜像」由哪些部件组成:
- Config:一个 Descriptor,指向镜像配置 JSON(入口点、环境变量、工作目录等,运行时要用);
- 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)内容。
2.1. PullImage
拉取一个镜像的过程,核心在于获取两个组成部分:清单 和 一个或多个 blob。
拉取清单,GET /v2/<name>/manifests/<tag-or-digest>,根据响应头Content-Type,区分清单类型:
application/vnd.oci.image.index.v1+json,返回资源为Index,需要通过Index过滤出可用平台的Manifest发起二次请求;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...
-
客户端请求Registry任意端点,返回401,并携带响应头
Www-Authenticate,如:Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:library/nginx:pull",error="invalid_token"。 -
客户端请求认证端点,返回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}
- 客户端携带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:下载镜像主流程
- Resolve:解析ref,如
docker.io/library/nginx:1.27或docker.io/library/nginx@sha256:abc判断镜像是否存在,返回对应的Descriptor,包括digest=sha256:abc和mediaType; - 构建
FetcherHandler+ChildrenHandler; 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。
- 发送
HEAD https://<domain>/v2/<repo>/manifests/<tag/digest>; - 响应头
Docker-Content-Digest为 digest,如sha256:xxx; - 响应头
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。
copyWithBuffer,GET响应数据先写入ingest,/var/lib/containerd/io.containerd.content.v1.content/ingest/<digest>/data;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下载的数据:
- 如果是Index,返回Index下的Manifest的Descriptor;
- 如果是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),再按类型分流:
- Manifest :Children 拆出 layers 与 nonLayers(主要是 Config);只把 Config 继续交给
Dispatch下载;layers 记到layers[configDigest],延后 Fetch; - 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,再从底到顶逐层处理。每层按照以下顺序执行:
-
Prepare:在 boltdb 创建 临时snapshot记录,如果存在则跳过本层,不存在创建/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/自增id/fs目录用于存放本层解压后的内容; -
Fetch:下载本层 blob 到 content store; -
Apply:解压 Layer压缩包 到 Prepare 返回的目录,校验 Config 里 Layer 的 DiffID 与 实际一致; -
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中创建了如下树结构:
- image:镜像名 -> 镜像元数据(包含根Descriptor);
- content:blob的digest -> blob元数据;
- 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:
- image:镜像元数据,根据镜像名从boltdb中获取;
- diffIDs:通过镜像元数据的根Descriptor,从content store中获取Index->Manifest->Config,从Config中获取diffIDs;
- chanID:根据diffIDs递推算出,代表镜像最上层的只读层;
- 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:
- lowerdir:镜像里的N个只读层,如果是sandbox容器则只有1个;
- upperdir:每个容器独立1个可写层;
- workdir:每个容器overlay临时工作目录;
- 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(规范 + 下载)
- 规范:Image Spec 描述 Descriptor / Index / Manifest / Config / Layers;Distribution Spec 描述鉴权与
/v2/.../manifests、/v2/.../blobs; - Resolve:把
nginx:1.27/@sha256:...解析成根 Descriptor(多平台是 Index,单平台是 Manifest); - Dispatch:按 Descriptor 图递归 Fetch------Index → 平台 Manifest → Config;Layers 走异步 Unpack 路径;
- content:清单、Config、压缩层全部按 digest 落入 content store;
Unpack(Snapshotter)
- 每层:Prepare 空快照 → Apply 解压 tar 到目录 → Commit 成只读 snapshot;
- 用 DiffID 递推 ChainID,父子 snapshot 链对应镜像层叠关系;
- 落盘路径形如
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/<id>/fs;
使用镜像(Create / Start)
- CreateContainer:按 Config 的 diffIDs 算出顶层 ChainID,
Prepare出容器专属可写层(parent = 镜像顶层); - StartContainer:overlayfs 拼 lowerdir(N 个只读层)+ upperdir(可写层),shim mount 到 Bundle/rootfs;