OCI Distribution APIで認証しmanifestを取得する——TLSは自作せずstd.http.Clientに任せる

OCI Runtime Specのバンドル形式に合わせてconfig.jsonから起動する記事まででコンテナ起動側の実装が一段落したので、今回からはイメージの取得を扱います。まずOCI Distribution APIで認証し、pull対象イメージのmanifest(layer一覧)を取得するところまでです。

設計方針: TLSは自作しない

これまでのフェーズは一貫して「std.os.linuxに無い部分を自前のsyscallラッパーで埋める」という路線でしたが、今回のHTTP(S)通信はstd.http.Client(Zig標準ライブラリ)をそのまま使います。TLSは実装の正確さがそのままセキュリティに直結する領域で、学習目的で自作するにはリスクとリターンが見合いません。自作する価値があるのはTLSの中身ではなく、OCI Distribution APIのプロトコルフロー(認証チャレンジの解釈・Bearerトークン取得・manifestのcontent negotiation)の方だと判断しました。

std.http.ClientはZig 0.16で導入された非同期I/O抽象(std.Io)を前提にした設計になっていて、pub fn main(init: std.process.Init) !voidinit.ioをそのまま渡せます。

var client = std.http.Client{ .allocator = allocator, .io = io };
defer client.deinit();

認証フロー: 401のチャレンジヘッダからトークン発行元を知る

OCI Distribution Specの認証は3ステップです。

  1. 未認証のままAPIを叩くと401が返り、WWW-Authenticateヘッダにトークン発行元(realm)とサービス名(service)が入っている
  2. realmに対してservicescoperepository:<repo>:pull)をクエリパラメータで渡し、Bearerトークンを取得する
  3. 以降のAPI呼び出しにAuthorization: Bearer <token>を付ける

Docker Hubで実際に確認したチャレンジヘッダです。

$ curl -sD - -o /dev/null https://registry-1.docker.io/v2/
www-authenticate: Bearer realm="https://auth.docker.io/token",service="registry.docker.io"

これを解析する部分です。

const Challenge = struct {
    realm: []const u8,
    service: []const u8,
};

// "Bearer realm="https://auth.docker.io/token",service="registry.docker.io"" を分解する。
fn parseChallenge(allocator: std.mem.Allocator, header_value: []const u8) !Challenge {
    var realm: []const u8 = "";
    var service: []const u8 = "";

    const prefix = "Bearer ";
    if (!std.mem.startsWith(u8, header_value, prefix)) return error.UnsupportedAuthScheme;

    var it = std.mem.splitScalar(u8, header_value[prefix.len..], ',');
    while (it.next()) |pair| {
        const eq = std.mem.indexOfScalar(u8, pair, '=') orelse continue;
        const key = std.mem.trim(u8, pair[0..eq], " ");
        const value = std.mem.trim(u8, pair[eq + 1 ..], " \"");
        if (std.mem.eql(u8, key, "realm")) realm = try allocator.dupe(u8, value);
        if (std.mem.eql(u8, key, "service")) service = try allocator.dupe(u8, value);
    }
    if (realm.len == 0 or service.len == 0) return error.InvalidChallenge;
    return .{ .realm = realm, .service = service };
}

realmをレジストリのレスポンスから動的に読み取っているため、このコードはDocker Hub専用ではなく、Bearerトークン方式を採用しているOCI準拠レジストリ(GHCR・Quay等)であればホスト名を変えるだけで動きます。

manifestの取得とcontent negotiation

GET /v2/<repository>/manifests/<reference>を、Acceptヘッダに複数のmedia typeを列挙して呼び出します。

const MANIFEST_ACCEPT = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json";

hello-worldのようなDocker公式イメージは、タグを引くと単一のmanifestではなく、複数プラットフォーム分の一覧であるmanifest indexが返ってきます。レスポンスのContent-Typeでこれを判別し、linux/amd64に該当するエントリのdigestで改めてmanifestを取得し直す、という2段構えにしています。

pub fn pullManifest(
    allocator: std.mem.Allocator,
    client: *std.http.Client,
    repository: []const u8,
    reference: []const u8,
) !Manifest {
    const challenge = try discoverChallenge(allocator, client);
    const token = try fetchToken(allocator, client, challenge, repository);

    var result = try fetchManifestRaw(allocator, client, repository, reference, token);

    if (isIndexMediaType(result.content_type)) {
        const index = try std.json.parseFromSliceLeaky(ManifestIndex, allocator, result.body, .{
            .ignore_unknown_fields = true,
        });
        const entry = try selectPlatform(index);
        result = try fetchManifestRaw(allocator, client, repository, entry.digest, token);
    }

    return try std.json.parseFromSliceLeaky(Manifest, allocator, result.body, .{
        .ignore_unknown_fields = true,
    });
}

Acceptヘッダに列挙しているのは、いずれも「manifestかindexを返すエンドポイント」が返しうる形式だけです。manifestの中に書かれているconfig/layersのmedia type(application/vnd.oci.image.config.v1+jsonapplication/vnd.oci.image.layer.v1.tar+gzip)は、これとは別のGET /v2/<repository>/blobs/<digest>エンドポイントから取得するものなので、ここでは要求していません。blobはdigest(ハッシュ値)でコンテンツアドレスされているため、「何を返すか」を交渉する必要自体がなく、digestを指定すればその内容がそのまま返ってきます。

実装時のミス: "Bearer ""Beader "と書き間違えた

parseChallengeのプレフィックス判定で、"Bearer ""Beader "rdの位置を間違えた単純なタイポ)と書いてしまい、WWW-Authenticateヘッダの値が常にこのプレフィックスと一致しなくなってerror.UnsupportedAuthSchemeが返る状態になっていました。文字列比較のタイポはコンパイルエラーにならないため気づきにくく、実際に動かして初めて発覚しました。

動作確認

Docker Hubに対して、実際にmanifestの取得まで確認しました。

$ ./zig-out/bin/zigcon pull library/hello-world:latest
config: sha256:e2ac70e7319a02c5a477f5825259bd118b94e8b02c279c67afa63adab6d8685b (577 bytes, application/vnd.oci.image.config.v1+json)
layer[0]: sha256:4f55086f7dd096d48b0e49be066971a8ed996521c2e190aa21b2435a847198b4 (2415 bytes, application/vnd.oci.image.layer.v1.tar+gzip)

$ ./zig-out/bin/zigcon pull library/busybox:latest
config: sha256:c6348fa86ba0fb2108c9334f5fe913ddc6d853313e655891f133a0127c30099f (459 bytes, application/vnd.oci.image.config.v1+json)
layer[0]: sha256:b05093807bb0294152bb9cf86d64da722732dddaf7f8882fa1f120477dbc4db3 (2226327 bytes, application/vnd.oci.image.layer.v1.tar+gzip)

hello-worldはmanifest indexを経由した2段階の解決が正しく機能し、layerが1つだけの最小構成であることも取得結果と一致しています。busyboxはlayerサイズが約2.2MBと、実際のイメージサイズと整合する値が取れています。存在しないリポジトリを指定した場合もManifestRequestFailedとして検知できることを確認しました。

今回はmanifestの取得までがスコープで、実際にlayerのblob本体(tar+gzip)をダウンロードして展開する処理は次のフェーズで扱います。