Containerd の Resolver 実装のメモ

はじめに

Containerd の Resolver インターフェースについてみていく。 Resolver インターフェースは、イメージのプル/プッシュ時に独自のイメージプル/プッシュ機構に差し替える機能である。 WithResolver オプションを使用して、Pull/Push クライアントに渡すことができる。

Containerd のデフォルトでは、Docker Resolver が使用される。

本メモでは、プルに焦点を絞る。

Resolver インターフェース

v1.5.7 現在、次のメソッドを実装することでインターフェースを満たすことができる。

ref はその Resolver が定めるイメージの URI フォーマットである。 Docker の Resolver であれば、スキーマレスのイメージの URI (例: docker.io/library/ubuntu)である。

Resolver インターフェース

1
2
3
4
5
type Resolver interface {
    Resolve(ctx context.Context, ref string) (name string, desc ocispec.Descriptor, err error)
    Fetcher(ctx context.Context, ref string) (Fetcher, error)
    Pusher(ctx context.Context, ref string) (Pusher, error)
}

Resolve は、イメージ URI より、そのイメージの OCI Spec の Descriptor を返す。 Descriptor には、MediaType や Digest が含まれており、どのような種類のアーティファクトか判定できる。 例えば、MediaType より Image Index や Image Manifest であることがわかる。 Resolve は、指定したイメージタグのイメージに対応する Descriptor が存在するか(解決できるか)を確認するメソッドと言える。 解決された Descriptor は Fetcher の Fetch で再度取得されるため、あくまで解決できるかの確認を担う。

Docker の Resolve は、Image Index(Image Manifest List)もしくは、Image Manifest へアクセスを試みてレスポンスから Descriptor を返す。 アクセスとは、具体的にはじめに HEAD リクエスト送り、Docker-Content-Digest ヘッダーがあればそれを使用する。 ヘッダーが存在しなければ続いて GET リクエストを送信してそのレスポンスからダイジェストを計算する。

Fetcher インターフェース

1
2
3
4
type Fetcher interface {
	// Fetch the resource identified by the descriptor.
	Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error)
}

Fetcher インターフェースのの Fetch は Descriptor を引数に渡すことで、その Descriptor に対応したデータ(Config 等)をリモートからフェッチする。

Docker の Fetch は OCI Registory の実装に従っている。 例えば、渡された Descriptor の MediaType が Image Index や Manifest であれば、manifests エンドポイントにリクエストをし、そうでなければ、(レイヤーと判断し) blobs エンドポイントにリクエストをする。

プル時の Authorizer

Docker の Resolver は、Authorizer を使用する。 Token Authentication Specification にあるように Docker Hub からイメージをプルするためには、認可トークンが必要である。これは、パブリックイメージでも必要となる。

そのため、Image Index などを取得する際には、事前にトークンエンドポイントより認可トークンを取得する必要があり、そのトークンを使用して Manifest を取得する。

その仕組みが、Authorizer インターフェースである。

1
2
3
4
type Authorizer interface {
	Authorize(context.Context, *http.Request) error
	AddResponses(context.Context, []*http.Response) error
}

Authorize が、上記トークンエンドポイントから認可トークンを取得するメソッドである。

AddResponses は、既存の Unauthorized(401) レスポインスより、上記認可するための情報を追加するメソッドである。

認可トークンなしで Manifest を取得しようとすると、Docker Hub のエンドポイントより Unauthorized(401) レスポンスが返却されるが、その際のレスポンスに WWW-Authenticate ヘッダが含まれており、ヘッダ値に scopeservice などが含まれている。 これらの値を使用することで、トークンエンドポイント の URL を動的に構築できる。 つまり、Unauthorized(401) レスポンスを、AddResponses に渡すことで、認可トークン URL に必要な情報がパースされ、動的にトークンエンドポイント の URL を構築できる。

Authorize は、構築したトークンエンドポイント の URL に対して HTTP リクエストを実施し認可トークンを取得する。 また、Authorize は、Manifest などを取得するための http.Request 型の HTTP クライアントの Authorization ヘッダに取得した認可トークンを設定する。 認可トークン取得後のリクエストでは、Authorization ヘッダに認可トークンが設定されているため Manifest などを取得できる(結果イメージをプルできる)。

Docker Resolver の流れとしては次のようになる。

  1. Resolve によって ref から Image Index や Manifest の取得(HEAD/GET リクエスト)が実行されるが、認可トークンが設定されていないため Unauthorized(401) エラーとなる。
  2. Resolve 内で上記 HTTP リクエストのリトライ判定が行われる。Unauthorized(401) の場合、引数に 1. の HTTP レスポンスを渡して AddResponses を実行する。そして、WWW-Authenticate レスポンスヘッダから、認可エンドポイントの URL の情報がパースされる。そしてリトライ対象として判定される。
  3. リトライにより再度 1. の実行を試みる。実際のリクエスト前に Authorize が実行され、トークンエンドポイントの URL に対して HTTP リクエストをする。成功すると、1. の HTTP クライアントの Authorization ヘッダに認可トークンが設定される。Authorization ヘッダが設定された HTTP クライアントにより、リトライの HTTP リクエストが実施され Manifest を取得できる(レイヤーなども取得可能)

カスタム Resolver 例

カスタム Resolver の例として、Amazon ECR containerd resolver が挙げられる。 AWS でコンテナを扱う際は、ECR のプライベートレジストリを使うことが多いと思われるが、containerd での ECR のプライベートレジストリの実装である。

ECR プライベートレジストリは、以下のように OCI Registory(Docker Registory V2)に対応している。

Amazon Elastic Container Registry のよくある質問

Q: Amazon ECR では、どのバージョンの Docker Registry API がサポートされていますか?
Amazon ECR でサポートされているのは、Docker Registry V2 API 仕様です。

そのため、ドキュメントの HTTP API 認証を使用するにあるように curl コマンドを使用してアクセスすることができる。 この際に Docker Hub と同じように Authorization ヘッダに IAM のクレデンシャルをベースとした認可トークンが必要となる。 ECR プライベートレジストリローンチ当時 Docker が主流であり、Docker Hub と同様に(docker login さえすれば) docker コマンドで ECR プライベートレジストリにアクセスできるようにしたためと考えられる。

ECR プライベートレジストリは、上記 Docker Registry V2 の他に AWS API を使用してイメージのプルなど操作可能である。 イメージのプルに関しては、Image Index/Manifest を取得する BatchGetImage API と blobs のダウンロードするための URL を取得する GetDownloadUrlForLayer API である。 ECR Containerd Resolver は、ECR の API を直接実行で実現している。

Resolver インターフェースに照らし合わせると、Resolve は ref(ecr.aws/arn:aws:ecr:<region>:<account>:repository/<name>:<tag> フォーマット)をパースして、BatchGetImage API を実行して、Image Index か Manifest を取得し、その Descriptor を返す。

FetchFetcher は、Image Index/Manifest や、Layer/Config など MediaType で分岐し、対応した API を実行する。 例えば、Layer/Config であれば、GetDownloadUrlForLayer API を実行し、その Layer/Config が保存されている S3 の署名付き URL を取得する。 取得した S3 の署名付き URL 対してリクエストすることで、レイヤーを取得する形となる。 なお、S3 の署名付き URL に対するリクエストは、並列度を設定することでメモリ使用量が増加する代わりに速度を向上可能である。。

Resolver インターフェース呼び出し流れ

クライアント実装の pull.go の fetch で呼ばれる。 https://github.com/containerd/containerd/blob/v1.5.7/pull.go#L128

まず、ref より Resolve を実行し、Image Index もしくは Manifest の Descriptor を取得する。 その次に Fetcher インターフェースを満たす実装を取得する。 Fetcher インターフェースの Fetch が指定した Descriptor を取得(ダウンロード)する。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (c *Client) fetch(ctx context.Context, rCtx *RemoteContext, ref string, limit int) (images.Image, error) {
	store := c.ContentStore()
	name, desc, err := rCtx.Resolver.Resolve(ctx, ref)
	if err != nil {
		return images.Image{}, errors.Wrapf(err, "failed to resolve reference %q", ref)
	}

  (snip)

	fetcher, err := rCtx.Resolver.Fetcher(ctx, name)
	if err != nil {
		return images.Image{}, errors.Wrapf(err, "failed to get fetcher for %q", name)
	}

Fetch の詳細

Descriptor に対してそれぞれの処理を実行するハンドラーを作成する。 ハンドラーの一つである images.ChildrenHandler は Image Index もしくは Manifest の Descriptor の入力を想定している。 入力の Descriptor が Manifest であれば、boltdb から Layer と Config の Descriptor のリストを返し、Image Index であれば、Manifest のリストを返す。

images.FilterPlatforms は、上記取得した Image Index などをプラットフォームなどでフィルターしている。例えば、Linux 環境であれば Image Index から Linux の Manifest を取得する。

images.FetchHandler は、Fetcher インターフェースのラッパーである。

images.Handlers(handlers...) は、個々のハンドラーを順番に同期的に実行する HandlerFunc 型のハンドラー関数を返す(束ねたハンドラーと呼ぶ)

Image Index もしくは Image Manifest の Descriptor と、束ねたハンドラー関数を Dispatch に渡す。 すると、再起的に Dispatch が実行され、Fetcher の Fetch によりイメージのプルが行われる。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
	if desc.MediaType == images.MediaTypeDockerSchema1Manifest && rCtx.ConvertSchema1 {
  (snip)
	} else {
		// Get all the children for a descriptor
		childrenHandler := images.ChildrenHandler(store)
		// Set any children labels for that content
		childrenHandler = images.SetChildrenMappedLabels(store, childrenHandler, rCtx.ChildLabelMap)
		if rCtx.AllMetadata {
			// Filter manifests by platforms but allow to handle manifest
			// and configuration for not-target platforms
			childrenHandler = remotes.FilterManifestByPlatformHandler(childrenHandler, rCtx.PlatformMatcher)
		} else {
			// Filter children by platforms if specified.
			childrenHandler = images.FilterPlatforms(childrenHandler, rCtx.PlatformMatcher)
		}
		// Sort and limit manifests if a finite number is needed
		if limit > 0 {
			childrenHandler = images.LimitManifests(childrenHandler, rCtx.PlatformMatcher, limit)
		}

        (snip)

		appendDistSrcLabelHandler, err := docker.AppendDistributionSourceLabel(store, ref)
		if err != nil {
			return images.Image{}, err
		}

		handlers := append(rCtx.BaseHandlers,
			remotes.FetchHandler(store, fetcher),
			convertibleHandler,
			childrenHandler,
			appendDistSrcLabelHandler,
		)

		handler = images.Handlers(handlers...)

		converterFunc = func(ctx context.Context, desc ocispec.Descriptor) (ocispec.Descriptor, error) {
			return docker.ConvertManifest(ctx, store, desc)
		}
	}
    (snip)

	if err := images.Dispatch(ctx, handler, limiter, desc); err != nil {
		return images.Image{}, err
	}

初回実行時の Dispatch メソッドへの descs は Resolve で解決された Image Manifest もしくは、Image Index の一つだけである。 つまり、Dispatch 内での for-loop は一度である。 Dispatch では、渡された Descriptor 一つに対して一つの go routine が実行される。 初回では、 Image Manifest もしくは、Image Index に対して go routine が一つ起動する。 一方で、ある一つの Descriptor に対して束ねたハンドラーは同期的に実行される。 初回実行では handler.Handle(ctx2, desc) によって、Image Manifest や Image Index の Descriptor に対して、 images.FetchHandlerimages.ChildrenHandler などが実行される。

  1. 例えば、descs が Image Manifest であれば、FetchHandler(Fetcher の Fetch のラッパー)で、Image Manifest が フェッチされる。(この際に boltdb に保存される)

  2. 次に、childrenHandler で、Image Manifest より、Image Config と Image Layers の Descriptor が返される(ネットワーク越しにフェッチするのではなく、boltdb の Image Manifest をパースする) childrenHandler の結果の複数の Descriptor が children 変数になる。

  3. children 変数は 0 以上なので、再帰的に Dispatch が実行される。この際に引数の descs は Config や Layers となるので複数のリストである。

  4. Dispatch 内の for-loop で descs の数だけ対応した go routine が一つ実行される。go routine では、束ねたハンドラーが desc に対して実行される。

  5. 例えば、Dispatch の for-loop で desc が Image Config となり、対応する go routine が起動する。

    1. Image Config の Descriptor に対して FetchHandler(Fetcher の Fetch のラッパー)が Image Config をネットワーク越しに取得する。
    2. Image Config は、Image Index でも Image Manifest でもないので、childrenHandler は Descriptor を返さない。
  6. Dispatch の for-loop の desc が Image Layer となり、対応する go routine が起動する。

    1. Image Layer の Descriptor より FetchHandler が Image Layer をネットワーク越しに取得する
    2. childrenHandler は Descriptor を返さない。
  7. …..

Dispatch https://github.com/containerd/containerd/blob/v1.5.7/images/handlers.go#L120

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func Dispatch(ctx context.Context, handler Handler, limiter *semaphore.Weighted, descs ...ocispec.Descriptor) error {
	eg, ctx2 := errgroup.WithContext(ctx)
	for _, desc := range descs {
		desc := desc

		if limiter != nil {
			if err := limiter.Acquire(ctx, 1); err != nil {
				return err
			}
		}

		eg.Go(func() error {
			desc := desc

			children, err := handler.Handle(ctx2, desc)
			if limiter != nil {
				limiter.Release(1)
			}
			if err != nil {
				if errors.Is(err, ErrSkipDesc) {
					return nil // don't traverse the children.
				}
				return err
			}

			if len(children) > 0 {
				return Dispatch(ctx2, handler, limiter, children...)
			}

			return nil
		})
	}

	return eg.Wait()
}

上記により Config や Layer はそれぞれの go routine で実行されるため、並列にダウンロードされるようになっている。

なお、Dispatch では semaphore.Weighted を使用して、並列起動する go routine の数を制限している。 言い換えると、Config や Layer を並列でプルする数を制御できる。 Acquire はセマフォを取得できるまでブロックされる。

WithMaxConcurrentDownloadsで設定可能であり、Docker ではデフォルト 3 とのこと