Cloudflareドメインのアカウント間移動とIaC(#19)

はじめに

私が所属している研究室では、Cloudflare Registrarで独自ドメインを取得し管理しています。このドメインは、Cloudflare TunnelとCloudflare Accessを用いたVPNレスSSH#6)や、External DNSとCloudflare DNSを利用したサービスの名前解決(#16)などに利用されています。また、現在github.ioドメインでホストしている研究室のホームページにおいて、独自ドメインを使用することも検討しています。

さて、研究室のドメインは私が半年ほど前に私用のCloudflareアカウントで取得しました。よって、引き継ぎを行うには新しいアカウントを作成して別のメンバーを招待し、管理者権限を付与する必要があります。

本記事では、Cloudflare Registrarで登録したドメインをCloudflareアカウント間で移動させる方法と、それに関連してTerraformでCloudflareリソースをIaC化し、GitHub Actionsでワークフローを自動化する方法を紹介します。

1. 旧アカウントにおけるCloudflareリソースのIaC

Cloudflareの公式ドキュメントによると、アカウント間でドメインを移動させることによって、ソースアカウント上でそのドメインに紐付く構成や設定は失われてしまいます。

The move will result in the loss of all configurations and settings for the domain in the source account.

よって、ドメインの移動が完了してから、ソースアカウントの構成をターゲットアカウントで復元するまでの時間がサービスのダウンタイムになります。サービスは研究室内でのみ提供しているため長いダウンタイムも許容されますが、一部のメンバーは研究活動がサービスに依存しているためダウンタイムは短いに越したことはありません。

Cloudflareリソースは今まで手動で管理していましたが、IaC(Infrastructure as Code)を導入することで一般的には以下のようなメリットが得られます。

Benefits of adopting Infrastructure as Code:

  • Reduces cost
  • Increases speed of deployments
  • Reduces errors
  • Improves infrastructure consistency
  • Eliminates configuration drift

What is Infrastructure as Code (IaC)?』より引用

このタイミングでIaCを導入することにより、ドメイン移動のダウンタイムを最小限に抑えられるだけでなく、研究室メンバーへの引き継ぎも容易になります。研究室サーバーでは既にAnsibleを利用して構成管理(#2#8#12)を行なっていますが、今回はIaCツールとしてTerraformを利用してCloudflareのリソースを管理します。

Terraformのバージョン管理には、tfenvの後継であるtenvを利用します。

$ tenv tf install
# 省略
Installation of Terraform 1.14.0 successful

$ terraform version
Terraform v1.14.0
on darwin_arm64

developers.cloudflare.com

既存のCloudflareリソースをTerraformで管理するためには、Terraform configuration(.tfファイル)とTerraform state(.tfstateファイル)が必要です。一般的には、resourceブロックを記述してから terraform importコマンドでstateのみ取り込む方法や、importブロックを記述してからgenerate-config-outフラグ付きのterraform planコマンドでresourceブロックを生成する方法があります。

一方でCloudflareはcf-terraformingというツールを提供しており、Cloudflare API経由で情報を取得することでTerraform configurationを生成することができます。

github.com

リソースIDを指定する必要がある方法(terraform importコマンドやimportブロック)とは異なり、cf-terraformingは指定したリソースタイプの全リソースについてTerraform configurationを生成できるため、今回のようにIaCに移行するケースではより効率的なツールだと考えました。

まずはCloudflare ProviderのTerraform公式ドキュメントに従ってprovider.tfに以下を記述し、terraform initコマンドでプロジェクトを初期化します。

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5"
    }
  }
}

provider "cloudflare" {
}

次に、READMEのSupported Resoucesを参考にリソースタイプを指定してコマンドを実行します。

$ export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN'
$ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform)
$ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID'
# export CLOUDFLARE_ZONE_ID='YOUR_CLOUDFLARE_ZONE_ID'

$ cf-terraforming generate --resource-type "cloudflare_account"
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
INFO[0000] detected provider                             registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0
resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" {
  name = "Kitsuya Azuma"
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = false
    user_groups_ui_beta    = false
  }
}

CloudflareアカウントのTerraform configurationが標準出力に出力されました。リソース名のxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxの部分は、指定したアカウントIDになっているはずです。

ちなみにAPIトークン/アカウントID/ゾーンIDは、CLOUDFLARE_API_TOKENCLOUDFLARE_ACCOUNT_IDCLOUDFLARE_ZONE_IDという環境変数の他にtokenaccountzoneフラグでも指定することができますが、環境変数が指定されている場合は環境変数でオーバーライドされます。

リソースタイプはカンマ区切りで複数タイプ指定できますが、リソースタイプ毎に指定すべきIDタイプ(アカウントまたはゾーン)が異なるため、片方のIDを指定すべきリソースタイプでもう片方のIDも指定してしまうとエラー(FATA[0000] --account and --zone are mutually exclusive, support for both is deprecated)になってしまいます。そこで今回は、IDタイプ別にリソースタイプ配列を定義する、以下のようなシェルスクリプトを記述しました。

generate.sh

#!/bin/bash

OUTPUT_FILE="generated.tf"

if [ -z "$CLOUDFLARE_API_TOKEN" ]; then
    echo "Error: CLOUDFLARE_API_TOKEN environment variable is not set."
    exit 1
fi

if [ -z "$CLOUDFLARE_ACCOUNT_ID" ]; then
    echo "Error: CLOUDFLARE_ACCOUNT_ID environment variable is not set."
    exit 1
fi

if [ -z "$CLOUDFLARE_ZONE_ID" ]; then
    echo "Error: CLOUDFLARE_ZONE_ID environment variable is not set."
    exit 1
fi

ACCOUNT_RESOURCES=(
    "cloudflare_account"
    "cloudflare_account_member"
    "cloudflare_account_subscription"
    "cloudflare_address_map"
    "cloudflare_calls_sfu_app"
    "cloudflare_calls_turn_app"
    "cloudflare_d1_database"
    "cloudflare_dns_firewall"
    "cloudflare_dns_zone_transfers_acl"
    "cloudflare_dns_zone_transfers_peer"
    "cloudflare_dns_zone_transfers_tsig"
    "cloudflare_email_routing_address"
    "cloudflare_email_security_block_sender"
    "cloudflare_email_security_impersonation_registry"
    "cloudflare_email_security_trusted_domains"
    "cloudflare_list"
    "cloudflare_load_balancer_monitor"
    "cloudflare_load_balancer_pool"
    "cloudflare_magic_wan_static_route"
    "cloudflare_mtls_certificate"
    "cloudflare_notification_policy"
    "cloudflare_notification_policy_webhooks"
    "cloudflare_pages_project"
    "cloudflare_queue"
    "cloudflare_r2_bucket"
    "cloudflare_registrar_domain"
    "cloudflare_stream"
    "cloudflare_stream_key"
    "cloudflare_stream_live_input"
    "cloudflare_stream_watermark"
    "cloudflare_stream_webhook"
    "cloudflare_turnstile_widget"
    "cloudflare_user"
    "cloudflare_web_analytics_site"
    "cloudflare_workers_custom_domain"
    "cloudflare_workers_for_platforms_dispatch_namespace"
    "cloudflare_workers_kv_namespace"
    "cloudflare_zero_trust_access_custom_page"
    "cloudflare_zero_trust_access_infrastructure_target"
    "cloudflare_zero_trust_access_key_configuration"
    "cloudflare_zero_trust_access_policy"
    "cloudflare_zero_trust_access_tag"
    "cloudflare_zero_trust_device_default_profile"
    "cloudflare_zero_trust_device_default_profile_local_domain_fallback"
    "cloudflare_zero_trust_device_managed_networks"
    "cloudflare_zero_trust_device_posture_integration"
    "cloudflare_zero_trust_device_posture_rule"
    "cloudflare_zero_trust_dex_test"
    "cloudflare_zero_trust_dlp_dataset"
    "cloudflare_zero_trust_dns_location"
    "cloudflare_zero_trust_gateway_certificate"
    "cloudflare_zero_trust_gateway_policy"
    "cloudflare_zero_trust_gateway_proxy_endpoint"
    "cloudflare_zero_trust_gateway_settings"
    "cloudflare_zero_trust_list"
    "cloudflare_zero_trust_risk_behavior"
    "cloudflare_zero_trust_risk_scoring_integration"
    "cloudflare_zero_trust_tunnel_cloudflared"
    "cloudflare_zero_trust_tunnel_cloudflared_route"
    "cloudflare_zero_trust_tunnel_cloudflared_virtual_network"
)

ZONE_RESOURCES=(
    "cloudflare_api_shield_discovery_operation"
    "cloudflare_api_shield_operation"
    "cloudflare_api_shield_schema"
    "cloudflare_api_shield_schema_validation_settings"
    "cloudflare_argo_smart_routing"
    "cloudflare_argo_tiered_caching"
    "cloudflare_authenticated_origin_pulls_certificate"
    "cloudflare_bot_management"
    "cloudflare_certificate_pack"
    "cloudflare_content_scanning_expression"
    "cloudflare_custom_hostname"
    "cloudflare_custom_hostname_fallback_origin"
    "cloudflare_dns_record"
    "cloudflare_dns_zone_transfers_incoming"
    "cloudflare_dns_zone_transfers_outgoing"
    "cloudflare_email_routing_catch_all"
    "cloudflare_email_routing_dns"
    "cloudflare_email_routing_rule"
    "cloudflare_email_routing_settings"
    "cloudflare_filter"
    "cloudflare_healthcheck"
    "cloudflare_keyless_certificate"
    "cloudflare_leaked_credential_check"
    "cloudflare_leaked_credential_check_rule"
    "cloudflare_load_balancer"
    "cloudflare_logpull_retention"
    "cloudflare_managed_transforms"
    "cloudflare_origin_ca_certificate"
    "cloudflare_page_rule"
    "cloudflare_page_shield_policy"
    "cloudflare_rate_limit"
    "cloudflare_regional_hostname"
    "cloudflare_regional_tiered_cache"
    "cloudflare_snippet_rules"
    "cloudflare_snippets"
    "cloudflare_spectrum_application"
    "cloudflare_tiered_cache"
    "cloudflare_total_tls"
    "cloudflare_url_normalization_settings"
    "cloudflare_waiting_room_settings"
    "cloudflare_web3_hostname"
    "cloudflare_zone"
    "cloudflare_zone_cache_reserve"
    "cloudflare_zone_cache_variants"
    "cloudflare_zone_dnssec"
    "cloudflare_zone_lockdown"
    "cloudflare_zero_trust_device_default_profile_certificates"
)

ACCOUNT_OR_ZONE_RESOURCES=(
    "cloudflare_logpush_job"
    "cloudflare_ruleset"
    "cloudflare_waiting_room"
    "cloudflare_zero_trust_access_application"
    "cloudflare_zero_trust_access_group"
    "cloudflare_zero_trust_access_identity_provider"
    "cloudflare_zero_trust_access_mtls_certificate"
    "cloudflare_zero_trust_access_mtls_hostname_settings"
    "cloudflare_zero_trust_access_service_token"
    "cloudflare_zero_trust_access_short_lived_certificate"
    "cloudflare_zero_trust_organization"
)

echo "Generating configuration to $OUTPUT_FILE ..."

echo "--- Account Level Resources ---"
for resource in "${ACCOUNT_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ZONE_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "--- Zone Level Resources ---"
for resource in "${ZONE_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "--- Account or Zone Level Resources ---"
for resource in "${ACCOUNT_OR_ZONE_RESOURCES[@]}"; do
    echo "Generating $resource ..."
    CLOUDFLARE_ACCOUNT_ID="" cf-terraforming generate \
        --resource-type "$resource" >>"$OUTPUT_FILE" 2>/dev/null
done

echo "Done! Configuration saved to $OUTPUT_FILE."

シェルスクリプトを実行したところ、合計で600行ほどのTerraform configurationが生成されました。私用のCloudflareアカウントには、研究室用のリソースだけでなく趣味用のリソースも多く存在しています。アカウントIDを指定するリソースタイプ(cloudflare_registrar_domaincloudflare_zero_trust_access_policyなど)については、生成された趣味用リソースのTerraform configurationのみ目視で削除しました。

ちなみにcf-terraforming importコマンドを実行することで、stateを取り込むためのterraform importコマンドを出力することができます。cf-terraforming importコマンドについては第3章で再登場しますが、今回は新アカウントでのリソース作成が目的であるため、旧アカウントでのstate取り込みは割愛します。

以上で、CloudflareリソースのIaCのための旧アカウント上での準備が完了しました。

2. Cloudflareアカウント間のドメイン移動

developers.cloudflare.com

Cloudflareで新アカウントを作成したら、上記の公式ドキュメントの手順に従います。

まずは、新アカウントでドメインをWebサイトとして追加し、DNSSECがoffになっていることを確認します。

次に、旧アカウントでManage Domainsページから該当ドメインManageを選択し、ConfigurationタブのMove to another Cloudflare accountStartボタンをクリックします。

Cloudflareアカウント間のドメイン移動を開始

注意書き(新アカウントのアカウントIDが必要&ゾーンの設定は移動されない)

新アカウントのアカウントIDを入力

最終確認(ドメインは旧アカウントで管理されない&ゾーン・サービス・プラン・設定が新アカウントに移動されない)

ドメイン移動が開始されたという表示

最後に、新アカウントでManage Domainsページからドメインの移動を許可します。

移動がpending状態のドメインがあるというメッセージが出現

ドメインの移動を許可

ドメイン移動が許可されたという表示

ネームサーバーの更新まで最大8時間かかると書いてありましたが、hereと書いてあるリンクをクリックしたところ数分程度でセットアップが完了しました。

ドメイン移動直後の状態(Invalid nameservers)

ドメイン移動から数分後の状態(Active)

以上で、Cloudflareアカウント間のドメイン移動が完了しました。

3. 新アカウントにおけるCloudflareリソースのIaC

移動されなかったドメイン以外のサービス・設定について、新アカウントで構成する必要があります。

新アカウント上で既に作成されているリソースのうちTerraformで管理したいリソースについては、第1章で紹介したcf-terraforming generateコマンドを利用してTerraform configurationを生成しつつ、cf-terraforming importコマンドでstateをローカルに取り込みます。cloudflare_accountcloudflare_account_membercloudflare_zoneの他にもデフォルトで多くのリソースが作成されていますが、Terraformで管理したい範囲を決めてstateを取り込むのが良いと考えました。

以下は、cloudflare_accountリソースのstateを取り込む例です。

$ export CLOUDFLARE_ACCOUNT_ID='YOUR_CLOUDFLARE_ACCOUNT_ID'
$ export CLOUDFLARE_TERRAFORM_BINARY_PATH=$(which terraform)
$ export export CLOUDFLARE_API_TOKEN='YOUR_CLOUDFLARE_API_TOKEN'

$ cf-terraforming generate --resource-type "cloudflare_account" | tee account.tf
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
INFO[0000] detected provider                             registry=registry.terraform.io/cloudflare/cloudflare version=5.13.0
resource "cloudflare_account" "terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0" {
  name = "Xxxxxx Xxxxxxxxxx"
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = false
    user_groups_ui_beta    = false
  }
}

$ sed -i.bak 's/terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0/main/g' account.tf && rm account.tf.bak

$ cf-terraforming import --resource-type "cloudflare_account"
INFO[0000] Using Terraform binary from explicit path     terraform-binary-path=/opt/homebrew/bin/terraform
terraform import cloudflare_account.terraform_managed_resource_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx_0 xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

$ terraform import cloudflare_account.main xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
cloudflare_account.main: Importing from ID "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"...
cloudflare_account.main: Import prepared!
  Prepared cloudflare_account for import
cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

$ terraform plan
cloudflare_account.main: Refreshing state... [id=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration and found no differences, so no changes are needed.

cf-terraformingで生成されたリソース名は扱いにくいので、適切なリソース名に変更しています。

新アカウントの既存リソースのうちTerraformで管理したいリソースのstate取り込みが完了したら、第1章で生成した旧アカウントTerraform configurationを参考に、新アカウントで作成したい新規リソース(Cloudflare Tunnel、Cloudflare Access、Cloudflare DNSなど)も記述します。

terraform.tf

terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.13.0"
    }
  }
  required_version = "~> 1.14.0"
}

provider.tf

provider "cloudflare" {
  # API token will be read from CLOUDFLARE_API_TOKEN environment variable
}

variables.tf

variable "account_name" {
  type        = string
  description = "The name of the Cloudflare account."
}

variable "super_admin_emails" {
  type        = list(string)
  description = "A list of email addresses assigned the Super Administrator role."
}

variable "domain_name" {
  type        = string
  description = "The domain name for the Cloudflare zone."
}

variable "lab_member_emails" {
  type        = list(string)
  description = "A list of email addresses for lab members."
}

variable "zero_trust_access_group_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access group."
}

variable "zero_trust_access_policy_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access policy."
}

variable "zero_trust_access_policy_session_duration" {
  type        = string
  description = "Session duration for the Zero Trust access policy."
}

variable "zero_trust_organization_auth_domain" {
  type        = string
  description = "The authentication domain for the Cloudflare Zero Trust organization."
}

variable "zero_trust_organization_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust organization."
}

variable "zero_trust_access_application_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust access application."
}

variable "zero_trust_access_application_domain" {
  type        = string
  description = "The domain of the Cloudflare Zero Trust access application."
}

variable "zero_trust_access_application_session_duration" {
  type        = string
  description = "Session duration for the Zero Trust access application."
}

variable "zero_trust_tunnel_cloudflared_name" {
  type        = string
  description = "The name of the Cloudflare Zero Trust tunnel (cloudflared)."
}

variable "dns_record_name" {
  type        = string
  description = "The name of the DNS record."
}

account.tf

resource "cloudflare_account" "main" {
  name = var.account_name
  type = "standard"
  settings = {
    abuse_contact_email    = null
    access_approval_expiry = null
    api_access_enabled     = null
    enforce_twofactor      = true
    user_groups_ui_beta    = false
  }
}

data "cloudflare_account_roles" "all" {
  account_id = cloudflare_account.main.id
}

locals {
  super_admin_role_id = one([
    for role in data.cloudflare_account_roles.all.result : role.id
    if role.name == "Super Administrator - All Privileges"
  ])
}

resource "cloudflare_account_member" "super_admin" {
  for_each = toset(var.super_admin_emails)

  account_id = cloudflare_account.main.id
  email      = each.value
  roles      = [local.super_admin_role_id]

  lifecycle {
    ignore_changes = [status]
  }
}

zone.tf

resource "cloudflare_zone" "main" {
  name = var.domain_name
  type = "full"
  account = {
    id = cloudflare_account.main.id
  }
}

resource "cloudflare_zone_dnssec" "main" {
  status  = "disabled"
  zone_id = cloudflare_zone.main.id
}

dns.tf

resource "cloudflare_dns_record" "polaris" {
  content = "${cloudflare_zero_trust_tunnel_cloudflared.main.id}.cfargotunnel.com"
  # name    = cloudflare_zero_trust_access_application.main.domain
  name    = var.dns_record_name
  proxied = true
  ttl     = 1
  type    = "CNAME"
  zone_id = cloudflare_zone.main.id
}

zero_trust.tf

resource "cloudflare_zero_trust_organization" "main" {
  account_id  = cloudflare_account.main.id
  auth_domain = var.zero_trust_organization_auth_domain
  name        = var.zero_trust_organization_name
}

resource "cloudflare_zero_trust_access_group" "main" {
  name    = var.zero_trust_access_group_name
  zone_id = cloudflare_zone.main.id
  include = [
    for email in var.lab_member_emails : {
      email = {
        email = email
      }
    }
  ]
}

resource "cloudflare_zero_trust_access_policy" "main" {
  account_id       = cloudflare_account.main.id
  decision         = "allow"
  name             = var.zero_trust_access_policy_name
  session_duration = var.zero_trust_access_policy_session_duration
  include = [{
    group = {
      id = cloudflare_zero_trust_access_group.main.id
    }
  }]
}

resource "cloudflare_zero_trust_access_application" "main" {
  app_launcher_visible = true
  domain               = var.zero_trust_access_application_domain
  name                 = var.zero_trust_access_application_name
  session_duration     = var.zero_trust_access_application_session_duration
  type                 = "self_hosted"
  zone_id              = cloudflare_zone.main.id
  destinations = [{
    type = "public"
    uri  = var.zero_trust_access_application_domain
  }]
  policies = [{
    id = cloudflare_zero_trust_access_policy.main.id
  }]
}

resource "cloudflare_zero_trust_tunnel_cloudflared" "main" {
  account_id = cloudflare_account.main.id
  config_src = "cloudflare"
  name       = var.zero_trust_tunnel_cloudflared_name
}

resource "cloudflare_zero_trust_tunnel_cloudflared_config" "main" {
  account_id = cloudflare_account.main.id
  tunnel_id  = cloudflare_zero_trust_tunnel_cloudflared.main.id

  config = {
    ingress = [{
      hostname = cloudflare_zero_trust_access_application.main.domain
      service  = "ssh://localhost:22"
      }, {
      service = "http_status:404"
    }]
  }
}

第1章で旧アカウントリソースのTerraform configurationを生成しましたが、ファイル分割や変数定義、命名規則などを考える過程で結局Cloudflare ProviderのTerraform公式ドキュメントを参照する必要がありました。

VPNレスSSH#6)に必要なリソースの中では、cloudflare_zero_trust_tunnel_cloudflared_configというリソースタイプのみcf-terraformingがサポートしていませんでした。よって、旧アカウント側でterraform importコマンドを実行してterraform.tfstateファイルの内容を確認しながら、新アカウントでterraform applyコマンドが成功するまで試行錯誤しました*1

以上で、新アカウントにおけるCloudflareリソースのIaCは概ね達成できました。

4. IaCワークフローの自動化

これまではlocal backendを利用してstateをローカルに保存していましたが、複数人のインフラ管理者で一貫したstate管理を実現するため、ワークフローを自動化してremote backendを利用する方針に変更します。

研究室では既にAnsible Playbookの実行にGitHub Actionsを利用しているため、Terraformワークフローの自動化についてもGitHub Actionsを採用し、CI/CD基盤の運用を統一しています。

developer.hashicorp.com

上記の公式ドキュメントではGitHub ActionsによるTerraformの自動化にHCP Terraformが利用されていますが、研究室の規模ではstateのバージョニングやRBAC、監査ログといったHCP Terraformの恩恵を受けにくく、運用コストが増加してしまいます。

今回はTerraformのremote backendとして、Cloudflare R2(S3互換のオブジェクトストレージ)を使用します。

developers.cloudflare.com

上記の公式ドキュメントに従って、R2バケットを作成してからオブジェクト読み書き用のAPIトークンを作成し、以下のようにterraformブロックに記述します。

terraform {
+  backend "s3" {
+    bucket                      = "<YOUR_BUCKET_NAME>"
+    key                         = "<YOUR_PATH_TO_STATE>"
+    region                      = "auto"
+    skip_credentials_validation = true
+    skip_metadata_api_check     = true
+    skip_region_validation      = true
+    skip_requesting_account_id  = true
+    skip_s3_checksum            = true
+    use_path_style              = true
+    access_key                  = "<YOUR_R2_ACCESS_KEY>"
+    secret_key                  = "<YOUR_R2_ACCESS_SECRET>"
+    endpoints                   = { s3 = "https://<YOUR_ACCOUNT_ID>.r2.cloudflarestorage.com" }
+  }
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5.13.0"
    }
  }
  required_version = "~> 1.14.0"
}

terraform.tfファイルを編集したら、ローカルでterraform initコマンドを実行してlocal stateからremote stateへのマイグレーションを行います。-migrate-stateオプションでは既存のローカルstateを新しいs3 backendにコピーされますが、-reconfigureオプションでは既存のstateが無視されるので注意が必要です。

$ terraform init -migrate-state
Initializing the backend...
Do you want to copy existing state to the new backend?
  Pre-existing state was found while migrating the previous "local" backend to the
  newly configured "s3" backend. No existing state was found in the newly
  configured "s3" backend. Do you want to copy this state to the new "s3"
  backend? Enter "yes" to copy and "no" to start with an empty state.

  Enter a value: yes


Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing provider plugins...
- Reusing previous version of cloudflare/cloudflare from the dependency lock file
- Using previously-installed cloudflare/cloudflare v5.13.0

Terraform has been successfully initialized!
# 省略

R2バケット内にterrraform.tfstateというオブジェクトが作成される

以上で、remote backendへの移行が完了しました。この変更をコミットする前に、terraform.tfaccess_keysecret_keyの2行は必ず削除してください。ローカルでterraform planコマンドを実行するメンバーのために、以下のような.env.templateをコミットするのがオススメです。

export CLOUDFLARE_API_TOKEN=
export AWS_ACCESS_KEY_ID=
export AWS_SECRET_ACCESS_KEY=

次に、GitHub Actionsを利用してワークフローを自動化します。

AWSGoogle Cloudとは異なりCloudflareではGitHub ActionsのOIDC連携が利用できないため、R2バケット読み書き用のアクセスキーとTerraform実行用のAPIトークンをGitHub Actionsのシークレットに登録する必要があります。

アクセスキーとAPIトークンをGitHub Actionsのシークレットに登録

GitHub Actions上でのTerraform CLIのセットアップには以下のアクションを利用しました。

github.com

.github/workflows/terraform.yaml

name: terraform

on:
  push:
    branches:
      - main
    paths:
      - 'terraform/**'
  pull_request:
    paths:
      - 'terraform/**'

permissions:
  contents: read
  pull-requests: write

concurrency:
  group: terraform-${{ github.ref }}
  cancel-in-progress: false

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./terraform

    env:
      CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
      AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
      AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}

    steps:
      - uses: actions/checkout@v5

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.14.0"

      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check
        continue-on-error: true

      - name: Terraform Init
        id: init
        run: terraform init -input=false

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            // 1. Retrieve existing bot comments for the PR
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            })
            const botComment = comments.find(comment => {
              return comment.user.type === 'Bot' && comment.body.includes('Terraform Format and Style')
            })

            // 2. Prepare format of the comment
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            <details><summary>Validation Output</summary>

            \`\`\`\n
            ${{ steps.validate.outputs.stdout }}
            \`\`\`

            </details>

            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`

            <details><summary>Show Plan</summary>

            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`

            </details>

            *Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Working Directory: \`./terraform\`, Workflow: \`${{ github.workflow }}\`*`;

            // 3. If we have a comment, update it, otherwise create a new one
            if (botComment) {
              github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body: output
              })
            } else {
              github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body: output
              })
            }

      - if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

paths:working-directory:を指定しているのは、リポジトリが以下のようなディレクトリ構造になっているためです。

.
├── README.md
├── .github
│   └── workflows
│       ├── ansible-on-merge.yaml
│       ├── ansible-on-pr.yaml
│       └── terraform.yaml
├── ansible
│   ├── ...
└── terraform
    ├── ...

./terraformディレクトリ以下に意図的な変更を加えると、ワークフローがトリガーされます。

PR上でのbotによるコメントの例

terraform planコマンドの出力は▶︎ Show Planというトグルを開いて確認できます。

以上で、Terraformワークフローの自動化が完了しました。

まとめ

本記事では、Cloudflare間でドメインを移動する方法から、CloudflareリソースをIaC化してワークフローを自動化する方法まで紹介しました。

取り組んだ感想として、アカウント間のドメイン移動は驚くほどスムーズに進みましたが、IaC周りでそれなりに時間がかかってしまいました。また、cf-terraformingは非常に便利なツールだと感じたので今後も積極的に活用していきたいです。

研究室の現在のインフラ構成は以下の図の通りです。

研究室のインフラ構成のアーキテクチャ図(GitOps & IaC)

メジャーなOSSを利用しつつ、GitOpsとIaCを組み合わせてインフラ管理することで、研究室メンバーへの引き継ぎはかなり容易になったと思います。

*1:Web UIでは自動的に設定されますが、Terraformではservice = "http_status:404"のように最後のIngressルールが全てのURLにマッチする必要があります(エラー:{"success":false,"errors":[{"code":1056,"message":"Bad Configuration: Validation failed: The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter)\n"}],"messages":[],"result":null}

Kubernetesクラスタのアップグレード(#18)

はじめに

これまで参加してきたインターンシップで、Kubernetesクラスタのアップグレードに苦労したという話を耳にする機会が何度かありました。また、古いバージョンのKubernetesクラスタを使い続けている組織も少なくない印象を受けています。

当時は話を聞くだけで実感を持って共感することはできませんでした。しかし、研究室には#14で構築したオンプレミスのKubernetesクラスタがあり、幸運にも実際にアップグレードを経験できる環境があります。むしろ、このような貴重な経験を得るためにクラスタを構築したと言っても過言ではありません。

kubernetes.io

本記事では、上記の公式ドキュメントを参考に、kubeadmで構築したオンプレミスKubernetesクラスタをv1.32からv1.34までアップグレードする手順を紹介します。

1. アップグレード対象バージョンの確認

今回はv1.32からv1.34へのアップグレードを行います。

root@polaris:~$ kubectl get nodes
NAME      STATUS   ROLES           AGE    VERSION
aries     Ready    <none>          153d   v1.32.3
gemini    Ready    <none>          132d   v1.33.1
polaris   Ready    control-plane   153d   v1.32.3
taurus    Ready    <none>          153d   v1.32.3

クラスタ内にはすでにv1.33 のノードがありますが、Kubernetesではマイナーバージョンのスキップがサポートされていないため、「v1.32 → v1.33」→「v1.33 → v1.34」という2段階でアップグレードを実施する必要があります。

基本的な手順は両ステップで共通しているため、本記事ではv1.33へのアップグレード手順を中心に紹介し、v1.34へのアップグレード部分は割愛します。

v1-33.docs.kubernetes.io

また、アップグレードを行う前には必ずリリースノートのUrgent Upgrade Notesに目を通しておきましょう。

github.com

なお、重要なコンポーネントのバックアップはベストプラクティスとされていますが、kubeadm upgradeで影響を受けるのはKubernetes内部のコンポーネントのみであり、ワークロードには直接影響しないため今回は省略しました。

2. コントロールプレーンノードのアップグレード

まず、アップグレードするバージョンを確認します。

root@polaris:~$ sudo apt update
root@polaris:~$ sudo apt-cache madison kubeadm
   kubeadm | 1.32.9-1.1 | https://pkgs.k8s.io/core:/stable:/v1.32/deb  Packages
   kubeadm | 1.32.8-1.1 | https://pkgs.k8s.io/core:/stable:/v1.32/deb  Packages
   # 省略

アップグレード対象のv1.33系が確認できないため、/etc/apt/sources.list.d/kubernetes.listのURLを以下のように変更します。

- deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.32/deb/ /
+ deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.33/deb/ /
root@polaris:~$ sudo apt update
root@polaris:~$ sudo apt-cache madison kubeadm
   kubeadm | 1.33.5-1.1 | https://pkgs.k8s.io/core:/stable:/v1.33/deb  Packages
   kubeadm | 1.33.4-1.1 | https://pkgs.k8s.io/core:/stable:/v1.33/deb  Packages
   # 省略

v1.33系が表示されたので、今回は最新のパッチバージョンであるv1.33.5にアップグレードします。

root@polaris:~$ sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.33.5-*' && \
sudo apt-mark hold kubeadm
# 省略

root@polaris:~$ kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"33", EmulationMajor:"", EmulationMinor:"", MinCompatibilityMajor:"", MinCompatibilityMinor:"", GitVersion:"v1.33.5", GitCommit:"03e764d0394bdff662e960c70d25b3c30d731666", GitTreeState:"clean", BuildDate:"2025-09-09T19:50:45Z", GoVersion:"go1.24.6", Compiler:"gc", Platform:"linux/amd64"}

root@polaris:~$ sudo kubeadm upgrade plan
# 省略

root@polaris:~$ sudo kubeadm upgrade apply v1.33.5
# 省略
[upgrade] SUCCESS! A control plane node of your cluster was upgraded to "v1.33.5".

[upgrade] Now please proceed with upgrading the rest of the nodes by following the right order.

この時点では、kubectl get nodesVERSION列は変わりません。これは、表示されるバージョンがkubeletのバージョンを参照しているためです。公式ドキュメントに従って、kubeletとkubectlもアップグレードします。

root@polaris:~$ kubectl drain polaris --ignore-daemonsets
# 省略

root@polaris:~$ sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.33.5-*' kubectl='1.33.5-*' && \
sudo apt-mark hold kubelet kubectl
# 省略

root@polaris:~$ sudo systemctl daemon-reload
root@polaris:~$ sudo systemctl restart kubelet

root@polaris:~$  kubectl uncordon polaris
# 省略

root@polaris ~ $  kubectl get nodes
NAME      STATUS   ROLES           AGE    VERSION
aries     Ready    <none>          153d   v1.32.3
gemini    Ready    <none>          132d   v1.33.1
polaris   Ready    control-plane   153d   v1.33.5
taurus    Ready    <none>          153d   v1.32.3

今回のKubernetesクラスタはコントロールプレーンノードが1台のみの構成です。そのため、kubeadm upgrade applyの実行中やkubelet再起動時にAPIサーバーが一時的に停止し、数分程度のダウンタイムが発生しました(HA構成であれば回避可能です)。

3. ワーカーノードのアップグレード

以下の公式ドキュメントに従い、ワーカーノードを1台ずつ順番にアップグレードしていきます。

v1-33.docs.kubernetes.io

手順はコントロールプレーンノードと同じく

  1. kubeadmのアップグレード
  2. ノードのdrain
  3. kubeletとkubectlのアップグレード
  4. ノードのuncordon

という流れで実行します。

root@aries:~$ sudo sed -i 's/v1\.32/v1.33/' /etc/apt/sources.list.d/kubernetes.list
root@aries:~$ sudo apt update
# 省略
root@aries:~$ sudo apt-mark unhold kubeadm && \
sudo apt-get update && sudo apt-get install -y kubeadm='1.33.5-*' && \
sudo apt-mark hold kubeadm
# 省略
root@aries:~$ sudo kubeadm upgrade node
# 省略

# コントロールプレーンノード上で実行
root@polaris:~$ kubectl drain aries --ignore-daemonsets --delete-emptydir-data
# 省略

root@aries:~$ sudo apt-mark unhold kubelet kubectl && \
sudo apt-get update && sudo apt-get install -y kubelet='1.33.5-*' kubectl='1.33.5-*' && \
sudo apt-mark hold kubelet kubectl
root@aries:~$ sudo systemctl daemon-reload
root@aries:~$ sudo systemctl restart kubelet

# コントロールプレーンノード上で実行
root@polaris:~$ kubectl uncordon aries
# 省略

同じ手順を全てのワーカーノードに適用すると、アップグレードが完了します。

root@polaris:~$ kubectl get nodes
NAME      STATUS   ROLES           AGE    VERSION
aries     Ready    <none>          154d   v1.33.5
gemini    Ready    <none>          132d   v1.33.5
polaris   Ready    control-plane   154d   v1.33.5
taurus    Ready    <none>          154d   v1.33.5

まとめ

本記事では、オンプレミス環境に構築したKubernetesクラスタをkubeadmを用いて段階的にアップグレードする手順を紹介しました。

CNIプラグインとして利用しているCiliumについては、コントロールプレーンノードのアップグレード完了時点で、Kubernetes v1.33との互換性が保証されているv1.18へアップグレードするのが望ましいです。ただし今回は、以下の公式ドキュメントで必須となっているpre-flight checkに失敗したため、保留としました。

docs.cilium.io

その後、v1.33と同じ手順でv1.34へのアップグレードも完了させました。

root@polaris:~$ kubectl get nodes
NAME      STATUS   ROLES           AGE    VERSION
aries     Ready    <none>          154d   v1.34.1
gemini    Ready    <none>          132d   v1.34.1
polaris   Ready    control-plane   154d   v1.34.1
taurus    Ready    <none>          154d   v1.34.1

クラスタの規模は小さいにも関わらず想定以上に手間がかかり、アップグレード作業の大変さを実感しました。また、マネージドKubernetesの自動アップグレードの便利さも再確認できました。

小規模なクラスタではありましたが、実際に手を動かしてアップグレードを経験できたのは良い経験でした。

Free-threaded Python実践入門(#17)

はじめに

Python 3.14のリリースを2025年10月7日に控え、バージョン3.14.0rc3が9月18日にリリースされました。

docs.python.org

What's newドキュメントでは多くの新機能が紹介されていますが、私が特に注目しているのは『PEP 779: Free-threaded Python is officially supported』です。Python 3.13でもオプションとしてFree-threadingが実験的*1にサポートされていましたが、Python 3.14では正式にサポートされます。

Free-threadingによってGlobal Interpreter Lock(GIL)を無効化できるようになると、マルチスレッドのアプリケーションが真にマルチコアCPUの恩恵を受けることができます。つまり、マルチスレッドはI/Oバウンドなタスクの並行処理に限らず、CPUバウンドなタスクの並列処理にとっても有力な選択肢となりました。

本記事では、Free-threaded Pythonの入門から実践まで、実例を通して徹底的に解説します。レベルを問わず、並列処理に興味がある全てのPythonistaを対象読者としています。

本記事の内容は、2025年9月20日時点の情報に基づいています。

1. Pythonにおける並行処理と並列処理

まず、並行処理(Concurrency)と並列処理(Parallelism)について復習します。初学者はよくこれらを混同してしまいがちですが、全く異なる概念です。

本記事の読者はこれらの概念を大まかに理解していると予想されるため、Go言語の共同開発者の一人であるRob Pike氏の『Concurrency is not parallelism』という素晴らしい講演からの引用に留めます。「処理する(dealing with)」と「実行する(doing)」という言葉の対比によって、並行処理と並列処理の違いがとても美しく説明されています。

In programming, concurrency is the composition of independently executing processes, while parallelism is the simultaneous execution of (possibly related) computations. Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

FastAPIの公式ドキュメントでは、ハンバーガーショップの例を用いて並行処理と並列処理の違いが分かりやすく紹介されているので、こちらもぜひ参考にしてください。

fastapi.tiangolo.com

次に、プロセス(Process)とスレッド(Thread)について復習します。釈迦に説法かもしれませんが、以下の説明はMicrosoft Learnの『Processes and Threads』より引用したものです。

An application consists of one or more processes. A process, in the simplest terms, is an executing program. One or more threads run in the context of the process. A thread is the basic unit to which the operating system allocates processor time. A thread can execute any part of the process code, including parts currently being executed by another thread.

プロセスは実行中のプログラムそのものであり、スレッドはOSがCPU時間を割り当てる基本単位です。プロセスには1つ以上のスレッドが含まれ、同じプロセスに属するスレッドはリソースを共有します。プロセスを「リソースが関連付けられる実体」、スレッドを「スケジューリングの対象となる実体」と説明することもできます。

さて、Pythonにおける並行・並列処理を実現するためのライブラリはいくつか存在しますが、ここでは代表的な標準ライブラリであるthreadingmultiprocessingasyncioの3つを紹介します。表中の取り消し線太字は本記事のメインテーマであるFree-threaded Pythonのサポートによる変更です。

threading multiprocessing asyncio
並行/並列 並行
並列
並列 並行
実行単位 スレッド プロセス コルーチン/タスク
GILの影響 受ける
受けない
受けない 受けない
得意なタスク I/Oバウンド
CPUバウンド
CPUバウンド I/Oバウンド
メモリ空間 共有 独立 共有
オーバーヘッド 極小
ユースケース ファイルI/O
ネットワークリクエス
???
科学技術計算
データ処理
画像・動画処理
機械学習
Webサーバー
ネットワークI/O
データベース接続

表中のthreadingユースケースにおける「???」については、これからPythonコミュニティ全体でFree-threaded Pythonユースケースを模索していく必要があるというメッセージを込めています。

表を見やすさを優先した結果、やや説明不足になってしまいました。

それでは、Python公式ドキュメントを参考にそれぞれ簡単に紹介します。

1-1. threading

docs.python.org

threadingは、プロセス内で複数のスレッドを並行に実行できるライブラリです。

import threading
import time
import os

def worker(name: str, duration: int) -> None:
    print(f"thread {name} started (pid: {os.getpid()})")
    time.sleep(duration)
    print(f"thread {name} finished")

if __name__ == "__main__":
    t1 = threading.Thread(target=worker, args=("t1", 2,))
    t2 = threading.Thread(target=worker, args=("t2", 1,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print("all threads have finished")
$ python3.13 threading_example.py
thread t1 started (pid: 21808)
thread t2 started (pid: 21808)
thread t2 finished
thread t1 finished
all threads have finished

Python 3.12以前はGILの影響により、1つのスレッドしか同時にPythonコードを実行することができず、threadingでマルチコアCPUの恩恵を受けることができませんでした。そのため、CPUバウンドなタスクを実行する場合はmultiprocessingを利用し、I/Oバウンドなタスクを実行する場合のみthreadingを利用していたPythonistaも多いはずです。

一方、Free-threadingがPython 3.13で実験的にサポートされ、Python 3.14では正式にサポートされます。依然としてGILの無効化はオプションですが、threadingでも真の並列性が実現されるためユースケースは増加すると考えられます。Free-threaded PythonとGILについては、第2章でより詳しく説明します。

スレッド間はメモリ空間を共有しているためプロセスに比べてオーバーヘッドが小さく、コンテキストスイッチのコストも低いです。

メモリ空間を共有することで、簡単かつ高速にスレッド間でデータを共有することができますが、競合状態(Race Condition)やデッドロック(Deadlock)を引き起こす可能性があるためLockなどを適切に利用する必要があります。これについては第6章で詳しく取り扱います。

1-2. multiprocessing

docs.python.org

multiprocessingは、複数のプロセスを生成して並列に実行できるライブラリです。

import multiprocessing
import time
import os

def worker(name: str, duration: int) -> None:
    print(f"process {name} started (pid: {os.getpid()})")
    time.sleep(duration)
    print(f"process {name} finished")

if __name__ == "__main__":
    p1 = multiprocessing.Process(target=worker, args=("p1", 2,))
    p2 = multiprocessing.Process(target=worker, args=("p2", 1,))

    p1.start()
    p2.start()

    p1.join()
    p2.join()

    print("all processes have finished")
$ python3.13 multiprocessing_example.py
process p2 started (pid: 20304)
process p1 started (pid: 20303)
process p2 finished
process p1 finished
all processes have finished

multiprocessingはサブプロセスを利用しているためGILを回避することができ、マルチコアマシンの恩恵を受けることができます。threadingと似たようなAPIの他に、データ並列に便利なPoolなどもサポートしています。

multiprocessingはプロセスを開始する方法としてspawnforkforkserverという3つの方法をサポートしています。WindowsmacOSのデフォルトであるspawnは新しいPythonインタプリタを起動しますが、親プロセスから実行に必要なリソースのみ引き継ぐため、他の方法に比べて起動が少し遅くなります。Python3.13以前はmacOSを除くPOSIXのデフォルトであったforkは親プロセスが複製されてリソースが全て引き継がれるため、起動は非常に高速ですが親プロセスがマルチスレッドで動作している場合は安全性に問題があります。こうした背景によりPython3.14からPOSIXのデフォルトになったforkserverは、プロセスを生成するためのサーバープロセスを予め起動し、このサーバーに依頼することで安全な状態からプロセスをforkします。いずれの方法も、if __name__ == '__main__'内でmultiprocessing.set_start_method()によって指定する必要があるので注意が必要です。

プロセスは独自のメモリ空間を持つため、プロセス間のデータ共有には共有メモリかサーバープロセスを利用する必要があります。共有メモリはValueArrayといったクラスが利用でき、メモリ経由で直接データを共有できるためパフォーマンスは高いですが、数値や配列などC言語の単純なデータ型と互換性のあるものに限定されます。一方でサーバープロセスはManager()によってサーバープロセスがオブジェクトを管理し、各プロセスがプロキシ経由で操作します。任意のオブジェクトを扱える柔軟性がありますが、シリアライズ/デシリアライズやデータコピーが発生するため共有メモリより低速です。

multiprocessingPyTorchjoblibscikit-learnのdependenciesの1つ)など、多くのPythonライブラリで並列処理を実現するために利用されています。

1-3. asyncio

docs.python.org

asyncioは、async/await構文を利用して非同期なコードを書けるライブラリです。asyncioの基礎については、以下の公式ドキュメントが参考になります。

docs.python.org

import asyncio
import os

async def worker(name: str, duration: int) -> None:
    print(f"task {name} started (pid: {os.getpid()})")
    await asyncio.sleep(duration)
    print(f"task {name} finished")

async def main() -> None:
    t1 = asyncio.create_task(worker("t1", 2))
    t2 = asyncio.create_task(worker("t2", 1))

    await asyncio.gather(t1, t2)

if __name__ == "__main__":
    asyncio.run(main())

    print("all tasks have finished")
$ python3.13 asyncio_example.py
task t1 started (pid: 24620)
task t2 started (pid: 24620)
task t2 finished
task t1 finished
all tasks have finished

コルーチン(Coroutines)について、async defで定義された関数はコルーチン関数であり、それによって呼び出されたのがコルーチンオブジェクトです。一方でタスク(Tasks)にはコルーチンをイベントループ(Event Loop)に結び付ける役割があり、asyncio.create_task()などによって作成されたタスクはすぐに実行できるように自動的にスケジューリングされます。

asyncioはシングルスレッドで動作します。これは、実行中の各タスクがCPUの制御を自発的にイベントループに返却することで他のタスクに実行機会を与えます。一方、threadingはマルチスレッドを利用してOSが強制的にコンテキストスイッチを行います(プリエンプティブマルチタスキング)。この違いから、asyncioの仕組みは協調的マルチタスキングCooperative Multitasking)と呼ばれています。

Pythonの公式ドキュメントによると「asyncioはI/Oバウンドで高度に構造化されたネットワークコードに向いている」とされています。例えばWebフレームワークであるFastAPIは、Starletteを土台としてasync/awaitによる非同期コードを利用しています。これにより、シングルスレッドでも多数のクライアントからのリクエストを並行処理することができ、スレッドベースのアーキテクチャと比較してオーバーヘッドやメモリ消費が少なく、非常に高いパフォーマンスを獲得しています。

1-4. その他の標準ライブラリ

docs.python.org

concurrent.futuresは、呼び出し可能なオブジェクトを非同期実行するための高レベルなインターフェースを提供しています。

import concurrent.futures
import time
import os

def worker(name: str, duration: int) -> None:
    print(f"worker {name} started (pid: {os.getpid()})")
    time.sleep(duration)
    print(f"worker {name} finished")

if __name__ == "__main__":
    with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
        f1 = executor.submit(worker, "t1", 2)
        f2 = executor.submit(worker, "t2", 1)

        f1.result()
        f2.result()

    print("all threads have finished")

    with concurrent.futures.ProcessPoolExecutor(max_workers=2) as executor:
        f1 = executor.submit(worker, "p1", 2)
        f2 = executor.submit(worker, "p2", 1)

        f1.result()
        f2.result()

    print("all processes have finished")
$ python3.13 concurrent_futures_example.py
worker t1 started (pid: 63540)
worker t2 started (pid: 63540)
worker t2 finished
worker t1 finished
all threads have finished
worker p1 started (pid: 63542)
worker p2 started (pid: 63543)
worker p2 finished
worker p1 finished
all processes have finished

Executor抽象基底クラスのsubmit()メソッドは実行をスケジュールし、実行結果や状態、例外を取得できるFutureオブジェクトを返します。ThreadPoolExecutorProcessPoolExecutorはどちらもExecutorを実装したサブクラスであり、スレッドベースかプロセスベースかの違いに関わらず、共通のインターフェースを利用することができます。また、引数のmax_workers=によって、プールの最大のスレッド・プロセス数を指定することができます。

concurrent.futures.ProcessPoolExecutorsubmit()メソッドを基本として、ノンブロッキングなタスクの投入とブロッキングな結果の待機が明確に分離されるように設計されています。これに対し、multiprocessing.Poolは、投入と待機が一体化したブロッキングなメソッドと、_async接尾辞で区別されるノンブロッキングなメソッドが混在しており、APIの設計思想が一貫しているとは言えません。

2. Free-threaded Pythonの概要とGIL

docs.python.org

PEP 703とそのacceptanceにおいて、Free-threaded Pythonの3段階のロールアウトフェーズが紹介されました。フェーズIPython 3.13から始まり、Free-threadedビルドを利用可能かつ実験的にのみサポートしています。Python 3.14から始まるフェーズIIでは、Free-threadedビルドが依然としてオプションではありますが公式にサポートされます。Python 3.14が公式サポートの基準を満たすとするPEP 779とそのacceptanceは非常に大きなインパクトを与えました。そして次のフェーズⅢでは、Free-threadedビルドがデフォルトになる予定です。

peps.python.org

PEP 779では根拠としてパフォーマンスの向上も言及されています。GILありビルドと比べてFree-threadedビルドのシングルスレッド性能低下が10%程度(macOSは3%程度)で、いくつかのPRによって10%未満に抑えられる見込みのことです。ベンチマークとしてはpyperformanceが利用されています。

そもそも、Free-threadedビルドでシングルスレッド性能が低下する原因は何でしょうか。PEP 703によると、GILなしでもCPythonをスレッドセーフにするための変更が実行時のオーバーヘッドになっているそうです。その中には、参照カウント操作のアトミック化や可変コンテナのオブジェクト単位ロック、循環GC実行時のstop-the-world同期、デッドロック回避のためのクリティカルセクション管理などが含まれます。確かにGILがなくなることでシングルスレッド性能は低下しますが、マルチスレッドで複数CPUコアを活用することで全体的なパフォーマンスは改善できます。

以下のページでFree-threadingをサポートしているパッケージのステータスを確認できます(純粋なPythonで書かれたパッケージはそのまま動作するため含まれていません)。Numpy、pandas、Pillow、PyTorch、SciPyなどの著名なライブラリが既にFree-threadingをサポートしています。

py-free-threading.github.io

3. Free-threaded Pythonベンチマーク

本章では、2種類のCPUバウンドタスクを通じ、Free-threaded Pythonの性能を検証します。特にGILから解放されたthreadingが、従来の並列処理の代表格であったmultiprocessingと比較して、どの程度実用的な選択肢となり得るかを考察します。

ベンチマークは、アーキテクチャが異なる以下の2つの環境で実施しました。

  • MacBook Pro (OS: macOS 15.6.1, Chip: Apple M2 Pro, Cores: 10, Memory: 16 GB)
  • Supermicro AS-1014S-WTRT (OS: Ubuntu 24.04.2 LTS, Chip: AMD EPYC 7543P, Cores: 32, Memory: 128 GB)

multiprocessingにおけるプロセスの開始方法は、各OSにおけるPython 3.14のデフォルト設定(macOSではspawnUbuntuではforkserver)をそのまま使用しています。

使用したPythonバージョンは3.14.0rc2および3.14.0rc2 free-threading buildです。

uvを利用して仮想環境のPythonバージョンを切り替える方法

$ uv venv -p 3.14
Using CPython 3.14.0rc2
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
$ uv python pin 3.14
Pinned `.python-version` to `3.14`
$ uv run python -VV
Python 3.14.0rc2 (main, Aug 28 2025, 17:02:21) [Clang 20.1.4 ]

$ uv python pin 3.14t
Updated `.python-version` from `3.14` -> `3.14t`
$ uv venv -p 3.14t
Using CPython 3.14.0rc2
Creating virtual environment at: .venv
✔ A virtual environment already exists at `.venv`. Do you want to replace it? · yes
Activate with: source .venv/bin/activate
$ uv run python -VV
Python 3.14.0rc2 free-threading build (main, Aug 28 2025, 16:50:54) [Clang 20.1.4 ]

完全なソースコードは以下のGitHubリポジトリで公開しています。

github.com

3-1. 純粋なCPUバウンドタスク

実行時間がほぼ完全にCPUの処理能力に依存する計算集約型のタスクです。

今回は、素数カウント(prime_count.py)を採用しました。このタスクでは、素数を探索する数値の範囲を複数のチャンクに分割し、各ワーカーが担当する範囲内の素数をカウントします。is_prime関数で素数判定を行い、count_primes_in_range関数でその個数を集計します。最後に全ワーカーの計算結果を合計することで、全体の素数の個数が得られます。

def is_prime(n: int) -> bool:
    """Checks if a number is prime."""
    if n <= 1:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False
    for i in range(3, int(math.sqrt(n)) + 1, 2):
        if n % i == 0:
            return False
    return True

def count_primes_in_range(start: int, end: int) -> int:
    """Counts the number of primes within a given range."""
    return sum(1 for n in range(start, end) if is_prime(n))

以下のグラフは、1,000万未満の素数をカウントした時の並列数と実行時間の関係を表しています。各点は5回測定した平均値として示しています。

MacBook Proにおける並列数と実行時間の関係

Supermicro AS-1014S-WTRTにおける並列数と実行時間の関係

GILビルド(実線)の場合、threadingはワーカー数を増加させても性能がほとんど向上せず、むしろSupermicroではオーバーヘッドで性能が低下してしまいました。純粋なPythonコードによるCPUバウンドな処理はGILの影響を非常に強く受けることが確認できました。一方、GILの影響を受けないmultiprocessingは、ワーカー数を増加させることで実行時間がスケールして短縮されました。

Free-threadedビルド(点線)の場合、GILが無効化されたthreadingでワーカー数の増加に伴って性能が向上しています。MacBookにおけるthreadingmultiprocessingの性能差は、ワーカー数が少ないうちは小さいですが、コア数に近づくにつれて大きくなっていきます。Supermicroでは、threadingmultiprocessingとの性能差は非常に小さくなっています。また、Free-threadedビルドのmultiprocessingの性能はGILビルドよりも僅かに低下しており、2章で言及したスレッドセーフのためのオーバーヘッドが存在していると考えました。

この結果は、これまでmultiprocessingがほぼ唯一の選択肢であった純粋なCPUバウンドタスクの並列処理において、Free-threaded Pythonthreadingが新たな選択肢となり得ることを示しています。

3-2. データ共有を伴うCPUバウンドタスク

CPU負荷が高く、複数のワーカーが巨大な共有データに対して演算を行うタスクです。

今回は配列要素の合計(array_sum.py)を採用しました。このタスクは、メモリ上に確保された巨大なNumpy配列を複数のチャンクに分割し、各ワーカーが担当する範囲の配列要素を合計します。各ワーカーはsum_array_chunk関数(共有メモリ利用時はsum_array_shm関数)を使用して、敢えて単純なforループで要素を加算していくことでCPU負荷を高くしています。最後に全ワーカーの計算結果を合計することで、全配列要素の合計を求めます。

def sum_array_chunk(data: npt.NDArray[np.int64]) -> np.int64:
    """Calculates the sum of a given NumPy array chunk."""
    total = np.int64(0)
    # NOTE: Intentionally not using np.sum(data) to simulate a CPU-bound task.
    for value in data.reshape(-1):
        total += value
    return total

def sum_array_shm(
    shm_name: str, shape: tuple[int], dtype: np.dtype, start: int, end: int
) -> np.int64:
    """Calculates the sum of a segment of a NumPy array in shared memory."""
    shm = None
    try:
        shm = shared_memory.SharedMemory(name=shm_name)
        data: npt.NDArray[np.int64] = np.ndarray(shape, dtype=dtype, buffer=shm.buf)[
            start:end
        ]
        # NOTE: Intentionally not using np.sum(data) to simulate a CPU-bound task.
        total = np.int64(0)
        for value in data.reshape(-1):
            total += value
    finally:
        if shm:
            shm.close()
    return total

以下のグラフは、長さ1億の配列要素を合計した時の並列数と実行時間の関係を表しています。各点は5回測定した平均値として示しています。

MacBook Proにおける並列数と実行時間の関係

Supermicro AS-1014S-WTRTにおける並列数と実行時間の関係

GILビルド(実線)の場合、threadingはワーカー数を増加させても性能が向上せず、ほぼ一定の結果となりました。これは、配列の合計処理を(np.sum()ではなく)GILを解放しないforループで実装したため、GILの影響を強く受けた結果だと考えられます。一方、multiprocessingはワーカー数の増加に伴って実行時間が短縮されました。また、特にMacBookではワーカー数が多い場合に共有メモリがデータ共有のオーバーヘッドを削減し、より高速になる傾向が見られました。

Free-threadedビルド(点線)の場合、GILから解放されたthreadingがワーカー数の増加に伴ってスケールしました。特にSupermicroではthreadingが全手法の中で最速となり、multiprocessingはワーカー数の増加に伴うオーバーヘッドにより、性能向上が鈍化してしまいました。一方MacBookでは、共有メモリを利用するmultiprocessingが最速であるものの、threadingや通常のmultiprocessingとの性能差は比較的小さく、いずれの手法も有効にスケールしていることが分かります。

この結果は、Free-threaded Pythonにおいてデータ共有のオーバーヘッドとハードウェア特性が並列処理手法の選択に大きく影響することを示しています。threadingのシンプルさと、共有メモリを使ったmultiprocessingの効率性の間で、状況に応じた最適な選択が求められます。

4. Free-threaded Pythonのプロファイリング

第3章のベンチマークでは、Free-threaded Pythonthreadingが特定の条件下で高い性能を発揮することを確認できました。しかし、その実行時間の数値だけでは、パフォーマンスの真のボトルネックを特定するには不十分です。

本章では、プログラムの内部動作を分析し、リソース消費が大きい箇所を特定するプロファイリングというテクニックを利用します。Grafanaの公式ドキュメント『What is profiling?』では、プロファイリングを次のように定義しています。

Profiling is a technique used in software development to measure and analyze the runtime behavior of a program. By profiling a program, developers can identify which parts of the program consume the most resources, such as CPU time, memory, or I/O operations. You can use this information to optimize the program, making it run faster or use fewer resources.

Python用の高性能なCPU・GPU・メモリプロファイラとしてScaleneがあります。ただしScaleneはGILを前提とした設計であり、Free-threadingをサポートするためには内部構造の大幅な変更が必要であるとIssueで言及されています。

github.com

また、Rustで書かれたPython用のCPUプロファイラであるpy-spyについても、PEP703に記載されているPyObject構造体の変更により、Free-threaded Pythonでは動作しないとIssueで言及されています。

github.com

ScaleneのREADMEによると、Scaleneとpy-spyはともにマルチプロセスとマルチスレッドの両方をサポートするプロファイラですが、本記事では対象外とします。

プロファイリングにはいくつかの種類がありますが、ここではCPUプロファイリングのメモリプロファイリングの2つに絞って、Free-threaded Pythonのプロファイリングに利用できるツールを紹介します。

4-1. CPUプロファイリング

第2章でも紹介した『Python Free-Threading Guide』では、Free-threaded Pythonのプロファイリングのために、低レベルな統計的プロファイラ(サンプリングプロファイラ)であるsamplyが推奨されています。

github.com

samplyは、プログラム実行中に一定間隔(デフォルトは1ms)でコールスタックを記録します。このサンプリング結果を分析することで、どの関数がコールスタック中に最も頻繁に現れたか、つまり統計的にどの関数が最も多くのCPU時間を消費したかを特定できます。

samply自体はクロスプラットフォームで動作しますが、現時点でLinux環境のみPythonperfプロファイリングサポートを有効化できるため、CPythonの内部関数だけでなくPythonの関数レベルで詳細な情報を取得したい場合はLinux環境が必須です。

参考:Python support for the Linux perf profiler — Python 3.13.7 documentation

実際にsamplyを利用して、第3章で利用したデータ共有を伴うCPUバウンドタスク(array_sum.py)をCPUプロファイリングしてみましょう。-X perfLinuxperfプロファイリングサポートを有効化している点に注意してください。

# threadingに絞って最大並列数32で実行
$ samply record python -X perf -m benchmark.cases.array_sum --max-workers 32 --filter threading
# 省略
Local server listening at http://127.0.0.1:3000
Press Ctrl+C to stop.

処理が完了すると、samplyは自動的にローカルサーバーを起動してブラウザに結果が表示されます。SSH経由で実行している場合は、SSHポートフォワーディングを利用するか、Ctrl+Cでローカルサーバーを停止し、生成されたprofile.json.gzを手元にダウンロードしてからFirefox Profiler⁩にアップロードしましょう。

Firefox ProfilerのUI(デフォルトではCall Treeを表示)

ワーカースレッドをFlame Graphで表示

上図は、Free-threaded threadingarray_sum.pyをプロファイリングした時のThreadPoolExecuという名前のワーカースレッドのFlame Graphです。

Flame Graphでは、長方形が幅が広ければ広いほど(サンプリング回数が多く)CPU時間を多く消費したことを表しています。下にあるのが呼び出し元の関数、上にある呼び出し先の関数です。

このグラフを見ると、sum_array_chunkという関数が非常に広い幅を占めていることが分かります。これは、意図的にnp.sum()を使わずにforループで要素を加算している関数であり、このベンチマークにおける主要なCPUバウンド処理です。その下を辿っていくと、concurrent/futures/thread.pyWorkerContext.run/_WorkItem.run/_workerや、threading.py(標準ライブラリ)のThread.run/Thread._bootstrap_inner/Thread._bootstrapなど、スレッド実行に関連する呼び出しスタックが確認できます。

このように、samplyを用いることで、Free-threaded Pythonでもマルチスレッド実行されているコードのどの部分がボトルネックになっているかを正確に特定し、的を絞った最適化を行うことができます。ベンチマークの数値だけでは分からない「なぜ遅いのか」を解明するための、非常に重要なステップです。

4-2. メモリプロファイリング

Free-threaded Pythonのメモリプロファイラとしては、Memrayが最も有力な選択肢です。MemrayはPythonコードだけでなく、C/C++拡張やPythonインタプリタ内部のメモリ割り当てまで追跡でき、解析のためにさまざまな形式のレポートを生成します。

github.com

先ほどと同様に、第3章で利用したデータ共有を伴うCPUバウンドタスク(array_sum.py)をメモリプロファイリングしてみましょう。

# threadingに絞って最大並列数32で実行
$ memray run -m benchmark.cases.array_sum --max-workers 32 --filter threading
Writing profile results into memray-benchmark.cases.array_sum.4187165.bin
# 省略
[memray] Successfully generated profile results.

You can now generate reports from the stored allocation records.
Some example commands to generate reports:

/root/azuma/pyconjp2025/.venv/bin/python3 -m memray flamegraph memray-benchmark.cases.array_sum.4187165.bin

バイナリファイルが出力されるので、Flame Graphを生成します。

$ memray flamegraph memray-benchmark.cases.array_sum.4187165.bin
Wrote memray-flamegraph-benchmark.cases.array_sum.4187165.html

出力されたHTMLファイルをブラウザで開いてみましょう。

Web UIでFlame Graphを表示

メモリ使用状況グラフの詳細を表示

5. Free-threaded Pythonの活用事例

Free-threaded Pythonの活用事例として、筆者の研究分野であるFederated Learningを取り上げます。

5-1. Federated Learningの概要

Federated Learningにおけるクライアントとサーバーの通信(図はWikipediaより引用)

Federated Learning(連合学習、FL)とは、複数のクライアントがデータを集約することなく、協調して機械学習モデルを訓練するテクニックのことです。クライアントが機密性の高いデータを送信する代わりに、そのデータで訓練したローカルモデルのパラメータを定期的に送信することで、サーバーがそれらを集約してグローバルモデルを構築できるため、プライバシー保護の観点から注目を集めています。

FLについてより詳しく知りたい方は、以下をご参照ください。

www.ibm.com

flower.ai

初めて「Federated Learning」という用語を定義し、FLアルゴリズムを提案したのが以下の論文です。

arxiv.org

この論文で提案されたFedAvgというアルゴリズムは、最も基本的かつ広く利用されているFLの手法です。具体的には、各クライアントがローカルデータでモデルを訓練し、そのモデルパラメータをサーバーに送信します。サーバーは受信したモデルパラメータの重み付き平均をとって新しいグローバルモデルを更新し、再びクライアントに配布します。これを繰り返すことで、クライアントがデータを直接共有することなく、全体として高性能なグローバルモデルを獲得することができます。

FedAvgをきっかけに、通信コストの削減やデータ・システムの異質性への対応、敵対的攻撃への耐性強化など、様々な課題に取り組むFLアルゴリズムが数多く提案されています。

5-2. FLフレームワークと並列処理

現実世界でFLアルゴリズムの検証を行う場合、大量の物理マシンを調達・設定・管理する必要があり非常にコストがかかります。そこで有効なのがシミュレーションです。FLフレームワークを用いることで、数百から数千のクライアントを模擬しながら効率的にFLアルゴリズムを検証することができます。また、データ・システムの異質性やクライアントの参加・離脱といった、より現実的なシナリオを柔軟に再現することも可能です。

FLシミュレーションでは、サーバーと多数のクライアントが反復的に通信し、各ラウンドでサイズの大きいモデルパラメータを交換します。クライアント処理を直列で実行することもできますが、効率的な実験サイクルを回すためには並列化が不可欠です。

代表的なFLフレームワークは、それぞれ異なるアプローチで並列化を実現しています。

  • Flower:汎用的な分散処理フレームワークであるRayをバックエンドに利用します。シミュレーション対象のクライアント(オブジェクト)を、利用可能なリソースに応じて作成したRay Actor(ワーカープロセス)のプールにタスクとして投入することで並列実行します。シングルノードからマルチノードクラスタまでシームレスにスケールするのが特徴です。

  • NVFlare:組み込みのFL Simulator を備えており、サーバと複数クライアントを単一プロセス内のマルチスレッドでシミュレーションします。この軽量なアプローチにより、シングルノードでの迅速な検証が可能です。クライアント群を指定したGPUに分配し、別々のプロセスとして実行することもできます。

  • FedML:大規模分散学習を想定して設計されており、MPI(Message Passing Interface)や NCCLNVIDIA Collective Communications Library)といった通信バックエンドを利用して複数GPU環境における効率的な分散処理を実現します。特に、高速なGPU間通信(例: NVLink, InfiniBand)が利用可能な環境では NCCLベースのMPIシミュレーションが推奨されます。

  • pfl-research:研究を加速させるためのシミュレーションフレームワークであり、分散学習ライブラリであるHorovodを主なバックエンドとして利用し(tf.distributetorch.distributedもサポート)、単一プロセスでの実行から、マルチプロセス、マルチGPU、マルチノード環境へとシームレスにスケールアップできます。

  • BlazeFL:シングルノードでのシミュレーション効率を重視し、Free-threaded Pythonを活用したマルチスレッドモードを中核に据えています。threadingを用いた真の並列処理と、メモリ空間の共有による低オーバーヘッドなデータ共有を実現します。torch.multiprocessingを活用したマルチプロセスモードもサポートしています。

本記事ではRayベースのFlowerと、筆者が開発しているBlazeFLに焦点を当てて、そのアーキテクチャと利点を掘り下げます。

5-2-1. Flower

github.com

Flowerはカスタマイズ性が高く、PyTorchやTensorFlow、JAXなど多くの機械学習フレーワークに対応している著名なFLフレームワークです。Flowerのシミュレーションエンジンは、Rayをベースに構築されています。

RayはスケーラブルなMLワークロードのための分散処理フレームワークであり、シングルノードからマルチノードまで同じPythonコードでスケールします。ステートレスなタスク(関数)は@ray.remoteというデコレーターを追加するだけ分散実行させることができます。クラスについてはActorというステートフルなワーカーをインスタンス化すると、そのActorのメソッドのスケジュール先となるワーカープロセスが作成されます。

RayベースのFlowerシミュレーションエンジンでは、複数のワーカー(Pythonプロセス)を含むRayBackend(デフォルトのバックエンド)がClientAppというオブジェクトを生成・管理します。実行するClientAppがワーカー数よりも多い場合は、リソースが解放されてから実行されるため並列数をコントロールすることができます。例えば、1ラウンドで100クライアントを処理する設定で、システムリソースの都合上10クライアントまでしか並列化できない場合、100個のClientAppは10個ずつのバッチで順次実行されます。

Rayは、各ノードに存在するオブジェクトストアにオブジェクトを保存します。複数のオブジェクトストアを組み合わせることで、クラスタ全体で分散共有メモリのような仕組みを提供します。オブジェクトは不変(immutable)であるため、オブジェクトが複数のノードに複製されてもデータを同期させる必要がなく、高いパフォーマンスを実現しています。あるノードで参照先のオブジェクトがオブジェクトストアに存在しない場合は、そのオブジェクトを持つ別のノードから転送され、ローカルストアにキャッシュされます。

オブジェクトがNumpy配列やPandasデータフレームなど、Apache Arrow形式と互換性がある場合は、プロセスは共有メモリ上のデータを直接参照するゼロコピーでの読み取りが可能で非常に高速です。一方、それ以外のPythonオブジェクトの場合、共有メモリからPythonオブジェクトを復元する必要があるため、データコピーシリアライズという2つのコストがかかります。

このインメモリオブジェクトストアであるPlasmaは、もともとRayプロジェクトの一部として開発されました。その後、より広範なプロジェクトで利用できるようにApache Arrowプロジェクトに移管されましたが、Rayは独自の最適化を行うためPlasmaをフォークして開発を継続しています。一方、Apache ArrowではPlasmaの開発は終了し、既に削除されています。

5-2-2. BlazeFL

github.com

BlazeFLは、あえてシングルノードでの高速なシミュレーションに特化して「シンプルさ」と「高速性」という2つの価値を追求するために設計されたFLフレームワークです。

このシンプルさは、FL研究の現実的なユースケースに焦点を当てることから生まれています。多くの論文で扱われるモデルはコンシューマー向けGPUを搭載した単一マシンで十分に訓練可能であり、実験は再現性の観点からコンテナ上で行われるのが一般的です。BlazeFLはまさにこの環境をターゲットとし、マルチノード化に伴うKubernetesのようなコンテナオーケストレーションツールの複雑さを意図的に回避しています。

この思想は、PyTorch以外に主要な依存ライブラリを持たないというミニマルな設計にも現れています。標準ライブラリ中心の軽量なコードベースは、フレームワーク全体を透明でカスタマイズ可能な状態に保ちます。これにより、研究者は複雑な抽象化に悩まされることなく、コアロジックを容易に理解し、自身の研究に合わせて自由にカスタマイズすることが可能です。

そして、もう一つの価値である高速性を追求するため、BlazeFLは特性の異なる2つの並列実行モードを提供します。

  • マルチプロセスモード: 安定した従来型の並列化手法です。multiprocessingのラッパーでPyTorchのテンソル共有に特化したtorch.multiprocessingを活用し、サイズの大きいモデルパラメータ(torch.Tensor)を共有メモリに配置します。これにより、各ワーカプロセスはゼロコピーでテンソルを参照でき、デシリアライズのコストを削減します。ただし、プロセス自体の生成コストは避けられません。

  • マルチスレッドモードPython 3.13ではExperimental): 本記事のテーマであるFree-threaded Pythonを活用する先進的なモードです。GILが無効化された環境でクライアントをスレッドとして実行することで、真の並列処理を実現します。スレッドはメモリ空間を共有するため、プロセス間通信で発生するシリアライズやデータコピーのオーバーヘッドがほぼゼロになります。これにより、multiprocessingのような共有メモリ管理の複雑さから解放され、研究者はほぼ通常のPyTorchコードを書く感覚で、効率的なFLシミュレーションを実行できます。

5-3. FlowerとBlazeFLのベンチマーク

理論上の利点だけでなく、実際のワークロードでどの程度の性能差が生まれるかを検証するため、FlowerとBlazeFLのFLシミュレーション性能を比較するベンチマークを実施します。

ベンチマークはバックグラウンドプロセスは最小限に抑えた、以下のシングルノード・マルチGPU環境で実施しました。

  • TYAN S8030GM2NE (OS: Ubuntu 24.04.2 LTS, Chip: AMD EPYC 7542, Cores: 32, Memory: 256 GB, GPU: 4 × Quadro RTX 6000)

今回のベンチマークでは、執筆時点の最新の安定版であるPython 3.13を使用しました。これは、各種ライブラリの対応が過渡期にあるためです。具体的には、両フレームワークが共通して依存するtorchがまだPython 3.14向けの公式wheelを提供しておらず、Flowerが依存するcffiPython 3.14からしかFree-threadedビルドに対応していません。

これらの制約から各フレームワークが動作する最新の環境として、Flowerは3.13.7、BlazeFLは3.13.7 experimental free-threading buildを選択しました。第2章で言及したように、Free-threadedビルドはGILありのビルドと比較してシングルスレッド性能が低下するため、今回のベンチマークではBlazeFL側に僅かながら不利な条件が含まれている点に注意してください。

FLシナリオとしては、一般的なFL研究を想定し、両フレームワークで条件を統一しました。具体的には、アルゴリズムはFedAvg、データセットCIFAR-10、クライアント数は100、ラウンド数は5、モデルは量なCNNと中規模のResNet-18を採用し、バッチサイズなどのハイパーパラメータは全て統一しています。なお、PyTorchのDataLoaderは画像の前処理でCPUを利用するため、このタスクはCPUとGPUの両方を使用する混合バウンドなワークロードとなります。

Rayの利用可能リソース確認 FlowerのバックエンドであるRayは、モデルパラメータなどのオブジェクトをインメモリのオブジェクトストアで管理します。このストアの容量が不足すると、オブジェクトのスピル(ディスクへの退避)が発生し、深刻なパフォーマンス低下を引き起こす可能性があります。そのため、実験前にray.available_resources()を用いて、リソースが十分であることを確認しました。

$ uv run python
Python 3.13.7 (main, Sep  2 2025, 14:21:46) [Clang 20.1.4 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import ray
>>> ray.init()
2025-09-20 17:29:40,090 INFO worker.py:1951 -- Started a local Ray instance.
2025-09-20 17:29:40,101 INFO packaging.py:588 -- Creating a file package for local module '/home/azuma/benchmarks/flower-case'.
2025-09-20 17:29:40,109 INFO packaging.py:380 -- Pushing file package 'gcs://_ray_pkg_e15f9b005451107a.zip' (0.30MiB) to Ray cluster...
2025-09-20 17:29:40,113 INFO packaging.py:393 -- Successfully pushed file package 'gcs://_ray_pkg_e15f9b005451107a.zip'.
RayContext(dashboard_url='', python_version='3.13.7', ray_version='2.49.1', ray_commit='c057f1ea836f3e93f110e895029caa32136fc156')
>>> resources = ray.available_resources()
>>> print(resources)
{'node:192.168.1.220': 1.0, 'node:__internal_head__': 1.0, 'CPU': 64.0, 'memory': 177717184512.0, 'accelerator_type:RTX': 1.0, 'object_store_memory': 76164507648.0, 'GPU': 4.0}
>>> exit()

上記の通り、オブジェクトストアのメモリ(object_store_memory)は約70GB確保できています。今回最大のモデルであるResNet-18を100クライアント分ロードした場合の総サイズ(約11.7Mパラメータ × 4バイト/パラメータ × 100クライアント ≈ 4.5GB)を大きく上回っており、オブジェクトストアがボトルネックにならないことを確認しています。

完全なソースコードは以下のGitHubリポジトリで公開しています。

github.com

以下に示すグラフは、CNNモデルとResNet-18モデルを使用し、並列ワーカー数を変更しながらシミュレーション全体の実行時間を5回計測した平均値です。エラーバーは標準偏差を表します。

CNNを利用した場合の並列数と実行時間の関係

ResNet-18を利用した場合の並列数と実行時間の関係

CNNとResNet-18の両方のモデルにおいて、BlazeFLのマルチスレッドモードがほぼ全ての並列数で最速の結果を示しました。これは、Free-threaded Pythonの恩恵を最大限に活用できていることを示唆しています。スレッド間でメモリ空間を共有するため、各ラウンドで交換されるモデルパラメータのシリアライズ/デシリアライズやデータコピーのオーバーヘッドがほとんど発生しません。GILが無効化されているため、CPU+GPUバウンドなクライアントのモデル訓練を真に並列化でき、性能が大幅に向上しています。

BlazeFLのマルチスレッドモードは、ワーカー数が16あたりまでは良好にスケールしますが、それを超えると性能が頭打ち、あるいは低下する傾向が見られます。これは、CPUコア数(32コア)に近づくにつれて、コンテキストスイッチのオーバーヘッドやリソース競合が無視できなくなったためと考えられます。

BlazeFLのマルチプロセスモードでは、並列化によって性能は向上しますが、マルチスレッドモードほどのスケーラビリティは見られません。特にCNNモデルでは8ワーカーを超えると性能が著しく低下しており、クライアント数に等しいプロセスの生成コストと、共有メモリを使用しているとはいえシリアライズにかかるオーバーヘッドが大きいためと推測されます。

FlowerはRayをバックエンドとして利用しておりスケールしますが、BlazeFLと比べて特に並列数が1の時点で実行時間が長くなっています。これは、Rayが汎用的な分散処理フレームワークであり、オブジェクトストアの管理やスケジューリングなどが、シングルノードのシミュレーションに比べては、オーバーヘッドが大きくなるためだと考えられます。

モデルサイズの影響として、軽量なCNNモデルと比べてより計算負荷の高いResNet-18の方が並列化による恩恵を受けやすくなっています。一方、BlazeFLのマルチスレッドモードと他の手法との性能差はCNNでより顕著に現れています。これは、モデルの計算時間に対する通信やデータ共有のオーバーヘッドの割合が相対的に大きくなるため、オーバーヘッドを削減することで純粋な並列計算能力に近い速度が出るためだと考えられます。

あくまでもシングルノードにおけるFLシミュレーションという設定ですが、Free-threaded Pythonを活用したBlazeFLのマルチスレッドモードが他の手法を圧倒する高いパフォーマンスとスケーラビリティを発揮しました。これは、特にデータ共有が頻繁に発生するCPUバウンドなタスクにおいてthreadingmultiprocessingやRayのような分散フレームワークを上回る有力な選択肢であると言えます。

6. threadingの落とし穴

Free-threaded Pythonの登場により、threadingはCPUバウンドなタスクでも真の並列処理を実現できるようになりました。しかし、threadingを安全に使いこなすには、複数のスレッドがメモリ空間を共有することによって生じる課題と向き合う必要があります。特に、競合状態はFree-threadingによって顕在化し、デッドロックthreadingによってより身近な問題となります。

本章では、なぜこれらの問題が起きるのか、そしてどうすれば安全にスレッドを扱えるのかを解説します。

6-1. 競合状態(Race Condition)

競合状態とは、2つ以上のスレッドが共有データに同時にアクセスし、処理が実行される順序によって最終的な結果が変わってしまう、意図しない状況を指します。

まずは、複数のスレッドがデータを同時に書き換えようとして発生する競合状態の例を見てみましょう。以下のコードは、2つのスレッドがそれぞれ10万回ずつ、グローバル変数counterをインクリメントする単純なプログラムです。最終的にcounterは20万になることを期待しています。

import threading

counter = 0

def worker(iterations: int) -> None:
    global counter
    for _ in range(iterations):
        counter += 1

if __name__ == "__main__":
    ITERATIONS_PER_THREAD = 100_000

    t1 = threading.Thread(target=worker, args=(ITERATIONS_PER_THREAD,))
    t2 = threading.Thread(target=worker, args=(ITERATIONS_PER_THREAD,))

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    expected = 2 * ITERATIONS_PER_THREAD
    print(f"Expected: {expected}, Actual: {counter}")

このコードを、GILありビルドとFree-threadedビルドでそれぞれ実行してみると、全く異なる結果が得られます。

# GILありビルド
$ uv run python -VV
Python 3.14.0rc3 (main, Sep 18 2025, 19:55:15) [Clang 20.1.4 ]

$ for i in {1..10}; do uv run python race_condition.py; done
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000
Expected: 200000, Actual: 200000

GILありビルドの場合、何度実行しても期待通り20万という結果になります。

# Free-threadedビルド
$ uv run python -VV
Python 3.14.0rc3 free-threading build (main, Sep 18 2025, 19:37:18) [Clang 20.1.4 ]

$ for i in {1..10}; do uv run python main.py; done
Expected: 200000, Actual: 102344
Expected: 200000, Actual: 105768
Expected: 200000, Actual: 102739
Expected: 200000, Actual: 101825
Expected: 200000, Actual: 106348
Expected: 200000, Actual: 102701
Expected: 200000, Actual: 101180
Expected: 200000, Actual: 101508
Expected: 200000, Actual: 100588
Expected: 200000, Actual: 103120

一方、Free-threadedビルドの場合は実行するたびに結果が変わり、期待した値になりません。

この現象の鍵は、counter += 1という処理がアトミック(不可分)な操作ではないことにあります。この1行のコードは、内部的に少なくとも以下の3ステップのバイトコード命令に分解されて実行されます。

  1. 読み出し:メモリからcounterの現在の値を読み出す
  2. 変更:読み込んだ値に1を加算する
  3. 書き戻し:計算結果をcounterに書き戻す

Free-threadedビルドでは、複数のスレッドが文字通り同時に別々のCPUコアで実行されるため、この3ステップの途中でスレッドの実行が割り込まれる可能性が非常に高くなります。その結果、以下のような更新の消失(Lost Update)が頻繁に発生します。

  1. スレッドAがcounterの値100を読み出す
  2. ほぼ同時に、スレッドBもcounterの値100を読み出す
  3. スレッドAが100 + 1を計算し、結果の101counterに書き戻す
  4. スレッドBも100 + 1を計算し、結果の101counterに書き戻す(スレッドAの更新を上書き)

このシナリオでは、2回インクリメントしたにもかかわらず、counterの値は1しか増えていません。これが、最終的な結果が期待していた値より遥かに小さくなる原因です。

では、なぜGILありビルドでは問題が起きなかったのでしょうか。GILは、一度に1つのスレッドしかPythonバイトコードを実行できないようにする仕組みです。counter += 1に対応する一連のバイトコードは非常に短いため、CPythonのスケジューラがスレッドを切り替える(デフォルトで5ミリ秒ごと)前に読み出しから書き戻しまでの一連の処理が完了してしまうことがほとんどです。これにより、競合状態が発生する可能性が極めて低くなり、問題が表面化していなかったのです。

disモジュールによる逆アセンブル disモジュールを利用することで、Pythonバイトコードを逆アセンブルして分析できるようになります。

docs.python.org

import dis

counter = 0

def f() -> None:
    global counter
    counter += 1

if __name__ == "__main__":
    dis.dis(f)
$ python disassemble.py
  5           RESUME                   0

  7           LOAD_GLOBAL              0 (counter)
              LOAD_SMALL_INT           1
              BINARY_OP               13 (+=)
              STORE_GLOBAL             0 (counter)
              LOAD_CONST               1 (None)
              RETURN_VALUE

LOAD_GLOBAL(読み出し)、BINARY_OP(変更)、STORE_GLOBAL(書き戻し)という複数の命令に分かれていることが明確に分かります。

この問題を解決するには、counterへのアクセスを一度に1つのスレッドに限定する排他制御が必要です。これにはthreading.Lockを使います。

+ lock = threading.Lock()

def worker(iterations: int) -> None:
    global counter
    for _ in range(iterations):
-         counter += 1
+         with lock:
+             counter += 1

with lock:で囲まれたコードブロックは、あるスレッドがこのブロックを実行している間、他のスレッドはブロックの入り口で待機させられます。これにより、読み出し・変更・書き戻しの一連の処理が中断されることなく安全に実行され、Free-threadedビルドでも常に期待通りの結果が得られます。

6-2. デッドロック(Deadlock)

競合状態と並んで、マルチスレッドプログラミングで注意すべきもう一つの典型的な問題がデッドロックです。デッドロックとは、2つ以上のスレッドが互いに相手が保持しているリソース(この場合はロック)の解放を待ち続けて、永遠に処理が進まなくなる状態を指します。

以下のコードは、2つのスレッドが2つのロックabを、それぞれ異なる順序で取得しようとするプログラムです。

import threading
import time

a = threading.Lock()
b = threading.Lock()

def worker1() -> None:
    with a:
        print("thread 1 got lock a")
        time.sleep(0.1)
        with b:
            print("thread 1 got lock b")

def worker2() -> None:
    with b:
        print("thread 2 got lock b")
        time.sleep(0.1)
        with a:
            print("thread 2 got lock a")

if __name__ == "__main__":
    t1 = threading.Thread(target=worker1)
    t2 = threading.Thread(target=worker2)

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print("all threads have finished")

このコードを実行すると、プログラムは途中で停止(ハング)してしまいます。

$ python deadlock.py
thread 1 got lock a
thread 2 got lock b

このプログラムは、以下のシナリオでデッドロックに陥ります。

  1. スレッド1がロックaを取得する
  2. ほぼ同時に、スレッド2がロックbを取得する
  3. スレッド1は次にロックbを取得しようとするが、スレッド2によって保持されているため待機状態になる
  4. スレッド2は次にロックaを取得しようとするが、スレッド1によって保持されているため待機状態になる

この結果、スレッド1はスレッド2を、スレッド2はスレッド1を永遠に待ち続ける循環待ち(Circular Wait)状態になり、プログラムが停止してしまうのです。

競合状態とは異なり、デッドロックはGILの有無に直接関係なく発生し得る、マルチスレッドにおける普遍的な問題です。しかし、Free-threaded Pythonによってthreadingの利用範囲がCPUバウンドなタスクにも広がることで、これまで以上に身近な課題となります。

デッドロックを回避するための最も確実で一般的な方法は、複数のロックを取得する際の順序を全てのスレッドで統一することです。これにより、循環待ちの発生を防ぐことができます。

以下の例では、worker2worker1と同じく、必ずロックabの順で取得するように修正します。

def worker2() -> None:
-     with b:
-         print("thread 2 got lock b")
+     with a:
+         print("thread 2 got lock a")
        time.sleep(0.1)
-         with a:
-             print("thread 2 got lock a")
+         with b:
+             print("thread 2 got lock b")
$ python deadlock.py
thread 1 got lock a
thread 1 got lock b
thread 2 got lock a
thread 2 got lock b
all threads have finished

全てのスレッドが同じ順序でロックを取得しようとするため、どちらかのスレッドが先に両方のロックを獲得し、もう一方が待機します。処理が終わればロックが解放され、待っていたスレッドが処理を再開できるため、デッドロックは発生しません。

ロックの取得順序を厳密に管理するのが難しい複雑なケースでは、ロック取得処理にタイムアウトを設ける方法も有効です。Lock.acquire()メソッドは、ブロッキングする場合にtimeout引数を指定できます。

def worker2() -> None:
-     with b:
-         print("thread 2 got lock b")
-         time.sleep(0.1)
-         with a:
-             print("thread 2 got lock a")
+     while True:
+         if b.acquire():
+             print("thread 2 got lock b")
+             time.sleep(0.1)
+             if a.acquire(timeout=1):
+                 print("thread 2 got lock a")
+                 a.release()
+                 b.release()
+                 break
+             else:
+                 print("thread 2 failed to get lock a, retrying...")
+                 b.release()
+                 time.sleep(0.1)
thread 1 got lock a
thread 2 got lock b
thread 2 failed to get lock a, retrying...
thread 1 got lock b
thread 2 got lock b
thread 2 got lock a
all threads have finished

このコードでは、worker2はロックaの取得を1秒だけ試みます。もし取得できなければacquire()Falseを返すため、一度保持しているロックbを解放し、少し待ってから再試行します。このようにリソースを一時的に手放すことで、もう一方のスレッドに処理を進める機会を与え、デッドロックを能動的に解消することができます。

まとめ

本記事では、Python 3.14 で正式にサポートされたFree-threaded Pythonについて、並行処理と並列処理の基礎からmultiprocessingとの性能比較、プロファイリング手法、Federated Learningへの応用事例、そしてthreading特有の注意点に至るまで、網羅的に解説しました。

Free-threaded Pythonの登場は、「CPUバウンドタスクにはmultiprocessing一択」という長年の常識を覆し、軽量性と真の並列性を両立したthreadingをシンプルかつ強力な選択肢として確立しました。

この変更は、機械学習やデータ分析といった分野だけでなく、あらゆるPythonistaがマルチコアCPUの恩恵を容易に受けられるようになることを意味します。まだオプション機能ではありますが、将来的なデフォルト化も見据えられている今、この新しい並列処理を探求することは全ての開発者にとって大きな価値があると考えています。

ぜひ本記事で紹介したコードを試しながら、自分のユースケースにどう活かせるかを探ってみてください。

KubeflowによるNaaS構築入門(#16)

Audio Overview(English/日本語) English (recommended)

日本語(非推奨)

はじめに

コードを迅速にプロトタイピングしたい時やデータを可視化して共有したい時に、手軽に利用できるGoogle Colabのようなサービスをself-hostedで提供したいと考えています。

特に研究室サーバーではホストマシン上にPythonがインストールされておらず、JupyterLabを利用したい場合はDockerコンテナを起動する必要があります。

環境の分離という観点では正しい運用ですが、設定の煩わしさからGoogle Colabに勝るユーザー体験は提供できていません。Docker / Podmanに慣れていないメンバーや研究室に新規参入したメンバーでも研究活動をbootstrapできるようなプラットフォームが理想的です。

本記事では、Kubeflow Notebooksを利用してオンプレミスKubernetesクラスタにNotebook as a Serviceを構築する方法ユーザー体験を向上させるための工夫について紹介します。

1. Kubeflow Notebooksの概要

Kubeflowは、Kubernetes上でAI/MLを簡単に、柔軟に、そして拡張可能に実行するための、機械学習ライフサイクル全体をサポートするオープンソースのプロジェクト群です。

Kubeflow NotebooksはそんなKubeflowコンポーネントの1つで、Webベースの開発環境を提供できます。

www.kubeflow.org

Kubeflow NotebooksのWeb UIのスクリーンショット

実際にはKubernetesクラスタ上でPodを起動していますが、以下の特徴があります。

  • JupyterLabRStudiocode-serverのネイティブサポート
  • ユーザーがクラスタ上で直接ノートブックコンテナを作成可能
  • 管理者がカスタムノートブックイメージを提供できる
  • アクセス制御をKubeflowのRBACで管理できるためノートブックの共有が容易

特にブラウザ上でVS Codeが利用できるcode-serverをサポートしている点や、必要なパッケージがプリインストールされたカスタムイメージを利用できる点が魅力的だと感じました。

2. JupyterHubとの比較

JupyterHubは、シングルユーザー用Jupyter notebook(またはJupyterLab)サーバーを複数インスタンス起動・管理・プロキシする機能を提供することで、マルチユーザーをサポートしています。

JupyterHubをデプロイする方法としては、以下の2種類が用意されています。

公式ドキュメントによると、TLJHは単一マシンでコンテナを利用しない場合、Z2JHは複数マシンでコンテナを利用する場合の利用が推奨されています。

Kubeflow NotebooksとZ2JHを比較します。

Kubeflow Notebooks JupyterHub (Z2JH)
アーキテクチャ Notebook ControllerNotebookカスタムリソースの作成をトリガーにStatefulSet / Service / VirtualServiceを作成 HubがKubespawnerを利用してシングルユーザー用のノートブックサーバーをPodとして起動し、Configurable HTTP Proxyがルーティングテーブルを更新
認証 Istio Ingress Gateway + OAuth2-Proxy (OIDC client) + Dex (OIDC IdP) Ingress + Configurable HTTP Proxy + Authenticator
IDEサポート JupyterLab、RStudio、code-serverをネイティブサポート JupyterLabとJupyter notebookを標準サポート(Jupyter Server ProxyでRStudioやcode-serverも利用可能)
カスタムイメージ ユーザーがノートブック作成時にイメージ名を指定可能 ユーザーが選択可能なプロファイルを管理者がKubeSpawnerの設定として定義
リソース指定 ユーザーがノートブック作成時にCPU、メモリ、GPU、ボリュームを指定可能

JupyterHub(Z2JH)はHelmで容易にインストールできますが、ユーザーが自由にリソースやカスタムイメージを指定できないことや、ノードメンテナンス時にPodが自動で再作成されないことに注意が必要です。

Kubeflow NotebooksはKubeflow Platformのコンポーネントが多いため学習コストが大きくなりますが、StatefulSetの恩恵を受けつつ、Web UIでユーザーが自由にリソース・カスタムイメージを指定できます。

以上の理由から、今回はNaaSユーザーにとってカスタマイズ性が高いKubeflow Notebooksを採用しました。

3. Kubeflowのインストール

#14で構築したオンプレミスKubernetesクラスタにKubeflow Notebooksをインストールします。

www.kubeflow.org

Kubeflow Notebooksはstandaloneコンポーネントとしてインストールできないので、Kubeflow Platformとして全てのコンポーネントをインストールします。

github.com

執筆時点で最新の安定版であるKubeflow Platform v1.10.0を利用します。コンポーネント数が多いため、READMEの通りにシングルコマンドによるインストールを実行しました。

$ while ! kustomize build example | kubectl apply --server-side --force-conflicts -f -; do echo "Retrying to apply resources"; sleep 20; done
# 省略

インストール完了後に2つの問題が発生しました。

1つ目の問題として、Istioを利用して公開していたArgo CDなどのサービスがRBAC: access deniedエラーを返すようになりました。最初は2つのIngress Gatewayがデプロイされていることが原因だと考えていましたが、Tetrateのドキュメントにもあるように複数のIngress Gatewayをデプロイすることは可能です。

権限エラーであることからも分かるように、実際はKubeflow Platformのマニフェストallow-nothingAuthorizationPolicyを設定していることが原因でした。Kubeflow Platform用のIngress Gatewayだけでなく、既存のIngress Gatewayへのトラフィックも許可するALLOWポリシーを追加する必要があります。

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: istio-ingressgateway-private
  namespace: istio-system
spec:
  action: ALLOW
  selector:
    matchLabels:
      istio: ingressgateway-private  # 適宜変更
  rules:
  - {}

これによって1つ目の問題は解決しました。

2つ目の問題として、Kubeflow関連のいくつかのPodでコンテナの作成に失敗していました。

$ kubectl get pods -n kubeflow | awk '$3!="Running"'
NAME                                                     READY   STATUS             RESTARTS          AGE
katib-db-manager-6987b965b7-tfnwp                        0/1     Error              193 (6m24s ago)   15h
katib-mysql-5dfcbbc87f-xjnkb                             0/1     Pending            0                 15h
metadata-grpc-deployment-6c44975f56-f2ffs                1/2     CrashLoopBackOff   190 (2m32s ago)   15h
metadata-writer-6fbc8d8c4f-x4m8b                         1/2     CrashLoopBackOff   143 (39s ago)     15h
minio-6748f5ff9d-5xslh                                   0/2     Pending            0                 15h
ml-pipeline-857d4dd86-qggb6                              1/2     CrashLoopBackOff   238 (4m5s ago)    15h
mysql-6c6bb95f89-89r29                                   0/2     Pending            0                 15h

CLIでの原因調査

$ kubectl logs ml-pipeline-857d4dd86-qggb6 -n kubeflow
I0504 06:39:26.476455       7 client_manager.go:170] Initializing client manager
I0504 06:39:26.476614       7 client_manager.go:171] Initializing DB client...
I0504 06:39:26.476660       7 config.go:57] Config DBConfig.MySQLConfig.ExtraParams not specified, skipping
[mysql] 2025/05/04 06:39:26 connection.go:49: unexpected EOF
# 省略

$ kubectl describe pod mysql-6c6bb95f89-89r29 -n kubeflow
# 省略
  Warning  FailedScheduling  2m30s (x202 over 16h)  default-scheduler  0/3 nodes are available: pod has unbound immediate PersistentVolumeClaims. preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.

$ kubectl get pvc -n kubeflow
NAME             STATUS    VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
katib-mysql      Pending                                                     <unset>                 16h
minio-pvc        Pending                                                     <unset>                 16h
mysql-pv-claim   Pending                                                     <unset>                 16h

$ kubectl describe pvc mysql-pv-claim -n kubeflow
# 省略
  Normal  FailedBinding  100s (x4002 over 16h)  persistentvolume-controller  no persistent volumes available for this claim and no storage class is set

$ kubectl get pv
No resources found

$ kubectl get sc
No resources found

PersistentVolumeClaim(PVC)がPersistentVolume(PV)をバインドできず、MySQLやMinIOなどのPodがPending状態のままでした。そのため、これらのストレージに依存するサービスで接続エラーが発生していました。

再確認したところ、READMEのPrerequisitesのうち以下の項目を見落としていました。

ボリュームタイプとしては、すぐに用意できるhostPathlocalを初期候補として検討しました。ただし、手動でPVを個別作成する手間を省くため以下のProvisionerを導入しました。

github.com

Local Path Provisionerによって、PVCに対応したPVがノード上に自動的に作成されます。

今回のPVCのマニフェストにはstorageClassNameが指定されていないため、ターゲットとなるStorageClassをデフォルトに指定することで全てのPodが無事にRunning状態となりました。

$ kubectl get sc
NAME         PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  7h29m

$ kubectl patch storageclass local-path -p '{"metadata": {"annotations":{"storageclass.kubernetes.io/is-default-class":"true"}}}'
storageclass.storage.k8s.io/local-path patched

$ kubectl get pvc -n kubeflow
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
katib-mysql      Bound    pvc-fee92883-d2ef-451c-aa71-c432e935703f   10Gi       RWO            local-path     <unset>                 17h
minio-pvc        Bound    pvc-39c3a5ee-6e29-44ca-b689-820f437c782d   20Gi       RWO            local-path     <unset>                 17h
mysql-pv-claim   Bound    pvc-c8423a2c-e2a6-42ce-a05f-a55bf42f82f9   20Gi       RWO            local-path     <unset>                 17h

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                     STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-39c3a5ee-6e29-44ca-b689-820f437c782d   20Gi       RWO            Delete           Bound    kubeflow/minio-pvc        local-path     <unset>                          2m1s
pvc-c8423a2c-e2a6-42ce-a05f-a55bf42f82f9   20Gi       RWO            Delete           Bound    kubeflow/mysql-pv-claim   local-path     <unset>                          2m2s
pvc-fee92883-d2ef-451c-aa71-c432e935703f   10Gi       RWO            Delete           Bound    kubeflow/katib-mysql      local-path     <unset>                          119s

$ kubectl get pods -n kubeflow | awk '$3!="Running"'
NAME                                                     READY   STATUS    RESTARTS          AGE

これによって2つ目の問題も解決し、無事にKubeflow Platformのインストールが完了しました。

4. Kubeflow Notebooksの動作検証

Web UI上でKubeflow Notebooksの動作検証を行います。

ただしREADMEで言及されている通り、localhost以外ではHTTPでKubeflowにアクセスできません。別のIngress経由でistio-ingressgatewayサービスをHTTP公開する方法もありますが、6章でサービス公開のセットアップをする予定なのでしばらくはkubectl portforwardコマンドを検証用に利用します。

普段コントロールプレーンノード(以下の例ではpolaris)にSSH接続して作業している場合は、SSHのローカルポートフォワーディングと組み合わせて利用できます。

azuma@macbook
$ ssh -L 8080:localhost:8080 polaris

root@polaris
$ kubectl port-forward svc/istio-ingressgateway -n istio-system 8080:80
Forwarding from 127.0.0.1:8080 -> 8080
Forwarding from [::1]:8080 -> 8080

MacBookWebブラウザからlocalhost:8080でKubeflowのWeb UIにアクセスできます。

www.kubeflow.org

最新版とスクリーンショットが少し異なっていますが、概ね上記の公式ドキュメント通りにノートブックを作成できます。

最もシンプルな設定として、GPUもWorkspace Volumeも無いJupyterLabノートブックを作成します。

ノートブックの一覧画面

ノートブック接続後の画面

LauncherからPython 3 (ipykernel)を選択して、JupyterLabが問題なく利用できることを確認しました。

次に、PVCをボリュームとして利用できることを検証します。

Web UI上でWorkspace VolumeとData Volumesを設定

ノートブックを作成すると、PVCに対応したPVがLocal Path Provisionerによって自動的に作成されていることが分かります。

$ kubectl get pvc -n kubeflow-user-example-com
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
test-datavol-1   Bound    pvc-c2b5305e-217d-4134-b78a-3b246a07060b   5Gi        RWO            local-path     <unset>                 3m46s
test-workspace   Bound    pvc-bc09bf68-dc42-4d94-8d50-82fba5d1adeb   5Gi        RWO            local-path     <unset>                 3m46s

$ kubectl get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                                      STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-bc09bf68-dc42-4d94-8d50-82fba5d1adeb   5Gi        RWO            Delete           Bound    kubeflow-user-example-com/test-workspace   local-path     <unset>                          3m53s
pvc-c2b5305e-217d-4134-b78a-3b246a07060b   5Gi        RWO            Delete           Bound    kubeflow-user-example-com/test-datavol-1   local-path     <unset>                          3m53s
# 省略

Web UI上で設定した通りのパスにWorkspace VolumeとData Volumesがそれぞれマウントされていることをノートブックからも確認できました。

最後に、GPUが利用できることを検証します。

WebUI上でGPUsを設定

カスタムイメージとしてjupyter-pytorch-cuda-full:v1.10.0を選択してノートブックを作成すると、以下のようなエラーが出てコンテナが作成できませんでした。

RunContainerError: failed to create containerd task: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: error during container init: error running prestart hook #0: exit status 1, stdout: , stderr: Auto-detected mode as 'legacy' nvidia-container-cli: requirement error: unsatisfied condition: cuda>=12.4, please update your driver to a newer version, or use an earlier cuda container: unknown

jupyter-pytorch-cudaはDockerfile内でCUDA 12.4に対応するPyTorchをインストールしており、cuda>=12.4を要求しています。一方、全てのワーカーノードでDriverバージョンは535.216.03であり、CUDA 12.4には>=550.54.14が必要です(参考)。

解決策として、新しいバージョンのDriverにアップデートするか、古いCUDAコンテナを利用するかの2通りの方法が考えられます。前者はノードメンテナンスが必要となり、他のメンバーの研究を中断してしまうため、今回は後者で対応することにしました。

Kubeflowのv1.9Dockerfileでは要求がcuda>=12.1になっていたため、コンテナイメージのタグを変更することにしました。マニフェストspawner_ui_config.yamlというUIの設定ファイルがあるので以下のように修正して差分を適用します。

  image:
    # the default container image
-    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.10.0
+    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0

    # the list of available container images in the dropdown
    options:
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-cuda-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-gaudi-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-full:v1.10.0
-    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-cuda-full:v1.10.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-cuda-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-full:v1.9.0
+    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-tensorflow-cuda-full:v1.9.0

ついでにlogos-configmap.yamlで、VSCodeRStudioのアイコン・ロゴも追加するとUXが改善します。

コンテナイメージを変更して、ロゴまで追加した結果

jupyter-pytorch-cuda-full:v1.9.0コンテナイメージを使ってGPUを2枚に設定したJupyterLabノートブックを作成したところ、無事にGPUが利用可能であることを確認できました。

PyTorchでGPUが利用できることを確認

以上でKubeflow Notebooksの動作検証は完了です。

5. marimoカスタムイメージの利用

#13でも紹介しているように、最近はJupyter notebookやJupyterLabの課題を解決するPythonノートブックとして再現性重視でGitフレンドリーなmarimoが注目されています。

marimo.io

Jupyter notebook、JupyterLab、marimoのGitHubスター数の比較(リンク

Kubeflow Notebooksでも選択肢の1つとしてmarimoを利用できるようにカスタムイメージを作成します。

www.kubeflow.org

上記のKubeflow公式ドキュメントやREADMEによると、Common Base Imageを拡張したBaseイメージ(JupyterLab、code-server、RStudio)と、主にJupyterLabに対してPyTorchやTensorflowなどのパッケージをインストールして拡張したKubeflowイメージが提供されています。

一方、カスタムイメージを作成する際にはBaseイメージの拡張が推奨されており、以下の3つの要件を満たす必要があります。

  • HTTPインターフェースをポート8888で公開
  • jovyanと呼ばれるユーザーで実行
  • /home/jovyanにマウントされた空のPVCで正常に起動

Common Base Imageを拡張している他のBaseイメージを参考にしてmarimoのカスタムイメージを作成しました。変更は以下のPRから確認できます。

github.com

Kubeflowにコントリビュートするチャンスですが、イメージ追加以外にも修正が必要なので一旦保留としました。具体的にはフロントエンドとバックエンドで、JupyterLab・code-server・RStudioの3種類がそれぞれjupytergroup-onegroup-twoとしてハードコーディングされており、フォームの送信やIstioによるURI書き換えの際に必要となります。参考程度に影響のあるファイルをいくつか紹介します。

marimoのアイコンをWeb UI上に表示して利用できるようにするには手間がかかりそうですが、イメージを利用するだけであれば2通りの方法があります。1つはフォーム上でCustomイメージとしてイメージ名を入力する方法、もう1つはJupyterLabのイメージグループに追加する方法です。本質的にそれほど大きな差はないので、ユーザー体験を考慮して後者の方法を採用します。

4章に続けて、マニフェストspawner_ui_config.yamlという設定ファイルを変更して適用します。

  ################################################################
  # Jupyter-like Container Images
  #
  # NOTES:
  #  - the `image` section is used for "Jupyter-like" apps whose
  #    HTTP path is configured by the "NB_PREFIX" environment variable
  ################################################################
  image:
    # the default container image
    value: ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-pytorch-full:v1.9.0

    # the list of available container images in the dropdown
    options:
    - ghcr.io/kubeflow/kubeflow/notebook-servers/jupyter-scipy:v1.9.0
    # 省略
+    - ghcr.io/kitsuyaazuma/kubeflow/notebook-servers/marimo:latest

Package kubeflow/notebook-servers/marimo · GitHub

marimoのカスタムイメージは上記のGitHubコンテナレジストリで公開しています。

JupyterLabのイメージグループの中からmarimoイメージを選択

ノートブックに接続してmarimoを利用

また、提供されているjupyter-pytorchjupyter-pytorch-cuda-fullなどのイメージが全てCondaを採用しているのに対し、marimoカスタムイメージではuvを採用しているためユーザー体験の向上が期待できます。

以上で、作成したmarimoのカスタムイメージをKubeflow Notebooksから利用できることを確認できました。

6. HTTPSによるサービス公開

4章でも言及しましたが、KubeflowではWebアプリでSecure Cookieを利用しているため、HTTPSのセットアップが推奨されています。KubeflowのサービスをHTTPSで公開するため、マニフェストリポジトリREADMEでも紹介されているようにIngressでIstio Ingress Gatewayを公開しながらTLS終端を行います。

Ingressの役割については以下のKubernetes公式ドキュメントが参考になります。

kubernetes.io

まずはNGINX Ingress Controllerをインストールします。

github.com

次にMetalLBをインストールして、NGINX Ingress ControllerをLoadBalancerサービスとして公開しましたが、NodePortを利用する場合は適宜読み替えてください。

(任意)MetalLBのインストール MetalLBのインストールすることで、type: LoadBalancerなサービスに外部IPアドレスを割り当てることができます。

kubernetes.github.io

metallb.io

公式ドキュメントに従ってMetalLBをインストールした後、IPAddressPoolL2Advertisementという2つのカスタムリソースを作成します。

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: example-ipaddresspool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.1.XXX-192.168.1.YYY
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: example-l2advertisement
  namespace: metallb-system
spec:
  ipAddressPools:
    - example-ipaddresspool

次に、手動で自己証明書を作成してSecretリソースを作成します。今回はローカルで信頼される証明書を発行できるmkcertを利用しました。

$ mkcert -install
The local CA is now installed in the system trust store! ⚡️

$ mkcert nlab.com
Created a new certificate valid for the following names 📜
 - "nlab.com"
The certificate is at "./nlab.com.pem" and the key at "./nlab.com-key.pem" ✅
It will expire on 16 August 2027 🗓

$ kubectl create -n ingress-nginx secret tls tls-secret \
  --key=nlab.com-key.pem \
  --cert=nlab.com.pem
secret/tls-secret created

Ingressリソースを作成してspec.tlsフィールドでTLS終端の設定を行います。

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: example-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: nlab.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: example-service
                port:
                  number: 80
  tls:
    - hosts:
        - nlab.com
      secretName: tls-secret
---
# https://kubernetes.io/docs/concepts/services-networking/service/#externalname
apiVersion: v1
kind: Service
metadata:
  name: example-service
spec:
  type: ExternalName
  externalName: istio-ingressgateway.istio-system.svc.cluster.local
  ports:
    - port: 80

Ingressリソースを確認すると、MetalLBによって外部IPアドレスが割り当てられていることが分かります。

$ kubectl get ingress -n ingress-nginx
NAME              CLASS   HOSTS      ADDRESS         PORTS     AGE
example-ingress   nginx   nlab.com   192.168.1.XXX   80, 443   6m9s

外部IPアドレスは割り当てられましたが名前解決はできていないので、MacBook/etc/hosts192.168.1.XXX nlab.comのように追記してください。将来的には(Cloudflare DNSなどを利用する)ExternalDNSを導入することで、ユーザーの負担を軽減したいです。

アドレスバーにNot Secureという警告が出る場合

ブラウザからアクセスすると表示される警告画面

MacBookの場合、ルート証明書をダウンロードしてからKeychain Accessで信頼されたルート証明書として設定します。

root@polaris
$ mkcert -CAROOT
/root/.local/share/mkcert

azuma@macbook
$ scp polaris:/root/.local/share/mkcert/rootCA.pem .
$ open rootCA.pem

追加したルート証明書をダブルクリックする

Always Trustを選択する

https://{ドメイン名}でKubeflowにアクセス

以上で、KubeflowのサービスをHTTPSで公開することができました。

7. DexによるGitHub認証

これまではKubeflow Central Dashboardにアクセスすると、Dexのログイン画面が起動してデフォルトのユーザー名user@example.comとパスワード12341234でログインしていました。しかし実際に運用する場合は、たとえ研究室内であってもデフォルトユーザーを全員で使い回すことはあり得ません。

Dexはユーザーを他のIdPで認証するためのConnectorを実装しており、LDAPやOIDC、OAuth 2.0といったプロトコルベースのものから、GitHubGoogleMicrosoftのようなプラットフォーム固有のものまでサポートしています。詳細は公式ドキュメントを参照してください。

今回は、既に研究室のGitHub Organizationが存在していて大多数のメンバーが参加していることから、GitHub認証を利用することにしました。

dexidp.io

READMEと上記の公式ドキュメントに従って、マニフェストconfig-map.yamlを以下のように変更しました。

apiVersion: v1
kind: ConfigMap
metadata:
  name: dex
data:
  config.yaml: |
-    issuer: http://dex.auth.svc.cluster.local:5556/dex
+    issuer: https://nlab.com/dex
    # 省略
    staticClients:
    # https://github.com/dexidp/dex/pull/1664
    - idEnv: OIDC_CLIENT_ID
      redirectURIs: ["/oauth2/callback"]
      name: 'Dex Login Application'
      secretEnv: OIDC_CLIENT_SECRET
+    connectors:
+    - type: github
+      id: github
+      name: GitHub
+      config:
+        clientID: $GITHUB_CLIENT_ID
+        clientSecret: $GITHUB_CLIENT_SECRET
+        redirectURI: https://nlab.com/dex/callback
+        loadAllGroups: false
+        teamNameField: slug
+        useLoginAsID: false

$GITHUB_CLIENT_ID$GITHUB_CLIENT_SECRETは適宜置き換えてください。GitHub OrganizationのDeveloper Settingsから新しいOAuth Appを作成する必要があります。Authorization callback URLは上記のConfigMapのredirectURIと一致させる必要があります。

OAuth Appの設定例(Homepage URLは任意のURLで可)

Kubeflowの認証をカスタムする技術記事はほとんど無く、DexでConnectorを追加した場合に修正すべきマニフェストはREADMEにも記載されていません。今回はKeyCloak Integration Guideを参考にしながら、以下のようにOAuth2 ProxyとIstio Request Authenticationの設定を変更しました。

(差分)common/oauth2-proxy/base/oauth2_proxy.cfg

 provider = "oidc"
-oidc_issuer_url = "http://dex.auth.svc.cluster.local:5556/dex"
+oidc_issuer_url = "https://nlab.com/dex"
 scope = "profile email groups openid"

(差分)common/oauth2-proxy/components/istio-external-auth/requestauthentication.dex-jwt.yaml

   jwtRules:
-  - issuer: http://dex.auth.svc.cluster.local:5556/dex
+  - issuer: https://nlab.com/dex
+    jwksUri: http://dex.auth.svc.cluster.local:5556/dex/keys

IstioのRequest AuthenticationはデフォルトでIssuerの/.well-known/openid-configurationから公開鍵を取得しますが、ブラウザと違ってクラスター内のPodからはnlab.comが名前解決ができないので、明示的にjwksUriフィールドをdex.auth.svc.cluster.localと指定しなければいけない点がポイントでした。

Kubeflowの認証フロー(引用:kubeflow/manifests
v1.10は図中の「kubeflow 1.9 with oauth2 proxy」に該当

仕組みとしては、ユーザーがブラウザからKubeflowにアクセスするとIstioのEnvoyに到達し、ext_authzフィルターによってOAuth2 Proxyにリクエストが転送されます。OAuth2 ProxyはセッションCookieoauth2_proxy_kubeflow)を検証して、未認証であればDexを介して上流IdP(GitHubなど)とOIDC / OAuth2フローを実行してトークンレスポンス(IDトークンやリフレッシュトークンを含む)を取得します。OAuth2 ProxyはIDトークンをもとにCookieを発行し、以降のリクエストではそのCookieの検証を行います。最終的にはOAuth2 Proxyが付与する認証済みヘッダーをEnvoyが受け取り正当なユーザーとしてバックエンドのサービスへリクエストを通過させます。

GitHub認証でパスキーを利用して簡単にKubeflowにログイン

現時点でユーザーはログインしてもProfileがないためノートブックを作成できません。ProfileカスタムリソースはNamespaceをラップしたもので、1人のユーザーがProfileを所有しますが、コントリビューターに権限を付与することもできます。

www.kubeflow.org

公式ドキュメントに従って環境変数の値を変更し、デフォルトで無効化されているProfileの自動作成を有効化します。再度新しいユーザーでログインすると、Namespaceの作成画面が表示されるはずです。

デフォルトでユーザー名のNamespaceを作成

$ kubectl get namespace | grep kitsuyaazuma
kitsuyaazuma                Active   64s

GitHub認証を利用することで、DexのBuilt-In ConnectorでのStaticなユーザー管理を必要とせず、マルチユーザーにNaaSを提供できるようになりました。

8. ExternalDNSによるCloudflare DNSの利用

6章では、ドメイン名(自己証明書を作成したnlab.com)を利用してKubeflowダッシュボードにHTTPSでアクセスできるようになりました。しかしユーザー視点では、名前解決のために/etc/hostsでホスト名とIPアドレスを関連付けなければならず、ブラウザの警告を消すために手動でルート証明書を信頼しなければなりません。

これらの問題を一気に解決するために、ExternalDNSを利用してCloudflare DNSのレコードを動的に更新しつつ、証明書管理も自動化します。

github.com

事前にCloudflareでドメインを登録して、サイドバーの「アカウントの管理」からAPIトークンを発行する必要があります。下記の公式チュートリアルに取り組んで、NginxにHTTPでアクセスできることを確認しましょう。注意点として、cert-managerが既にデフォルトでインストールした場合はAPIトークンのSecretをcert-manager Namespaceに作成しておきましょう(参考)。

github.com

チュートリアルのサービスにHTTPでアクセスできるかを確認

次に、cert-managerで証明書管理を自動化します。今回は、ClusterIssuerでACME認証局(例:Let's Encrypt)とのチャレンジ方式(例:DNS-01チャレンジ)を設定し、Certificateで証明書を発行するDNS名や保存するSecret名、参照するIssuerなどを設定します。

apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    email: YOUR_EMAIL
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: issuer-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-key
              key: apiKey
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: example-com
  namespace: ingress-nginx
spec:
  dnsNames:
  - YOUR_DOMAIN_NAME
  issuerRef:
    group: cert-manager.io
    kind: ClusterIssuer
    name: letsencrypt
  secretName: example-com-tls

これに合わせて、Ingressrulestlsフィールド、7章で変更したKubeflow認証のissuerフィールド、GitHub OAuth Appのcallback URLを変更する必要があります。YOUR_DOMAIN_NAMEは研究室ホームページに利用したいという要望があったので、このタイミングでサブドメインkubeflow.YOUR_DOMAIN_NAMEを利用する方針に変更しました。

https://kubeflow.{ドメイン名}でKubeflowにアクセス

以上で、動的なDNSレコード更新と証明書管理の自動化を達成することができました。

まとめ

Kubeflow Notebooksを利用して研究室サーバーにNotebook as a Serviceを構築する方法を紹介しました。

執筆時点でこのNaaSをα版としてリリースしており、一部の研究室メンバーに利用して頂くことができました。今年度のうちにβ版リリース、そしてGAを目指します。また、機能の追加だけでなくドキュメントの整備も含めた総合的なユーザー体験の向上に努めていきます。

今後の課題としては以下の候補が挙げられます。

  • 直感的にBaseイメージを拡張してカスタムイメージをpushできるワークフローの構築
  • HarborDistributionによるプライベートレジストリのデプロイ
  • NFSボリュームの利用と動的プロビジョニング

Kubeflow Platformのコンポーネントは非常に多く、JupyterHub(Z2JH)と比較して学習コストがかかります。しかしKubeflowはKubernetesネイティブなアーキテクチャであるため、今まで触れてこなかった技術(私の場合はIstioやOIDC、TLS)に向き合う非常に良いチャンスになります。本記事が、社内のデータプラットフォームや逸般の研究室サーバーで参考になれば幸いです。

【参考】CloudNative Days 2025にて『研究室サーバーとKubeflowで実践するNotebook as a Service』というテーマでLT発表しました。

Kubernetesネイティブな監視基盤への移行(#15)

はじめに

#5では、研究室サーバーの監視基盤としてPrometheus、Grafana、Alertmanagerを導入しました。この基盤で半年ほど問題なく運用できており、現在Grafanaダッシュボードは研究室の他のメンバーにも利用されています。

当時は引き継ぎやバージョンアップのコストを考慮して、各コンポーネントをバイナリインストール(Linuxサービス起動)ではなく、Dockerコンテナとして起動する意思決定を行いました。しかし、この方法でも依然として手動でのバージョンアップが必要であり、決してメンテナンス性が高いとは言えません。

一方、#14では研究室サーバーでオンプレミスのKuberentesクラスタを構築しました。本記事では、Prometheus Operatorなどを利用したKubernetesネイティブな監視基盤に移行する方法とそのメリットを紹介します。

1. Prometheus Operatorとは

Prometheus Operatorを利用することで、PrometheusやAlertmanagerなどをKubernetesネイティブなOperator patternでデプロイ・管理することができます。

github.com

目的として「KubernetesクラスタにおけるPrometheusベースの監視スタックの設定を簡素化し、自動化すること」を掲げていて、主に以下の3つの機能を提供しています。

  1. カスタムリソースの提供
  2. シンプルなデプロイ設定
  3. Prometheusターゲット設定

1つ目について、デプロイされるPrometheusAlertmanagerだけでなく、(prometheus.yamlで指定されるような)ProbeScrapeConfigPrometheusRuleなどといった設定用のカスタムリソースも定義されています。また、Serviceに関連付けられたPodを間接的に指定するServiceMonitorと、Podを直接指定するPodMonitorも特徴的です。

例えばPrometheusというCRDは、scrapeConfigSelectorserviceMonitorSelectorなどのフィールドによって、使用するScrapeConfigServiceMonitorを特定できます。詳細は以下の公式ドキュメントが図解付きで分かりやすいです。

prometheus-operator.dev

2つ目は冒頭で述べた課題を解決するもので、KubernetesのリソースとしてPrometheusのバージョンやレプリカ数などを管理できます。3つ目については、Kubernetesのラベルから監視ターゲットの設定を生成できるため、Prometheusの従来のYAML設定を必要としません。どちらも運用上のメリットが大きいです。

2. Prometheus Operatorのインストール

prometheus-operator.dev

さて、Prometheus Operatorのインストール方法ですが、公式ドキュメントでは3通りの方法が紹介されていました。Prometheus OperatorとCRDのみインストールする方法もこのうちの1つですが、より簡単にデプロイできる方法を検討します。

kube-prometheusはPrometheus Operatorと同じOrganizationによってメンテナンスされています。しかし今回はHelmを利用してインストールしたかったため、kube-prometheus-stackを利用します。どちらもPrometheus Operatorを利用したGrafanaダッシュボード付きのマニフェストです。

kubeadmを利用している場合、いくつか事前準備が必要なので公式ドキュメントをご参照ください。

既にArgo CDを導入してGitOpsを実践しているため、以下のようなマニフェストを用意しました。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: kube-prometheus-stack
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: kube-prometheus-stack
    targetRevision: 70.4.2
    helm:
      valuesObject:
        grafana:
          grafana.ini:
            server:
              root_url: "%(protocol)s://%(domain)s:%(http_port)s/grafana/"
              serve_from_sub_path: true
        prometheus:
          prometheusSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/prometheus"
            routePrefix: /prometheus
        alertmanager:
          alertmanagerSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/alertmanager"
            routePrefix: /alertmanager
  destination:
    server: https://kubernetes.default.svc
    namespace: kube-prometheus-stack
  syncPolicy:
    automated: {}
    syncOptions:
    - CreateNamespace=true
    - ServerSideApply=true

<NodeIP><NodePort>は各自で置き換えてください。

ブラウザからGrafana / Prometheus / Alertmanagerにアクセスするとリダイレクトが発生するため、私たちのように/grafana / /prometheus / /alertmanagerでアクセスさせたい場合はvalues.yamlの値を上書きしてサブパスを指定する必要があります。

公式ドキュメントではPrometheusやAlertmanagerを公開する方法として、NodePortやKuberenetes APIIngressを利用する方法が紹介されていましたが、今回は引き続きIstioのIngress Gatewayを利用することにしました。

Gatewayのマニフェスト

apiVersion: networking.istio.io/v1alpha3
kind: Gateway
metadata:
  name: http-gateway
  namespace: istio-system
spec:
  selector:
    istio: ingressgateway
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "*"

VirtualServiceのマニフェスト

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: kube-prometheus-stack-virtualservice
  namespace: kube-prometheus-stack
spec:
  hosts:
  - "*"
  gateways:
  - istio-system/http-gateway
  http:
  - match:
    - uri:
        prefix: /grafana
    route:
    - destination:
        host: kube-prometheus-stack-grafana
        port:
          number: 80
  - match:
    - uri:
        prefix: /prometheus
    route:
    - destination:
        host: kube-prometheus-stack-prometheus
        port:
          number: 9090
  - match:
    - uri:
        prefix: /alertmanager
    route:
    - destination:
        host: kube-prometheus-stack-alertmanager
        port:
          number: 9093

この設定によってhttp://<NodeIP>:<NodePort>/{grafana,prometheus,alertmanager}のようなパスベースのルーティングを行うことができ、<NodeIP>:<NodePort>さえ分かれば簡単にそれぞれのWeb UIにアクセスできます。

<NodeIP>:<NodePort>/grafanaでGrafanaのWeb UIにアクセス

<NodeIP>:<NodePort>/promethuesでPrometheusのWeb UIにアクセス

<NodeIP>:<NodePort>/alertmanagerでAlertmanagerのWeb UIにアクセス

そうは言ってもやはりドメイン名でアクセスしたいので、近いうちにMetalLBExternalDNSを導入する予定です。

3. NVIDIA GPU Operatorのインストール

お馴染みのNVIDIA/dcgm-exporterを利用してKubernetes上でもGPUメトリクスを監視する予定でしたが、READMEには以下のように書かれていました。

Note: Consider using the NVIDIA GPU Operator rather than DCGM-Exporter directly.

今度はNVIDIA/gpu-operatorのREADMEを確認すると以下のように紹介されています。

These components include the NVIDIA drivers (to enable CUDA), Kubernetes device plugin for GPUs, the NVIDIA Container Runtime, automatic node labelling, DCGM based monitoring and others.

DCGMだけでなく、#14でコンテナからGPUを利用するために導入したNVIDIA/k8s-device-pluginも含まれているようです。さらに、今まで手動でメンテナンスしていたNVIDIA GPU DriverやNVIDIA Container Toolkitも含まれているとのことで(インストール済みなので今回は無効化しますが)恩恵が大きいと感じました。

早速#14で導入したk8s-device-pluginは削除して、gpu-operatorを以下の公式ドキュメントに従ってインストールします。

docs.nvidia.com

注意点として、以下のような差分を一度にSyncしないようにしてください。k8s-device-pluginにもNode Feature Discovery(NFD)が含まれているため、一旦無効化してからgpu-operatorでNFDをインストールする必要があります(公式ドキュメント参照)。

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
-  name: nvidia-device-plugin
+  name: gpu-operator
  namespace: argocd
spec:
  project: default
  source:
-    repoURL: https://nvidia.github.io/k8s-device-plugin
-    chart: nvidia-device-plugin
-    targetRevision: 0.17.1
+    repoURL: https://helm.ngc.nvidia.com/nvidia
+    chart: gpu-operator
+    targetRevision: v25.3.0
    helm:
      parameters:
-        - name: gfd.enabled
-          value: "true"
+        - name: driver.enabled
+          value: "false"
+        - name: toolkit.enabled
+          value: "false"
  destination:
    server: https://kubernetes.default.svc
-    namespace: nvidia-device-plugin
+    namespace: gpu-operator
  syncPolicy:
    automated: {}
    syncOptions:

nvidia-device-plugin-***nvidia-dcgm-exporter-***というPodが起動していることを確認しましょう。

続いて、GPUメトリクスに関してPrometheusとGrafanaのセットアップも行います。

docs.nvidia.com

上記の公式ドキュメントに従うのが最も手っ取り早いです。2章のマニフェストで変更が必要なPrometheus部分だけを示します。

        prometheus:
          prometheusSpec:
            externalUrl: "http://<NodeIP>:<NodePort>/prometheus"
            routePrefix: /prometheus
            serviceMonitorSelectorNilUsesHelmValues: false
            additionalScrapeConfigs:
            - job_name: gpu-metrics
              scrape_interval: 15s
              metrics_path: /metrics
              scheme: http
              kubernetes_sd_configs:
              - role: endpoints
                namespaces:
                  names:
                  - gpu-operator
              relabel_configs:
              - source_labels: [__meta_kubernetes_endpoints_name]
                action: drop
                regex: .*-node-feature-discovery-master
              - source_labels: [__meta_kubernetes_pod_node_name]
                action: replace
                target_label: kubernetes_node
              - source_labels: [__address__,__meta_kubernetes_endpoint_node_name]
                action: replace
                separator: ,
                target_label: instance

relabel_configs:の最後のアイテムは独自に付け加えました。理由はGrafanaダッシュボード上でホスト名による変数フィルタリングを行うためです。NVIDIA DCGM Exporterという人気のあるダッシュボードを利用する場合、フィルタリング変数はinstance(デフォルトは__address__つまり192.168.1.xxx:9400のようなフォーマット)となり、ダッシュボード利用者がワーカーノードのIPアドレスを暗記しなければなりません。

__address____meta_kubernetes_endpoint_node_name(ホスト名)をカンマ区切りで結合した文字列でinstanceラベルを置換することで、さらにダッシュボード側で正規表現を利用して表示用のtextとクエリ用のvalueを抽出できます。

/(?<value>[^,]+),(?<text>.+)/

ただし、これらの変更によって元のinstanceラベルも変更されてしまったのでダッシュボード設定のJSON Modelタブで以下のような書き換えも行う必要があります。

s/instance=~\"${instance}\"/instance=~\"${instance}.*\"/g

お馴染みのホスト名で変数フィルタリングを行う様子

Grafanaダッシュボードは管理者だけでなく研究室メンバーにも普及しているので可能な限り認知負荷を下げるようにダッシュボードをセットアップしました。

まとめ

今回は、Prometheus Operatorなどを利用してKubernetesネイティブな監視基盤に移行する方法を紹介しました。Kubernetes上でメトリクスを収集できましたが、古い監視基盤のAlertmanagerの設定はまだ移行できていないので徐々に進めていきたいです。

一般的な研究室ではワーカーノードが数台程度なので古い監視基盤でも問題にならない規模ですが、GitOpsで監視の設定を一元管理できる点と各コンポーネントのアップデートが現実的になる点で、Kubernetesネイティブな監視基盤に移行するメリットは大きいと考えました。

ただし、Kubernetesクラスタがある時点で逸般的な研究室である可能性は高いので、ファーストステップとして監視基盤を構築したい方は依然として#5の方法をオススメします。

研究室サーバーでKubernetesクラスタ構築(#14)

はじめに

以前から、研究室でノードを跨いだNaaS(Notebook as a Service)を作ってみたいという野望がありました。そのためにはまず、研究室サーバーでKubernetesクラスタを構築するのが最も手堅いと言えるでしょう。

ただしNaaSの提供は、コンテナ(DockerやPodman)で研究していたユーザーを完全に移行させるものではありません。ユーザーが選択可能かつメンテナーが居なくなった場合は従来のコンテナのみの運用に戻せるように細心の注意が必要です。

本記事では、既にDockerがインストールされているノードを活用してkubeadmでオンプレミスのKubernetesクラスタを構築するためのノウハウを共有します。

1. クラスタのノード構成

kubernetes.io

元々は、余っていたRaspberry Pi 4BかIntel NUCをコントロールプレーンノードとする予定でした。しかし、過去のおうちKubernetesの経験から、クラスタを安定稼働させるには高性能なコントロールプレーンノードが必要不可欠だと感じていました。

研究室の先生にダメ元で購入をお願いしたところ、既に素晴らしい性能のCPUラックサーバーを注文されていたとのことで、ありがたくコントロールプレーンノードとして利用させていただくことになりました。

人生初のラックサーバー取り付け

最終的には以下のようなノード構成となりました。

ホスト名 役割 OS CPU メモリ GPU
polaris コントロールプレーンノード Ubuntu 24.04 AMD EPYC 7543P
(32C/64T, 2.8GHz)
128GB N/A
aries ワーカーノード① Ubuntu 24.04 AMD EPYC 7542
(32C/64T, 2.9GHz)
256GB NVIDIA Quadro RTX 6000 × 4
taurus ワーカーノード② Ubuntu 24.04 Intel Core i9-10940X
(14C/28T, 3.3GHz)
64GB NVIDIA RTX 6000 Ada Generation × 3
gemini ワーカーノード③ Ubuntu 24.04 Intel Xeon Gold 5218R
(20C/40T, 2.1GHz)
192GB NVIDIA RTX A6000 × 5

先生のおかげで、非常に恵まれた環境でKubernetesクラスタを構築できることになりました。

2. コンテナランタイムのセットアップ

kubernetes.io

コンテナランタイムの候補としてcontainerdCRI-Oの間でかなり悩みましたが、以下の2つの理由からcontainerdを採用しました。

1つ目は、全てのワーカーノードにDockerがインストールされており、containerdも一緒にインストールされていることです。Kubernetesクラスタのアップグレードに合わせてDocker Engineのアップグレードが必要になる可能性がありますが、Docker Engineはメンテナンスで年に数回アップグレードしているため、そこまで大きな問題にはならないと考えました。

2つ目は、CRI-OとPodmanでconmonが競合する可能性があることです。今年からデフォルトでrootlessなコンテナエンジンであるPodmanの利用を推奨しており、全てのワーカーノードにインストールされています。コンテナ監視ツールであるconmonはCRI-OとPodmanの両方で使用されているため、バージョン統一が大変になると考えました。

ワーカーノードでcontainerdのバージョンを確認します。

$ containerd -v
containerd containerd.io 1.7.25 bcc810d6b9066471b0b6fa75f557a15a1cbf31bb

基本的にはDocker Engineのアップグレード時点で最新バージョンのcontainerdをインストールしているため、執筆時点で最新のKubernetes v1.32もサポートしているcontainerdのバージョンでした(参考)。

それから注意点として公式ドキュメントにも記載がありますが、今回はパッケージからcontainerdをインストールした場合に相当するので、設定ファイルである/etc/containerd/config.tomlに変更が必要でした。デフォルトではdisabled_plugins = ["cri"]とCRIが無効化されているので、Kubernetesからcontainerdを利用するためにはこれを有効化する必要があります。公式ドキュメントで紹介されていたように以下のコマンドで設定をリセットしてcontainerdを再起動しました。

$ containerd config default > /etc/containerd/config.toml
$ sudo systemctl restart containerd

ここで、containerdを再起動する必要があったため、ワーカーノードとしては偶然ユーザーが誰も利用していなかった1台のノードのみを利用することにしました。残りのノードはメンテナンスを周知してからクラスタに参加させる予定です。

3. kubeadmによるクラスタ作成

Kubernetesのバージョンは最新のv1.32を採用しました。

基本的には以下の公式ドキュメントの通りセットアップを行いました。

kubernetes.io

ただし、手順に沿ってkubeadm initコマンドとkubeadm joinコマンドを実行しても、コントロールプレーンノードがNotReadyという状態のままでした。

root@polaris:~# kubectl get nodes
NAME      STATUS     ROLES           AGE     VERSION
polaris   NotReady   control-plane   7m27s   v1.32.3
taurus    Ready      <none>          4m1s    v1.32.3

公式ドキュメントにも

You must deploy a Container Network Interface (CNI) based Pod network add-on so that your Pods can communicate with each other. Cluster DNS (CoreDNS) will not start up before a network is installed.

と記載がありますが、以下のようなコマンドでもノードの状態がなぜNotReadyなのかを確認することができます。

root@polaris:~# kubectl describe node polaris
Name:               polaris
Roles:              control-plane
# 省略
Conditions:
  Type             Status  LastHeartbeatTime                 LastTransitionTime                Reason                       Message
  ----             ------  -----------------                 ------------------                ------                       -------
  MemoryPressure   False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasSufficientMemory   kubelet has sufficient memory available
  DiskPressure     False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasNoDiskPressure     kubelet has no disk pressure
  PIDPressure      False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletHasSufficientPID      kubelet has sufficient PID available
  Ready            False   Mon, 07 Apr 2025 00:52:13 +0900   Mon, 07 Apr 2025 00:47:04 +0900   KubeletNotReady              container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
# 省略

原因はCNIプラグインがインストールされていなかったことでした。

CNIプラグインとしては、公式ドキュメントで紹介されている中でもCalicoCiliumFlannelなどが有名ですが、今回はCiliumを採用しました。Ciliumの機能については別の記事にまとめようと思います。

docs.cilium.io

Ciliumは上記の公式ドキュメントを参考にHelmでデプロイしました。

ただし、今後Istioを導入する場合は公式ドキュメントに従って--set socketLB.hostNamespaceOnly=true --set cni.exclusive=falseを指定する必要があるので注意が必要です。

これによって、無事に全てのノードがReady状態となりました。

root@polaris:~# kubectl get nodes
NAME      STATUS   ROLES           AGE   VERSION
polaris   Ready    control-plane   21m   v1.32.3
taurus    Ready    <none>          18m   v1.32.3

4. Argo CDの導入

Kubernetesにおけるデプロイ方法のデファクトスタンダードであるGitOpsを実現するためのツールとして、Argo CDを導入します。

argo-cd.readthedocs.io

上記の公式ドキュメントに従ってArgo CDのインストールから始めました。

ステップ3でArgo CDのAPIサーバーを公開するためには、Service Type Load Balancer / Ingress / Port Forwardingの中から1つを選択しなければいけません。MetalLBはインストールしておらず、デバッグ用途のkubectl port-forwardも不適切であることから、消去法でIngressを選択しました。Ingressの設定にもたくさんの選択肢がありますが、今回はIstioを採用しました。

事前準備として以下の2ステップが必要でした。

  1. Istioのインストール:Istio / Install with Helm
  2. Ingress Gatewayのインストール:Istio / Installing Gateways

2.では「Kubernetes YAML」でインストールしましたが、LoadBalancerではなくNodePortを利用したかったので以下のように変更してapplyしました。

apiVersion: v1
kind: Service
metadata:
  name: istio-ingressgateway
  namespace: istio-ingress
spec:
-  type: LoadBalancer
+  type: NodePort
  selector:
    istio: ingressgateway
  ports:
  - port: 80
    name: http
+    targetPort: 8080
+    nodePort: 30080
-  - port: 443
-    name: https
---
# 省略
[root@polaris argocd ]# kubectl apply -f ingress.yaml
# 省略
[root@polaris argocd ]# kubectl get all -n istio-ingress
NAME                                        READY   STATUS    RESTARTS   AGE
pod/istio-ingressgateway-5b6548c6d6-wvt7d   1/1     Running   0          6s

NAME                           TYPE       CLUSTER-IP       EXTERNAL-IP   PORT(S)        AGE
service/istio-ingressgateway   NodePort   10.111.154.125   <none>        80:30080/TCP   6s

NAME                                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/istio-ingressgateway   1/1     1            1           6s

NAME                                              DESIRED   CURRENT   READY   AGE
replicaset.apps/istio-ingressgateway-5b6548c6d6   1         1         1       6s

残りは下記の公式ドキュメントに従ってIstioの設定を行います。ただし、今回はHTTPSは使用しないので、設定手順の中のTLSの設定はカットしました。

argo-cd.readthedocs.io

ブラウザでhttp://{{ IP }}/argocdにアクセスするとWeb UIにアクセスできます。

お馴染みのArgo CDのログイン画面

ステップ4でadminユーザーの初期パスワードを取得してログインしてPrivate Repositoriesの設定を行う予定でしたが、ログイン後のリダイレクトURLが/argocd/argocd/applications(正しくは/argocd/applications)になってしまいました。調査したところ、既にIssueでバグとして報告されており、執筆時点でv2.13では修正が完了したもののv2.14では未完了でした。

github.com

今回利用していたのはv2.14.9であったため、バージョンを下げることにしました。

# kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
# 以下を変更
- https://raw.githubusercontent.com/argoproj/argo-cd/v2.13.6/manifests/install.yaml

patches:
- path: ./patch.yaml

この修正で無事に/argocd/applicationsにリダイレクトされるようになりました。

正しくリダイレクトされた画面

さてPrivate Repositoriesの設定ですが、PATやSSH Private Key Credentialなどの属人化した設定をしたくなかったので、公式ドキュメントに従ってGitHub App Credentialの設定を行いました。

インストールしたGitHub Appの概要

最後にWeb UI上でアプリケーションの作成を行い、Getting StartedのサンプルマニフェストGitHubリポジトリにpushすることによって動作確認を行いました。

guestbookアプリケーションがSyncされている様子

Getting Startedの内容はここまででほとんどカバーできました。

公式ドキュメントで紹介されていたApp of Apps Patternは既に導入しましたが、今後はSSOSync Optionsなども設定する予定です。

5. GPU device pluginの導入

主にコンテナからGPUを利用するため、NVIDIA公式のdevice pluginを導入します。

github.com

READMEに記載されている必須要件の中のnvidia-docker >= 2.0 || nvidia-container-toolkit >= 1.7.0は、ワーカーノードが既に要件を満たしていました。

root@taurus:/home/azuma# nvidia-container-toolkit --version
NVIDIA Container Runtime Hook version 1.17.4
commit: 9b69590c7428470a72f2ae05f826412976af1395

一方で、必須要件のうち「nvidia-container-runtimeがデフォルトの低レベルランタイムとして設定されている」については、公式ドキュメントに従ってnvidia-ctkコマンドで設定した上で以下のような変更を/etc/containerd/config.tomlに加える必要がありました。

# 省略
    [plugins."io.containerd.grpc.v1.cri".containerd]
-      default_runtime_name = "runc"
+      default_runtime_name = "nvidia"
# 省略

containerdをrestartしたら、いよいよDaemonSetをデプロイするだけでGPUを有効化できます。staticなYAMLファイルも提供されていましたが、今回は本番環境で推奨されていたHelmを利用しました*1

[root@polaris ~]# helm upgrade -i nvdp nvdp/nvidia-device-plugin \
  --version=0.17.1 \
  --namespace nvidia-device-plugin \
  --create-namespace \
  --set gfd.enabled=true
# 省略
[root@polaris ~ ]# kubectl get daemonset -n nvidia-device-plugin
NAME                                              DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR                 AGE
nvdp-node-feature-discovery-worker                1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin                         1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin-gpu-feature-discovery   1         1         1       1            1           <none>                        5m17s
nvdp-nvidia-device-plugin-mps-control-daemon      0         0         0       0            0           nvidia.com/mps.capable=true   5m17s
[root@polaris ~ ]# kubectl describe node taurus
# 省略
Capacity:  # 省略
  nvidia.com/gpu:     3
Allocatable:  # 省略
  nvidia.com/gpu:     3
# 省略

このHelmの設定もGitOpsとして宣言的に管理するため、GitHubリポジトリに追加しました(参考)。

最後に、READMEで紹介されている、GPUを利用するサンプルジョブをデプロイして成功すれば完成です。

Time-slicingやMPSのようなshared GPUのための設定も必要になりそうですが、NaaS構築と同じタイミングでニーズに合わせて設定する予定です。

まとめ

本記事では、Dockerがインストールされたノードを活用してkubeadmでオンプレミスのKubernetesクラスタを構築する方法を紹介しました。つまずきポイントがいくつかあったので、オンプレミスクラスタを構築してみたい方にとって少しでも参考になれば幸いです。

Kubernetes周りでデファクトスタンダードなツールは他にもたくさんあるのですが、のめり込んでしまうとキリがないので、まずはNaaS構築に向けて着実にマイルストーンを達成してこうと思います。

*1:READMEに記載されていた最も基本的なインストールコマンドであるhelm upgrade -i nvdp nvdp/nvidia-device-plugin --namespace nvidia-device-plugin --create-namespace --version 0.17.1は期待通りにデプロイされませんでした。原因はデフォルトで無効化されているgpu-feature-discoveryが、自動的に生成するラベルがDaemonSetのnodeAffinityとして設定されているためでした。GFDを有効にするか、手動でノードにnvidia.com/gpu.present=trueラベルを付与しましょう。

次世代notebook『marimo』入門(#13)

はじめに

最近、Jupyter notebookの課題を解決するためのPython notebookとしてmarimoが注目されています。

docs.marimo.io

2024年12月頃からXでちらほらmarimoの話題を目にするようになりました(執筆時点ではSNSにおける話題性は少し薄れてきた印象です)。GitHubスター数の増加からもmarimoの人気が伺えます。

Jupyter notebookとmarimoのGitHubスター数の比較(リンク

本記事では、次世代notebookであるmarimoに入門しつつ、Jupyter notebookとの比較を通してmarimoのメリットや使用感を簡単に紹介します。

1. marimoの概要

github.com

百聞は一見に如かず。

手元で以下のコマンドを実行するか、提供されているオンラインのplaygroundでまずは試してみましょう。

$ python -m venv venv && . ./venv/bin/activate && pip install marimo && marimo tutorial intro

チュートリアルを一通りやってみると、おおよその感覚が掴めます。

reactiveにnotebookが実行される様子

上図では、ドロップダウンやスライダーの変更によって別のセルにある表示も変更されていることが分かります。marimoではセルの順序や実行順序は関係なく変数を参照しているセルがあれば自動的に再実行されるため、再現性が担保されています。

公式ドキュメントでreactive Python notebookと表現されている通り、marimoではセルを1つ1つ実行するという概念すらも忘れてしまいそうです。私たちが手動で実行する必要があるのは、変更を加えようとしているまさにそのセルのみということになります。

marimoの特長は他にもありますが、そのうちいくつかを簡単に紹介します。

  • Pythonスクリプトとして実行可能で、argparseなどでコマンドライン引数も取れる
  • CLIからWebアプリとしてデプロイでき、セル出力のみを結合したVerticalレイアウト、編集可能なGridレイアウト、スライドショーのようなSlidesレイアウトを切り替え可能
  • WASM notebooksとしてPythonのインストールを必要とせずにデプロイ・共有が可能
  • 接続したデータベースに対して直接セルにSQLを書いてDataFrameを取得したり、その出力のDataFrameに対してSQLを書いたりできる
  • Pythonスクリプトとして保存されるため、Gitフレンドリー
  • GitHub Copilotなどによるコード補完やAIアシスタント(ただしOpenAIやAnthropic、Google AIのAPIキーが必要)が利用可能

marimoのAIに関するユーザー設定画面

GitHub Copilotについては、Jupyter notebookでもVSCode.ipynbファイルを開く場合は利用できますが、jupyter notebookコマンドで起動したWeb UIからは利用できません*1

イデアの迅速なプロトタイピングを可能にするという観点では、Python notebookもVibe Codingも共通のニーズに応えられるので、コード補完やAIアシスタントをサポートしていることはmarimoの大きな武器になっていると考えました。

2. marimoとJupyter notebookの比較

marimoの公式ドキュメントのFAQに「marimoはJupyterとどう違いますか?」という項目があります。

docs.marimo.io

公式ドキュメントと同じことは述べませんが、文章を読むのが億劫だと感じる方のために簡単な表にまとめました(適宜文脈を補っています)。

marimo Jupyter 備考
再現性 × marimoは状態一貫性を担保。Jupyterは削除されたセルの変数の参照や実行順序の自由さが再現性を損ねる要因。
インタラクティブ marimoは標準搭載。Jupyterはipywidgets等で実現可能だが設定が必要。
Gitとの相性 ○ (.py) △ (.ipynb) marimoはPythonスクリプトとして保存。JupyterはJSON形式でGit差分が見づらい。
再利用性 marimoはそのままPythonスクリプトとして実行可能。Jupyterはスクリプト実行に追加処理が必要。
パッケージ管理 marimoはパッケージ要件をnotebookに含めるためコマンド1つで仮想環境として再現可能。Jupyterは外部管理が一般的で再現性がない傾向。
共有可能性 marimoはコマンド1つでインタラクティブなWebアプリとして公開可能。Jupyterはvoilaなどの利用が必要。

marimoのドキュメントなので必然的にmarimoの短所は取り扱っていませんでした。とは言え、marimoが「Python notebookの課題を解決するための再開発」である点からもJupyter notebookの多くの課題が解決されていると言えるでしょう。

強いてmarimoがJupyter notebookに劣っている点を挙げるとするならば知名度が挙げられます。Jupyter notebookは大学の講義や研究機関でも広く採用されています。marimoはCLIやWeb UI上でJupyter notebookとの相互変換もサポートしていますが、今後どれだけ普及するかがカギになりそうです。

3. marimoのデプロイ戦略

今後、研究室内または社内で「marimoをデプロイしたい」というニーズが高まるかもしれません。

この章では、以下の公式ドキュメントを参考にmarimoのデプロイ方法を簡単に紹介します。

docs.marimo.io

代表的なデプロイ方法は以下の3種類です。

  1. edit server(編集可能なnotebook):marimo editコマンドで起動
  2. run server(read-onlyなWebアプリ):marimo runコマンドで起動
  3. programmatic(read-onlyなWebアプリ):FastAPIを利用してASGIサーバーとして起動

read-onlyなWebアプリを公開したい場合、基本的には2.の方法で十分だと考えました。ミドルウェアや認証などを含めたい場合は3.の方法でカスタマイズする必要がありそうです。

編集可能なnotebookのデプロイとしては、大きく以下の4種類が紹介されていました。

  1. With ssh-port forwarding, using marimo edit --headless.

  2. Via docker and our prebuilt containers.

  3. Via a deployment service such as Railway.

  4. Behind JupyterHub.

1.はSSH接続さえできればリモートからアクセスできるということで、例えばScience TokyoのTSUBAME4.0でもSSHポートフォワーディングを使えばJupyter notebook同様にmarimoを利用できることになります。

2.については研究室や会社でセルフサービス形式でデプロイする時に活用できそうです。Prebuiltコンテナイメージはありがたいですが、ベースイメージのPythonは固定されているので多様なニーズに応えるためには結局イメージをカスタムする必要があります。

4.については、おそらく新規というよりも既にJupyterHubを運用しているチーム向けです。jupyter-marimo-proxyというパッケージを利用してJupyter上でmarimoを起動できるようですが、公式がメンテナンスしているものではないので本番運用は難しいでしょう。

まとめ

本記事では、Jupyter notebookとの比較を通してmarimoのメリットや使用感を紹介しました。

執筆時点でmarimoの最新バージョンはv0.12.7ですが、v1.0に向けたロードマップも既に公開されています。

marimo.io

個人的にはほぼ全て達成されているように見えましたが、GitHubリポジトリマイルストーンを確認すると達成率は75%(執筆時点)とのことでした。

github.com

これからは1人のユーザーとしてmarimoを利用するとともに、何らかの形でmarimoに貢献していきたいです。

*1:JupyterLabでもjupyter-copilotというライブラリを導入することでGitHub Copilotを利用できるそうです