PostgreSQL 上のコンテナレジストリ

このページは、コンテナレジストリのさまざまなデータベース設計アプローチについての議論を追跡するためのものです。

背景と参考資料

重複排除率

Docker マニフェストは、Docker イメージが複数のレイヤーで構成されていることを記述します。マニフェストは参照するレイヤーによって識別でき、そのためレジストリ全体で一意と考えることができます。複数のリポジトリが同じマニフェストを参照します。

以下では、そのモデル化のためのさまざまなアプローチを議論します。モデル1は、同じマニフェストのレコードが常に1つだけ存在し、リポジトリはマニフェストを単に参照するように、マニフェストを重複排除することを目指しています。モデル2はこのようにマニフェストを重複排除しません。ここでは、マニフェストは常にリポジトリに属します(これがキーの一部です)。同じマニフェストは複数のリポジトリに存在でき、これにより「重複した」マニフェストエントリが生じます(異なるリポジトリに存在するという事実は別として)。

レイヤーについても同様の概念が成立します。

dev.gitlab.org からのコンテナレジストリをモデル1を使用してデータベースにインポートしました。このセクションでは、データベース内の重複排除から期待されることを明らかにするために、そのインポートから統計を収集します。

「重複排除係数」は、重複排除しなかった場合に期待されるエントリ数の比率です。

エンティティ参照元重複排除係数
マニフェストリポジトリ1.003
Blobレイヤー1.53
Blobリポジトリ1.17

マニフェストの例:

select (select count(*) from repository_manifests) / (select count(*)::numeric from manifests);

dev.gitlab.org のレジストリでは、ほぼすべてのマニフェストが一意であることがわかります(重複排除係数 1.003)。平均して、blob は1.53のレイヤーと1.17のリポジトリから参照されています。

要約すると、もしデータベース内のレコードを重複排除しなかった場合(以下のモデル2)、dev.gitlab.org が十分に代表的であると仮定すると、レコード数は2倍以下になります。

データベース設計の代替案

モデル1: 「多対多」で重複レコードなし

このアプローチは、関連するすべてのコンテナレジストリ機能をサポートするために検証されています。データベース設計の議論は詳細に記載されており、クエリ例とそのプランが含まれています。

このアプローチのアイデアは、すべてのエンティティをファーストクラスの市民として扱い、多対多の参照テーブルを使用してそれらを接続することです。例えば、特定のマニフェストダイジェストに対して、manifests には常に1つのエントリのみが存在します。このエントリは多対多の参照テーブル repository_manifests を通じて複数のリポジトリから参照される場合があります。

これにより自然にレコードの重複排除が実現します。レコードは repository の存在に物理的に結びついていないからです。上記「重複排除率」を参照してください。

er_model

メリット
メリット1: コンテナレジストリの既存のファイルシステムモデルとよく一致する
デメリット
デメリット1: 共通のパーティショニングキーがない

このモデルには自然なパーティショニングキーがありません。テーブルへのアクセスパターンがいくつかあります。すべてのモデルがファーストクラスの市民であるため、すべてのテーブル(または十分に大きいサブセット)が共通してアクセスされる次元がありません。

これにより、意味のあるパーティショニングスキームを見つけることが問題となるか、不可能になります。テーブルが大きくなると、パフォーマンスの問題を引き起こす可能性があります。

デメリット2: モデルは自然に「宙吊り」レコードを生成する

このモデルの性質は、すべてのエンティティをファーストクラスの市民として扱うことです。これによりレコードの重複排除が助かりますが、エンティティがもはや参照を持たないのにデータベースに存在し続ける状態も許容します。

これは、孤立したエントリをクリーンアップするためのガベージコレクションアルゴリズムが必要になることを意味します。

デメリット3: ガベージコレクションのコストが高い

モデルはレコードが「宙吊り」になることを許容するため、それを見つけて最終的に削除する GC アルゴリズムを実装する必要があります。宙吊りマニフェストの例を見てみましょう。これはどのリポジトリからも参照されていないマニフェストです。次のように最大 N=100 件の宙吊りマニフェストのバッチを見つけられます:

SELECT *
FROM manifests m
WHERE NOT EXISTS (
  SELECT 1
  FROM repository_manifests rm
  WHERE rm.manifest_id = m.id
)
LIMIT 100

これは2つのインデックスのスキャンとして実行できる、完全なリレーションに対するアンチジョインです。最良の場合、N=100 件のレコードをすぐに見つけられます。ただし、これはあまり起きません。私たちは一般的にレジストリをそのような宙吊りレコードのない状態に保つよう努めているからです。最悪の場合は、レジストリがクリーンな状態(宙吊りレコードが存在しない)のときです。この場合、リレーション全体をスキャンします。これは高コストで非線形です。

アンチジョインのランタイム特性の良い例があります。ここでは、ランタイムは数ミリ秒(最良の場合 — 多くの「宙吊りレコード」またはスキャンの先頭に「宙吊りレコード」がある)から約30秒(宙吊りレコードがない)の間で変動します。

デメリット4: 参照テーブルが大きくなることが予想される

多対多の参照テーブルは、例えば N 件のリポジトリと M 件のマニフェストを接続するもの(接続ごとに1レコード)として大きくなります。

最終的に、これらが非常に大きくなるとパフォーマンスの問題になる可能性があります。これは特に、参照テーブルが1つ以上の他のテーブルとのジョインで使用されているためです。

モデル2: 独立した Blob 管理を持つ 1-N リポジトリ構造

このアプローチでは、リポジトリをファーストクラスのモデルとして扱います。リポジトリは多くのマニフェストを含み(1-N)、それらは多くのレイヤーを含みます(1-N)。リポジトリ構造とは別に、オブジェクトストレージに存在する blob を追跡します。指定されたレイヤーを表す blob を追跡するための参照テーブルを自動的に維持します(blob ダイジェストによるルックアップ)。

モデル1との細かな違いとして、アルゴリズムとダイジェストの両方を格納するために単一の digest カラムを使用し、メディアタイプ情報をルックアップテーブルに正規化しています。

参考用 SQL スキーマ
BEGIN;

CREATE TABLE public.media_types (
    created_at timestamp WITH time zone NOT NULL DEFAULT now(),
    id smallint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    media_type text not null,
    CONSTRAINT ck_media_types_type_length CHECK ((char_length(media_type) <= 255)),
    CONSTRAINT pk_media_types PRIMARY KEY (id),
    CONSTRAINT uq_media_types_type UNIQUE (media_type)
);

CREATE TABLE public.blobs (
      size bigint NOT NULL,
      created_at timestamp WITH time zone NOT NULL DEFAULT now(),
      media_type_id smallint not null references media_types (id),
      digest bytea not null,
      CONSTRAINT pk_blobs PRIMARY KEY (digest)
) PARTITION BY HASH (digest);

CREATE TABLE public.repositories (
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    parent_id bigint REFERENCES repositories (id) on delete cascade,
    created_at timestamp WITH time zone NOT NULL DEFAULT now(),
    updated_at timestamp WITH time zone,
    name text NOT NULL,
    path text NOT NULL,
    CONSTRAINT pk_repositories PRIMARY KEY (id),
    CONSTRAINT uq_repositories_path UNIQUE (path),
    CONSTRAINT ck_repositories_name_length CHECK ((char_length(name) <= 255)),
    CONSTRAINT ck_repositories_path_length CHECK ((char_length(path) <= 255))
);

CREATE TABLE public.manifests (
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    repository_id bigint not null references repositories (id) ON DELETE CASCADE,
    created_at timestamp WITH time zone NOT NULL DEFAULT now(),
    schema_version smallint NOT NULL,
    media_type_id smallint not null references media_types (id),
    configuration_media_type_id smallint references media_types (id),
    configuration_payload bytea,     -- 実際の設定ペイロード
    configuration_blob_digest bytea references blobs (digest), -- 設定用 blob への参照
    digest bytea not null,
    payload bytea NOT NULL,
    CONSTRAINT pk_manifests PRIMARY KEY (repository_id, id),
    CONSTRAINT uq_manifests_repository_id_digest UNIQUE (repository_id, digest)
) PARTITION BY HASH (repository_id);

CREATE TABLE public.manifest_references (
    id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    repository_id bigint not null,
    parent_id bigint NOT NULL,
    child_id bigint NOT NULL,
    created_at timestamp WITH time zone NOT NULL DEFAULT now(),
    CONSTRAINT pk_manifest_references PRIMARY KEY (repository_id, id),
    CONSTRAINT uq_manifest_references_parent_id_child_id UNIQUE (repository_id, parent_id, child_id),
    CONSTRAINT ck_manifest_references_parent_id_child_id_differ CHECK (parent_id <> child_id),
    CONSTRAINT fk_manifest_references_repository_id_parent_id_manifests FOREIGN KEY (repository_id, parent_id) REFERENCES manifests (repository_id, id) ON DELETE CASCADE,
    CONSTRAINT fk_manifest_references_repository_id_child_id_manifests FOREIGN KEY (repository_id, child_id) REFERENCES manifests (repository_id, id) ON DELETE CASCADE
) PARTITION BY HASH (repository_id);

CREATE TABLE public.layers (
     id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
     repository_id bigint not null,
     manifest_id bigint NOT NULL,
     -- blobs の冗長情報 — 大きなジョインを避けるのに役立つ
     size bigint NOT NULL,
     created_at timestamp WITH time zone NOT NULL DEFAULT now(),
     media_type_id smallint not null references media_types (id),
     digest bytea not null references blobs (digest),
     CONSTRAINT pk_layers PRIMARY KEY (repository_id, id),
     CONSTRAINT uq_layers_repository_id_manifest_id_digest UNIQUE (repository_id, manifest_id, digest),
     CONSTRAINT fk_layers_repository_id_manifest_id_manifests FOREIGN KEY (repository_id, manifest_id) REFERENCES manifests (repository_id, id) ON DELETE CASCADE
) PARTITION BY HASH (repository_id);

-- 説明目的のみ — これらは実際に「宙吊り」で削除可能かどうかをレビューすべき blob です
-- blobs_layers の参照を NULL に設定する代替手段
CREATE TABLE public.blob_review_queue (
  review_after timestamp with time zone not null default now() + INTERVAL '1 day',
  review_count integer not null default 0,
  digest bytea not null primary key
);

CREATE FUNCTION public.track_blob_usage() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
IF (TG_OP = 'DELETE') THEN
  -- TODO: ここでより多くの処理を行えます。これは説明目的のみです。 
  -- 注意: これはトリガーである必要はなく、アプリケーションロジックでも可能です
  IF (SELECT COUNT(*) FROM blobs_layers WHERE id <> OLD.id AND digest = OLD.digest AND layer_id IS NOT NULL) = 0 THEN
    INSERT INTO blob_review_queue (digest) VALUES (OLD.digest) ON CONFLICT (digest) DO NOTHING;
  END IF;
ELSIF (TG_OP = 'INSERT') THEN
  INSERT INTO blobs_layers (digest, repository_id, layer_id)
  VALUES (NEW.digest, NEW.repository_id, NEW.id)
  ON CONFLICT (digest, layer_id) DO NOTHING;

  INSERT INTO repository_blobs (repository_id, blob_digest)
  VALUES (NEW.repository_id, NEW.digest)
  ON CONFLICT (repository_id, blob_digest) DO NOTHING;
END IF;

RETURN NULL;

END
$$;

CREATE TRIGGER track_blob_usage_trigger AFTER INSERT OR UPDATE OR DELETE ON public.layers FOR EACH ROW EXECUTE PROCEDURE public.track_blob_usage();

-- layers への INSERT トリガーを通じて管理される
CREATE TABLE public.blobs_layers (
  id bigint not null generated by default as identity,
  repository_id bigint NOT NULL,
  layer_id bigint NOT NULL,
  digest bytea not null REFERENCES blobs (digest) ON DELETE CASCADE,
  CONSTRAINT pk_blobs_layers PRIMARY KEY (digest, id),
  CONSTRAINT uq_blobs_layers_digest_layer_id UNIQUE (digest, layer_id),
  CONSTRAINT fk_blobs_layers_repository_id_layer_id_layers FOREIGN KEY (repository_id, layer_id) REFERENCES layers (repository_id, id) ON DELETE CASCADE
) PARTITION BY HASH (digest);

CREATE TABLE public.blobs_configurations (
  id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  repository_id bigint NOT NULL,
  manifest_id bigint NOT NULL,
  digest bytea NOT NULL REFERENCES blobs (digest) ON DELETE CASCADE,
  CONSTRAINT pk_blobs_configurations PRIMARY KEY (digest, id),
  CONSTRAINT fk_blobs_configurations_repository_id_manifest_id_manifests FOREIGN KEY (repository_id, manifest_id) REFERENCES manifests (repository_id, id) ON DELETE CASCADE,
  CONSTRAINT uq_blobs_configurations_digest_layer_id UNIQUE (digest, manifest_id)
) PARTITION BY HASH (digest);

CREATE TABLE public.tmp_blobs_manifests (
    digest bytea NOT NULL,
    CONSTRAINT pk_tmp_blobs_manifests PRIMARY KEY (digest)
);

CREATE TABLE public.tags (
     id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
     repository_id bigint NOT NULL,
     manifest_id bigint NOT NULL,
     created_at timestamp WITH time zone NOT NULL DEFAULT now(),
     updated_at timestamp WITH time zone,
     name text NOT NULL,
     CONSTRAINT pk_tags PRIMARY KEY (repository_id, id),
     CONSTRAINT uq_tags_repository_id_name UNIQUE (repository_id, name),
     CONSTRAINT ck_tags_name_length CHECK ((char_length(name) <= 255)),
     CONSTRAINT fk_tags_repository_id_manifest_id_manifests FOREIGN KEY (repository_id, manifest_id) REFERENCES manifests (repository_id, id) ON DELETE CASCADE
) PARTITION BY HASH (repository_id);

CREATE TABLE public.repository_blobs (
      id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
      repository_id bigint NOT NULL REFERENCES repositories (id) ON DELETE CASCADE,
      created_at timestamp WITH time zone NOT NULL DEFAULT now(),
      blob_digest bytea not null REFERENCES blobs (digest) ON DELETE CASCADE,
      CONSTRAINT pk_repository_blobs PRIMARY KEY (repository_id, id),
      CONSTRAINT uq_repository_blobs_repository_id_blob_digest UNIQUE (repository_id, blob_digest)
) PARTITION BY HASH (repository_id);

CREATE SCHEMA partitions;

SET search_path=partitions;

-- HASH にデフォルトパーティションがないとは誰が言った?
CREATE TABLE blobs_default PARTITION OF public.blobs FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE manifests_default PARTITION OF public.manifests FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE manifest_references_default PARTITION OF public.manifest_references FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE layers_default PARTITION OF public.layers FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE blobs_layers_default PARTITION OF public.blobs_layers FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE tags_default PARTITION OF public.tags FOR VALUES WITH (MODULUS 1, REMAINDER 0);
CREATE TABLE repository_blobs_default PARTITION OF public.repository_blobs FOR VALUES WITH (MODULUS 1, REMAINDER 0);

SET search_path=public;

INSERT INTO media_types (media_type) VALUES ('default');
INSERT INTO repositories (name, path) VALUES ('testrepo', 'testrepo');
INSERT INTO manifests (repository_id, schema_version, media_type_id, digest, payload) VALUES (1, 1, 1, decode('00112233', 'hex'), decode('00', 'hex'));
INSERT INTO blobs (digest, size, media_type_id) VALUES (decode('0011223344', 'hex'), 32, 1);
INSERT INTO layers (repository_id, manifest_id, digest, size, media_type_id) VALUES (1, 1, decode('0011223344', 'hex'), 32, 1);

COMMIT;

alternative_model

図に示されていないのは、blobs_layers を自動的に追跡する可能性です。これはデータベース内で実装できますが、アプリケーションから行うこともできます。データベース内実装は次のようなトリガーに依存します:

CREATE FUNCTION public.track_blob_usage() RETURNS trigger
    LANGUAGE plpgsql
    AS $$
BEGIN
IF (TG_OP = 'DELETE') THEN
  -- TODO: ここでより多くの処理を行えます。これは説明目的のみです。
  -- 注意: これはトリガーである必要はなく、アプリケーションロジックでも可能です
  IF (SELECT COUNT(*) FROM blobs_layers WHERE id <> OLD.id AND digest = OLD.digest AND layer_id IS NOT NULL) = 0 THEN
    INSERT INTO blob_review_queue (digest) VALUES (OLD.digest) ON CONFLICT (digest) DO NOTHING;
  END IF;
ELSIF (TG_OP = 'INSERT') THEN
  INSERT INTO blobs_layers (digest, repository_id, layer_id)
  VALUES (NEW.digest, NEW.repository_id, NEW.id)
  ON CONFLICT (digest, layer_id) DO NOTHING;

  INSERT INTO repository_blobs (repository_id, blob_digest)
  VALUES (NEW.repository_id, NEW.digest)
  ON CONFLICT (repository_id, blob_digest) DO NOTHING;
END IF;

RETURN NULL;

END
$$;

CREATE TRIGGER track_blob_usage_trigger AFTER INSERT OR UPDATE OR DELETE ON public.layers FOR EACH ROW EXECUTE PROCEDURE public.track_blob_usage();

layers への挿入の場合、blobs_layersrepository_blobs で逆引きルックアップも自動的に追跡します。レイヤーを削除するとき、この blob に残りの使用がないか確認し(digest による効率的なルックアップに注意)、なければ digest をキューイングテーブルに挿入する場合があります。バックグラウンドプロセスがこれらのレコードを取り出し、最終的にオブジェクトストレージと blobs テーブルをクリーンアップします。

これは説明目的のものであり、詳細は後で検討できます。

メリット
メリット1: Blob の効率的なガベージコレクション — 「スキャン型」GC が不要

モデル1とは対照的に、このアプローチではガベージコレクションが簡単になります。データベースの内容は常に一貫しているため、「宙吊り」レコードを見つけるためにテーブル全体をスキャンする必要がありません。

例えば、レイヤーを削除するとき — 影響を受ける blob を簡単に特定し、さらなるチェックと最終的な削除のためにスケジュールできます。これはデータベース内(トリガー)でも実装できます。カスケードされた外部キーに依存すれば、これらすべてを DELETE でトリガーできます — フルリポジトリの削除でさえ(バッチ削除を検討したいかもしれませんが)、すべての manifestslayers のクリーンアップをトリガーし、最終的に関連する blob の削除をスケジュールできます。

メリット2: Blob 以外のエンティティに GC が不要

Blob 管理は(一部の)GC アルゴリズムが必要です。オブジェクトストレージのデータを効果的に重複排除しているからです。ただし、マニフェストやレイヤーなどの他のエンティティは、このモデルでは GC を実行する必要がありません。

これはモデル1とは対照的です。モデル1では、データベース内でもすべてのエンティティを重複排除するため、レコードが「宙吊り」になることを許容します。

メリット3: リポジトリと Blob のダイジェストによるパーティショニングをサポート

データベースには2つの独立した領域があり、それぞれ独自のパーティショニングキーを持ちます:

  1. リポジトリとその構造(repository_id でパーティション)
  2. Blob 管理(digest でパーティション)

パーティショニングキーの選択は、それぞれのテーブルへのアクセス方法を決定します。最も効率的なクエリのために、常にそれぞれのパーティショニングキーを使用する必要があります。

Blob 情報については、以下の「デメリット2」につながります。

このパーティショニングスキームは最終的に、アプリケーションシャーディング設計の作成にも使用できます。リポジトリ構造に同じアイデアを適用し(リポジトリで分割)、単一の Blob 管理(または空間利用効率を犠牲にしてさらに多くのパーツに分割)に依存します。

デメリット
デメリット1: マニフェストとレイヤーレコードの重複

リポジトリがトップレベルエンティティであるため、マニフェストとそのレイヤーは重複排除されません(モデル1では、特定のマニフェストを2回保存しない)。これは、内部でリポジトリごとにデータを分離する機能をサポートするために意図的です(パーティショニングを参照)。

manifests にはマニフェストの実際のペイロードも含まれているため、これはデータベースの全体サイズに大きな影響を与える可能性があります。これがパーティショニングによって軽減されるかどうかはまだ不明です。これはマニフェストの重複排除の効率、つまり重複排除係数に依存します。

デメリット2: layers 内の Blob メタデータの重複

Blob には digestsize、検出された media_type_id などのメタデータが添付されています。これは blobs に保存されますが、layers にも保存されます。この重複の理由は、マニフェストの範囲(例: マニフェストのすべてのレイヤー)をスキャンするクエリがあるからです。manifests.digest のみがあった場合、blobs でこの情報を検索する必要があります。このテーブルは digest でのみ効率的にクエリできるため、ルックアップは単一レコードのクエリとなり、効果的に N+1 パターンになります。これは情報を layers に複製することで軽減されます。

モデル2でのガベージコレクション

潜在的に宙吊りになるレコードの管理例

注意: ほとんどの場合、まずリポジトリの <name> を対応する <id> に解決する必要があります。後のクエリでパーティショニングキーとして id を使用できるようにするためです。

例: マニフェストの削除
DELETE /v2/<name>/manifests/<digest>

この場合、manifests の対応するレコードを削除します:

DELETE FROM manifests WHERE repository_id=:id AND digest=:digest

インストールされたトリガーが、マニフェスト内で参照されているすべての blob(設定とレイヤー)を blob_review_queue に挿入します。後の GC プロセスがそれらを検査し、参照がなくなった blob を削除します。

例: リポジトリの削除

フルリポジトリの削除は API 仕様では実装されていないようです。リポジトリ内のすべてのマニフェストを削除するように機能します。

例: マニフェストのタグ解除
DELETE /v2/<name>/tags/reference/<reference>

この場合、タグを削除します:

DELETE FROM tags WHERE repository_id=:id AND name=:reference

これにより、タグなしのマニフェストが生じ、削除の対象になる場合があります。このチェックをバックグラウンド GC に延期するために、タグが指すマニフェストが参照するすべての blob を blob_review_queue に挿入します。ただし、同じリポジトリ内の同じマニフェストを他のタグが指している場合は除きます。

同じマニフェストに別のタグがある場合、このステップを早期にスキップできる場合があります。シンプルのため、これらのチェックを GC で行うこともできます。

例: Blob のアップロード、マニフェストのプッシュ
PUT /v2/<name>/blobs/uploads/<uuid>

Blob のアップロードが完了すると、blobs にレコードを作成し、repository_blobs を通じて blob をリポジトリに関連付けます。通常の状況では、クライアントはマニフェストをプッシュするまでさらに blob をアップロードする場合があります。ただし、このプロセスが失敗する場合があり、宙吊りの blob(リポジトリに関連しているが、マニフェストが参照していない設定またはレイヤー)が残る可能性があります。

この場合、アップロードが完了した後(blobs, repository_blobs の対応するレコードが作成される前)に blob ダイジェストを blob_review_queue に挿入します。blob_review_queue.review_after を将来の時刻に設定することで、この blob のチェックを遅延させることができます(これにより、クライアントがマニフェストをプッシュするまでのタイムアウトを実質的に定義します)。

次に、クライアントがマニフェストをプッシュします。

PUT /v2/<name>/manifests/<reference>

manifestslayers に対応するレコードを作成します。検証ステップを完了した後、blob_review_queue からダイジェストをデキュー(削除)できます。この blob への参照が確実に存在するため、GC をスキップできるからです。これはオプションです。GC プロセス自身もこれを把握できます — キューからの削除は安価です(GC の実行よりもおそらく安価です)。そのため、これは最適化です。

例: 既存のタグをマニフェスト A からマニフェスト B に切り替える

PUT /v2//manifests/

マニフェストをアップロードするとき、reference がタグ(ダイジェストでもあり得る)の場合、既存のタグがマニフェスト A からマニフェスト B に切り替わるシナリオを検討する必要があります。例えば、次のような場合:

  1. サンプルアプリケーション用の Docker イメージをビルドし、myapp:latest でタグ付けしてレジストリにプッシュします。イメージをレジストリにプッシュすると、そのマニフェスト(A と呼びます)がアップロードされ、myapp リポジトリ内で latest タグが付けられます。

  2. サンプルアプリケーションのソースコードを変更し、同じ myapp:latest タグでイメージをリビルドしてレジストリにプッシュします。ソースコードを変更したため、このイメージは異なるマニフェスト(B と呼びます)を持ち、latest タグ名でアップロードされます。

レジストリがマニフェスト B を受け取ると、別のマニフェスト A がリポジトリで既に latest でタグ付けされていることを見つけます。この状況では、レジストリは A から latest タグを削除し、代わりに B を指すようにします。このため、他のタグが A を指していない場合、マニフェスト A は削除の対象になる場合があります。

これに対応するため、マニフェスト A が参照するすべての blob を blob_review_queue に挿入する必要があります。

Blob レビューキューの消費

blob_review_queue からエントリを消費し、GC チェックとアクションを実行するバックグラウンドジョブシステムを実装します。これは go ルーチンで実現でき、blob_review_queue の同期は SELECT ... FOR UPDATE SKIP LOCKED メカニズムで実装できます。

注意: ここでの特定の選択は、GC が単一の blob に対してのみ実行されることです。バッチ GC をサポートするように拡張することもできます。ただし、ここで使用されているデータベースモデルはダイジェストによるルックアップに最適化されています。

キューから取り出す

レビューのためにキューから次の blob ダイジェストを取得する(キューの「先頭」)ために、次のロックされていない適格なレコードを見つけます:

BEGIN;
SELECT * FROM blob_review_queue WHERE review_after < NOW() FOR UPDATE SKIP LOCKED LIMIT 1;
-- ここでチェックとクリーンアップアクションを実行します
-- 完了したら、キューからダイジェストを削除します:
DELETE FROM blob_review_queue WHERE digest = :digest;
COMMIT;

これには注意点があります。blob をストレージから削除するためには、開いているデータベーストランザクション内で外部 IO を実行することになります(これはアンチパターンです)。これを軽減するために、次のようにできます:

BEGIN;
SELECT * FROM blob_review_queue WHERE review_after < NOW() FOR UPDATE SKIP LOCKED LIMIT 1;
UPDATE blob_review_queue SET review_after = review_after + INTERVAL '5 minutes' WHERE digest = :digest;
COMMIT;

-- ここでチェックとクリーンアップアクションを実行します(データベーストランザクションの範囲外で)
-- 完了したら、キューからダイジェストを削除します:
DELETE FROM blob_review_queue WHERE digest = :digest;

ここでのトレードオフは、短いデータベーストランザクションを持つ代わりに、キューを 少なくとも一度 のセマンティクスに変更することです。つまり、同じダイジェストをクリーンアップしようとする複数のジョブが存在する可能性があります。例えば、外部 IO が5分(review_after のオフセット)より長くかかる場合や、ジョブが完全に失敗する場合です。ジョブは冪等であるため、これが問題になることはないという前提です。

チェックの実行

単一の blob ダイジェストに対して、blob をオブジェクトストレージから削除すべきかどうかを決定するための一連のチェックを実装します:

  1. 残りのレイヤー参照はあるか? SELECT 1 FROM blobs_layers WHERE digest=:digest LIMIT 1
  2. 残りのリポジトリ参照はあるか? SELECT 1 FROM repository_blobs WHERE blob_digest=:digest(TODO: ここでのパーティショニングスキームは適切ではないかもしれません。blobs_repositories を追加したいかもしれません — 検討中)
  3. 残りの設定参照はあるか? SELECT 1 FROM blobs_configurations WHERE digest=:digest LIMIT 1

データベースモデルはこれらのチェックをすべてサポートし、通常、パーティショニングモデルと効率的なインデックスを使用することでメリットを得ます(上記の例: 単一レコードルックアップ)。

クリーンアップアクションの実行

Blob を削除すべきと判断したら、以下のステップを実行します:

  1. ストレージから blob を削除
  2. blobsblob_review_queue のレコードを削除

受信リクエストとの同期の必要性について議論しました。つまり、受信リクエスト(この blob を参照するマニフェストの配置やチェックなど)は、一貫した結果を得るためにクリーンアップアクションと直列化する必要があるかもしれません。