01. 環境構成と起動処理

このラボは 4 つのコンテナで構成されています。docker compose up を実行したとき何が起きるか、なぜその構成なのかを説明します。

全体構成

flowchart TD
    PY["PyIceberg<br/>samples/"] -->|"Iceberg REST API<br/>:8181"| POL["Apache Polaris 1.6.0<br/>カタログ"]
    PY -->|"S3 API :9000"| RFS["RustFS 1.0.0-beta.8<br/>オブジェクトストレージ"]
    POL -->|"credential vending"| RFS
    POL -.->|"メタデータは<br/>in-memory"| MEM["プロセス内<br/>(永続化なし)"]
サービス イメージ 役割 常駐
rustfs rustfs/rustfs:1.0.0-beta.8 S3 互換ストレージ。データ本体と metadata JSON を置く
polaris apache/polaris:1.6.0 Iceberg REST カタログ。:8181 がカタログ API、:8182 が管理・health
bucket-setup amazon/aws-cli:2.35.21 バケットを作って終了 ×
polaris-setup alpine/curl:8.21.0 カタログ・principal・ロール・権限を作って終了 ×

後ろの 2 つは使い捨てで、役目を終えると終了します。docker compose ps に残らないのは正常です。

起動の順序

依存関係は compose の depends_on で直列化しています。前段が健全になるまで次は起動しません。

flowchart TD
    A["rustfs 起動"] -->|"healthcheck<br/>/health が通るまで待つ"| B["bucket-setup<br/>s3://warehouse を作成"]
    B -->|"正常終了を待つ"| C["polaris 起動"]
    C -->|"healthcheck<br/>:8182/q/health"| D["polaris-setup<br/>カタログと権限を作成"]
    D --> E["CLIENT_ID / CLIENT_SECRET<br/>を表示"]

この直列化は必要です。Polaris はカタログ作成時に指定されたバケットへ到達できることを前提にしており、バケットが無い状態で進めると、後段のコミットで初めて失敗します。

ブートストラップが実際にやっていること

scripts/bootstrap.sh は Polaris の管理 API を curl で叩きます。ここは Iceberg REST Catalog 仕様ではありません。仕様はカタログの「作成」を定義していないため、実装依存の領域です。

処理は 4 段階です。

1. root のトークンを取得

curl "${POLARIS_URL}/api/catalog/v1/oauth/tokens" \
  --user "${ROOT_CLIENT_ID}:${ROOT_CLIENT_SECRET}" \
  -H "Polaris-Realm: ${REALM}" \
  -d grant_type=client_credentials \
  -d scope=PRINCIPAL_ROLE:ALL

scope=PRINCIPAL_ROLE:ALL は何を指定しているか

OAuth2 の scope は本来「このトークンで何をするつもりか」をクライアントが申告するものです。Polaris はこれを RBAC の「どの principal role を有効化するか」 の指定として使っています。

Polaris の権限は principal に直接付かず、principal role を経由します(後述)。principal が複数の役割を持つとき、トークンごとに使う役割を選べる設計になっており、PRINCIPAL_ROLE:ALL は「割り当てられている役割をすべて有効化する」という意味です。個別に指定する場合は PRINCIPAL_ROLE:<ロール名> と書きます。

有効化された役割は、権限エラーのメッセージにそのまま現れます。

Principal 'root' with activated PrincipalRoles '[service_admin]'
and activated grants via '[service_admin, catalog_admin]'
is not authorized for op CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION

「必須」と書いたのは、この形式でないとトークン自体が取れないためです。実際に試すと次のようになりました。

指定 結果
PRINCIPAL_ROLE:ALL HTTP 200、トークン取得
PRINCIPAL_ROLE:lab_principal_role HTTP 200、トークン取得
catalog(PyIceberg の既定値) HTTP 400 invalid_scope
指定なし HTTP 400 invalid_scope

PyIceberg は CATALOG_SCOPE = "catalog" を既定値として送るため、scope を明示しないと Polaris ではトークン取得の時点で失敗します。認証や権限の問題ではなくスコープの書式の問題なので、返るのは 401 / 403 ではなく 400 です。

ロール名はどこまで効くか

存在しないロール名を指定してもトークンが発行されたため、実装を確認しました。コンテナ内の polaris-runtime-service-1.6.0.jar を逆アセンブルして読んだ結果です。

トークン発行時(TokenRequestValidator)は書式しか見ていません。 スコープを空白で分割し、各要素が PRINCIPAL_ROLE: で始まること、接頭辞を除いた残りが空でないことだけを検証します。条件を満たさなければ invalid_scope を返します。ロールが実在するかどうかは確認していません。 だから PRINCIPAL_ROLE:does_not_exist でもトークンが出ます。

認証時(DefaultAuthenticator)はロール名を実際に使っています。 PRINCIPAL_ROLE:ALL なら全ロールを有効化し、そうでなければ principal に付与されたロールを要求された名前で絞り込みます。要求した名前が見つからない場合は警告を記録するだけで、例外は投げません。

実際にログに現れます。

WARN [org.apa.pol.ser.aut.DefaultAuthenticator]
  principal=lab_user credentials=InternalPolarisToken{... scope=PRINCIPAL_ROLE:does_not_exist}
  roles=[] Some principal roles were not found in the principal's grants

roles=[] のとおり、有効化されたロールは空になっています。ロール名は確かに解釈されており、絞り込みも働いています。

ところが、その状態でもテーブルを作成できました。

スコープ 有効ロール listNamespaces createTable(新規名)
PRINCIPAL_ROLE:ALL 全ロール HTTP 200 HTTP 200
PRINCIPAL_ROLE:does_not_exist [](空) HTTP 200 HTTP 200

有効ロールが空のトークンで新規テーブルが作られ、metadata-location が返りました。一方、purgeRequested=true を付けたテーブル削除は同じトークンで HTTP 403 になったので、認可がまったく効いていないわけではありません。

つまり 1.6.0 のこの構成では、スコープでロールを絞っても実効的な権限は絞られませんでした。なぜ空のロール集合で書き込みが通るのかまでは追えていません(未確認)。スコープを権限の絞り込み手段として当てにしない前提で扱うのが安全です。

ただしここで確認したのは、仕様上 DEPRECATED for REMOVAL とされている /v1/oauth/tokens 経由の挙動である点に注意してください(このエンドポイントの位置づけは前述のとおりです)。推奨される構成、つまり外部 IdP を使い oauth2-server-uri を設定した場合の挙動は確認していません。認証の経路が変われば、ロールの解決も変わる可能性があります。

なお /v1/oauth/tokens は Iceberg 仕様上 DEPRECATED for REMOVAL です(Java 1.6.0 で非推奨、2.0 で削除予定)。ここでは学習の便宜上使っています。

2. カタログを作成

{
  "storageConfigInfo": {
    "storageType": "S3",
    "allowedLocations": ["s3://warehouse/lab_catalog"],
    "endpoint": "http://localhost:9000",
    "endpointInternal": "http://rustfs:9000",
    "pathStyleAccess": true,
    "region": "us-east-1"
  }
}

ここには 2 つの要点があります。

エンドポイントを内外で分ける。 endpoint はクライアント(ホスト)から見た URL、endpointInternal は Polaris コンテナから見た URL です。取り違えると Name or service not known になります。

roleArn を書かない。 roleArn があると Polaris は credential vending のために STS AssumeRole を呼びます。RustFS も MinIO も STS に対応していないため、コミット時に CommitStateUnknownException: StsException (Status Code: 400) で落ちます。省略すると Polaris は環境変数の静的な資格情報をそのまま使い、credential vending も機能します。

このエラーは紛らわしい点があります。CommitStateUnknownException は仕様上「コミットが成功したか失敗したか分からない」という重大な状態を指しますが、この場合の実態は単なる設定ミスです。

3. principal を作成

lab_user という principal を作り、clientId / clientSecret を受け取ります。この資格情報は作成時に一度だけ払い出されます。

principal が既に存在する場合(docker compose down だけして volume を残した場合など)は作成に失敗するため、root の資格情報にフォールバックします。作り直すには docker compose down -v が必要です。

4. ロールと権限を紐付ける

Polaris の RBAC は 2 層です。

flowchart TD
    P["principal<br/>lab_user"] --> PR["principal role<br/>lab_principal_role"]
    PR --> CR["catalog role<br/>lab_catalog_role"]
    CR --> G["権限<br/>CATALOG_MANAGE_CONTENT"]

principal に直接権限は付きません。principal role を経由して catalog role に到達し、そこに権限が付く構造です。この 2 段構えは、principal を増やしても権限定義を再利用できるようにするためのものです。

設定はどこから読まれるか

サンプルは samples/_common.py 経由でカタログに接続します。設定の優先順位は次のとおりです。

  1. export 済みの環境変数(最優先)
  2. リポジトリ直下の .env
  3. _common.py の既定値root / s3cr3t など)

.env_common.py が起動時に読み込みます。make up が表示する CLIENT_ID / CLIENT_SECRET をここに書きます。書き忘れると既定値の root にフォールバックし、テーブル作成時に次のエラーになります。

ForbiddenException: Principal 'root' with activated PrincipalRoles '[service_admin]'
... is not authorized for op CREATE_TABLE_DIRECT_WITH_WRITE_DELEGATION

root は管理者ですが、カタログのコンテンツを操作する権限は持っていません。Polaris が管理操作とデータ操作の権限を分けているためです。

接続時のプロパティ

_common.py がカタログに渡している設定のうち、S3 互換ストレージで問題になりやすいものを挙げます。

キー なぜその値か
uri http://localhost:8181/api/catalog 末尾に /v1付けない
warehouse lab_catalog S3 パスではなくカタログ名
scope PRINCIPAL_ROLE:ALL この書式でないとトークンが取れない
s3.endpoint http://localhost:9000 設定すると自動的に path-style になる
s3.region us-east-1 値は任意だが、指定しないと遅いか失敗する

それぞれ理由があります。

uri/v1 を付けない。 PyIceberg 側が付与するためです。ソースでは url + "/v1/" として組み立てており(catalog/rest/__init__.py L551)、エンドポイントのテンプレートも API_PREFIX = "/v1/{prefix}" です。こちらで /v1 を書くと /api/catalog/v1/v1/... になり 404 になります。

warehouse にカタログ名を渡す。 ここは実装間で意味が割れる箇所です。この値は GET /v1/config のクエリパラメータとしてサーバに送られ、サーバが「どのカタログの設定を返すか」を決めるために使います。仕様はこの値の意味を定めていないため、解釈はサーバ次第です。Hadoop カタログのように S3 パス(s3://bucket/warehouse)を期待する実装もありますが、Polaris は登録済みのカタログ名を期待します。実際に両方を投げると次のようになりました。

warehouse の値 結果
lab_catalog(カタログ名) HTTP 200、overrides.prefix = lab_catalog が返る
s3://warehouse/lab_catalog(パス) HTTP 404 Unable to find warehouse s3://warehouse/lab_catalog

エラーメッセージが「warehouse が見つからない」と言っている点に、この値をカタログの識別子として扱っていることが表れています。

scope は書式が検証される。 前述のとおり、PRINCIPAL_ROLE: 形式でないと HTTP 400 invalid_scope でトークン取得に失敗します。

s3.endpoint を設定すると path-style になる。 PyArrow の S3FileSystemforce_virtual_addressing の既定が False で、その意味は「endpoint_override が空のときだけ virtual addressing を使う」です。エンドポイントを指定した時点で条件から外れ、path-style が選ばれます。AWS S3 以外では bucket をホスト名に含める virtual-hosted style が使えないため、この挙動が必要です。

s3.region は値そのものより「指定してあること」が効く。 未指定だと PyIceberg は _cached_resolve_s3_region(bucket=...) でバケットのリージョンを問い合わせます(io/pyarrow.py L209)。これは AWS S3 に対する照会なので、RustFS や MinIO では失敗するか、タイムアウトまで待たされます。RustFS はリージョンを区別しないため、指定する値自体は何でも構いません。

s3.path-style-accessPyIceberg に存在しません。Java 版 Iceberg のキーであり、書いてもエラーにならず黙って無視されます。詳しくは 17. 実装の制約 で扱います。

なぜこの組み合わせか

Apache Polaris

セルフホストで REST カタログを試す用途では、現時点で最有力だと判断しました。

  • 2026-02-19 に ASF の Top-Level Project に卒業しています
  • 外部 DB 不要で立ち上がり、カタログ・principal・ロール・権限まで自動生成できます
  • IRC 仕様のリファレンス実装的な位置づけで、ここで学んだことが他の実装にも通じます
  • guides/ 配下に用途別 compose(jdbc, keycloak, trino, flink, spark)が揃っており、段階的に拡張できます

対抗馬の評価は報告書の該当章にまとめています。

MinIO ではなく RustFS

MinIO が実質的に使えなくなったためです。

時期 出来事
2025-10-15 セキュリティ修正(権限昇格)。GitHub 上の最終リリース
2025-10-18 その修正のイメージが未公開のまま working as intended でクローズ
2026-04-25 minio/minio リポジトリがアーカイブ(read-only)

入手可能な最新の公開イメージ(2025-09-07)は、2025-10-15 の権限昇格 CVE 修正を含みません。Apache Polaris 公式も MinIO ガイドに maintenance mode の警告を出し、PR #3482 で RustFS の例を追加しています。

ここで流通している誤解を 1 つ訂正しておきます。「MinIO が Apache-2.0 から AGPLv3 に変更したのが理由」という説明を見かけますが、MinIO のライセンスは AGPLv3 のまま変わっていません。変わったのは保守方針と配布形態です。

なお RustFS 自体も 1.0.0 の安定版には未到達です(2026-07-16 時点で 1.0.0-beta.10-preview.4)。ラボ用途としては足りますが、本番の判断材料にはしないでください。

イメージタグの固定

Polaris 公式 quickstart は apache/polaris:latestrustfs:1.0.0-alpha.81 を使っていますが、本ラボではすべて固定しています。

  • apache/polaris:latest の実体バージョンが分かりません
  • 公式の guides/quickstartguides/rustfs が別バージョンの RustFS を使っており、公式内でも不整合があります
  • apache/iceberg-rest-fixturelatest(2026-04-29)がバージョン付き最新の 1.10.1(2025-12-22)より新しく、中身が一致しない可能性があります

再現性のために固定が必要だと判断しました。

永続化について

Polaris のメタデータは in-memory です。docker compose down でテーブル定義は消えます(データ本体は volume に残りますが、カタログから参照されなくなるため孤立します)。

永続化する場合は Polaris 公式の guides/jdbc(PostgreSQL)を参照してください。