pullしたlayerを展開してrootfs化し、OCI Runtime Spec形式のbundleを生成する

OCI Distribution APIで認証しmanifestを取得する記事では、manifestの取得までをスコープとしていました。今回はその続きとして、manifestに列挙されたlayerのblob(tar+gzip)を実際にダウンロードして展開し、pullコマンド単体でOCI Runtime Specのバンドル形式からコンテナを起動する記事で実装したrunコマンドがそのまま読み込めるbundle(config.json + rootfs/)を生成するところまで実装しました。

layerの展開: gzip展開 + tar展開

Docker HubのlayerはOCI Image Specのapplication/vnd.oci.image.layer.v1.tar+gzip形式、つまりtarをgzipで圧縮したものです。Zig標準ライブラリのstd.compress.flate(gzip)とstd.tarをパイプラインでつなぎ、ダウンロードしたblobをrootfsディレクトリへ直接展開します。

pub fn extractLayer(
    io: std.Io,
    dir: std.Io.Dir,
    layer: oci_registry.Descriptor,
    blob: []const u8
) !void {
    if (std.mem.indexOf(u8, layer.mediaType, "gzip") == null) {
        return error.UnsupportedLayerMediaType;
    }

    var reader: std.Io.Reader = .fixed(blob);
    var decompress_buffer: [std.compress.flate.max_window_len]u8 = undefined;
    var decompress: std.compress.flate.Decompress = .init(&reader, .gzip, &decompress_buffer);

    try std.tar.extract(io, dir, &decompress.reader, .{});
}

std.compress.flate.Decompressstd.Io.Readerをラップして、読み出し時に逐次伸長する形の実装になっています。gzip展開後のバイト列を丸ごとメモリに確保する必要がなく、std.tar.extract側の読み出しに合わせてストリーミングで展開できます。今回は学習目的で単一layerのalpineイメージのみを検証対象にしているため、複数layerのマージ(上位layerによる下位layerのファイル上書き・whiteoutファイルの処理)は範囲外にしています。

digest検証をどこに効かせるか

OCI Distribution APIはすべてのblob(layer・config)をSHA-256のdigestでコンテンツアドレスしているため、ダウンロードした内容が改ざん・破損していないかをdigestとの比較で検証できます。前回実装したpullBlobでは、この検証をすでに組み込んでいました。

pub fn pullBlob(self: Session, digest: []const u8) ![]const u8 {
    // ...
    if (result.status != .ok) return error.BlobRequestFailed;

    try verifyDigest(result.body, digest);

    return result.body;
}

一方、manifestの取得(pullManifest)ではdigest検証をしていない箇所がありました。hello-worldのようなDocker公式イメージはmanifest indexを経由し、選択したプラットフォームエントリのdigestで改めてmanifest本体を取得し直す2段構えになっています。この2段階目の取得は、entry.digestという「期待するdigest」がすでに手元にある状態でのリクエストです。にもかかわらず、取得したレスポンスをそのdigestと突き合わせていませんでした。

pub fn pullManifest(self: Session, reference: []const u8) !Manifest {
    var result = try fetchManifestRaw(self.allocator, self.client, self.repository, reference, self.token);

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

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

タグ指定での最初の取得(referencelatestのようなタグの場合)は、そもそも期待するdigestを事前に知りようがないため検証しようがありません。一方、indexの中に記載されているdigest経由の取得は検証可能かつ検証すべき箇所です。同じpullManifest関数の中でも、取得経路によって「digestを検証できるかどうか」が異なる、というのが実装していて分かりやすかった点です。

pullしたimage configからconfig.jsonを生成する

runコマンドはOCI Runtime Spec形式のconfig.jsonを前提にしていますが、OCI Distribution APIから取得できるのはOCI Image Spec形式のconfig blob(Config.Entrypoint/Cmd/Env/WorkingDirなどを含むJSON)です。この2つは仕様として別物なので、layer展開に続けて変換処理を実装しました。

const ImageConfigDetail = struct {
    Env: [][]const u8 = &.{},
    Entrypoint: ?[][]const u8 = null,
    Cmd: ?[][]const u8 = null,
    WorkingDir: []const u8 = "",
};

const ImageConfig = struct {
    config: ImageConfigDetail = .{},
};

fn resolveProcessArgs(allocator: std.mem.Allocator, image_config: ImageConfigDetail) ![][]const u8 {
    const entrypoint = image_config.Entrypoint orelse &.{};
    const cmd = image_config.Cmd orelse &.{};
    if (entrypoint.len == 0 and cmd.len == 0) {
        return allocator.dupe([]const u8, &.{"/bin/sh"});
    }

    const args = try allocator.alloc([]const u8, entrypoint.len + cmd.len);
    @memcpy(args[0..entrypoint.len], entrypoint);
    @memcpy(args[entrypoint.len..], cmd);
    return args;
}

pub fn writeBundleConfig(
    allocator: std.mem.Allocator,
    io: std.Io,
    bundle_dir: []const u8,
    config_blob: []const u8,
) !void {
    const image_config = try std.json.parseFromSliceLeaky(ImageConfig, allocator, config_blob, .{
        .ignore_unknown_fields = true,
    });

    const runtime_config = oci_config.Config{
        .process = .{
            .args = try resolveProcessArgs(allocator, image_config.config),
            .env = image_config.config.Env,
            .cwd = if (image_config.config.WorkingDir.len == 0) "/" else image_config.config.WorkingDir,
        },
        .root = .{ .path = "rootfs" },
    };

    const config_path = try std.fmt.allocPrint(allocator, "{s}/config.json", .{bundle_dir});
    const file = try std.Io.Dir.cwd().createFile(io, config_path, .{});
    defer file.close(io);

    var buffer: [8192]u8 = undefined;
    var writer = file.writer(io, &buffer);
    try std.json.Stringify.value(runtime_config, .{ .whitespace = .indent_2 }, &writer.interface);
    try writer.interface.flush();
}

EntrypointCmdはどちらもOCI Image Specでは省略可能なフィールドで、どちらも指定がない場合はコンテナのエントリポイントが定まらないため、/bin/shをフォールバックにしています。Docker Hub側のEntrypoint/Cmdのフィールド名は先頭が大文字(EntrypointCmdEnvWorkingDir)ですが、これはOCI Image Specの元になったDocker独自のイメージconfig形式をそのまま踏襲しているためで、Zig側の構造体フィールド名もJSONのキーと完全一致させる必要があります。

oci_config.Configconfig.jsonの記事で実装済みの構造体をそのまま再利用しています。runコマンド側は「bundleディレクトリを読む」という以外の関心を持たないため、pull側で生成したconfig.jsonであっても手書きのconfig.jsonであっても区別なく動きます。

pullコマンド全体の流れ

main.zigpullImageは、manifest取得・layer展開・config.json生成をこの順で行います。

const bundle_dir = try std.fmt.allocPrint(allocator, "pulled/{s}", .{repository});
const rootfs_path = try std.fmt.allocPrint(allocator, "{s}/rootfs", .{bundle_dir});
const rootfs_dir = try std.Io.Dir.cwd().createDirPathOpen(io, rootfs_path, .{});
defer rootfs_dir.close(io);

for (manifest.layers, 0..) |layer, i| {
    const blob = try session.pullBlob(layer.digest);
    try oci_image.extractLayer(io, rootfs_dir, layer, blob);
}

const config_blob = try session.pullBlob(manifest.config.digest);
try oci_image.writeBundleConfig(allocator, io, bundle_dir, config_blob);

bundle_dir配下にrootfs/config.jsonが揃うため、pulled/<repository>をそのままrunコマンドに渡せます。

動作確認

Docker Hubからalpineイメージをpullし、生成されたbundleの中身を確認します。

$ ./zig-out/bin/zigcon pull library/alpine:latest
config: sha256:d529dd0c6e5597ac7e4a3e2dea65c3fcc6173f4cae713c409265c1dd9914a11b (611 bytes, application/vnd.oci.image.config.v1+json)
layer[0]: sha256:55afa1ecc21d2bb5e5045f32dafee56272ffd89860bac26f6c32123439af26a4 extracted (3846391 bytes, application/vnd.oci.image.layer.v1.tar+gzip)
bundle ready: pulled/library/alpine

$ cat pulled/library/alpine/config.json
{
  "ociVersion": "1.0.2",
  "hostname": "zigcon",
  "process": {
    "args": ["/bin/sh"],
    "env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
    "cwd": "/"
  },
  "root": { "path": "rootfs" },
  "linux": { "resources": { "memory": { "limit": null }, "cpu": { "quota": null, "period": null } } }
}

alpineイメージのCMD(/bin/sh)とPATH環境変数が、そのままprocess.args/process.envに反映されています。生成されたbundleをrunコマンドに渡し、実際にコンテナとして起動できることも確認しました。

$ sudo ./zig-out/bin/zigcon run pulled/library/alpine
[parent] loaded bundle: rootfs=pulled/library/alpine/rootfs, process=/bin/sh, hostname=zigcon
...
[child] pivot_root done, root filesystem is now pulled/library/alpine/rootfs
[child] handing over to /bin/sh
/ # cat /etc/os-release
NAME="Alpine Linux"
VERSION_ID=3.24.1
/ # ps aux
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    3 root      0:00 ps aux
/ # exit
[parent] child exited with status: 0

os-releaseの内容がAlpine Linuxのものと一致していること、PID namespace越しにPID 1がコンテナ内の/bin/sh自身になっていることから、Docker Hubからpullしたイメージが、これまでのフェーズで実装したnamespace・cgroup・capabilities/seccompの制御を経て正しく起動できることを確認できました。これで、pullからrunまでが一つのCLIでつながったことになります。

今回実装した範囲は単一layer・単一プラットフォーム(linux/amd64)のイメージのみで、複数layerのマージやWindows/arm64など他プラットフォームの選択は検証していません。