Skip to content
Published on

Operator のテストと配布 — envtest・e2e・OLM・バンドルパッケージング

Authors

はじめに

Operator を一度書いたことがある人と、その Operator を数十のクラスタへ安全に配布し無停止でアップグレードした経験のある人との間には、大きな隔たりがあります。コントローラの reconcile ロジックを書くことは出発点にすぎません。本当の難しさは、「このコントローラは本当に意図どおりに動作するのか」「新しいバージョンを配布しても既存のワークロードは壊れないか」「ユーザーは OperatorHub からワンクリックでインストールしアップグレードできるか」といった問いに答える過程で明らかになります。

この記事では、Operator の信頼性を支える二つの柱、すなわちテストと配布を深く掘り下げます。テストの面では、unit reconcile テストから envtest ベースの統合テスト、そして kind 上での e2e テストへとつながるテストピラミッドを見ていきます。配布の面では、OLM(Operator Lifecycle Manager)の ClusterServiceVersion、バンドル、カタログという概念と、チャネル・アップグレードグラフ、そして OperatorHub を通じた配布までを扱います。

2026 年現在、Kubebuilder は Kubernetes 1.36 と Go 1.26 をサポートし、controller-runtime v0.24.x と controller-tools v0.21.x の上で動作します。かつてメトリクス保護に使われていた kube-rbac-proxy は削除され、現在は controller-runtime が提供する認証・認可フィルタを直接使う方式に移行しました。この記事のサンプルはこの最新スタックを基準に書いています。

テストピラミッド

Operator のテストは、従来のテストピラミッドを Kubernetes 環境に合わせて変形した形で構成すると効率的です。下層は速くて安価ですが現実味に乏しく、上層は遅くて高価ですが実際の本番環境に近づきます。

              /\
             /  \        e2e (kind / 実クラスタ)
            /    \       - 少数、遅い、コスト高
           /------\      - 全体フロー、実 kubelet・CNI を検証
          /        \
         /          \    envtest 統合テスト
        /            \   - 中程度、実 apiserver+etcd
       /              \  - reconcile <-> API の相互作用を検証
      /----------------\
     /                  \ unit reconcile テスト
    /                    \- 多数、速い、コスト低
   /                      \- fake client でロジック単位の検証
  /________________________\

三つの層の役割は明確に分かれています。

実行対象速度kubelet/CNI主な検証対象
unit reconcilefake client非常に速いなしreconcile の分岐、状態遷移
envtest 統合実 apiserver + etcd速いなしCRD 検証、watch、所有参照、ステータス更新
e2e実クラスタ(kind)遅いありPod 起動、ネットワーク、実ワークロード

中核となる原則は「下の層で捕まえられるバグは下の層で捕まえる」です。e2e でのみ失敗が再現するなら、その代償は非常に大きくなります。したがって、できるだけ多くのロジックを unit と envtest のレベルまで引き下げて検証し、e2e は本当に実クラスタが必要な統合シナリオだけに集中させるのが望ましいやり方です。

envtest のセットアップ

envtest は controller-runtime が提供するテストツールで、実際の kube-apiserver と etcd のバイナリをローカルで起動し、その上にコントローラを接続します。kubelet、スケジューラ、コントローラマネージャは実行されないため、Pod が実際にスケジュールされたり起動したりすることはありません。しかし API サーバとのあらゆる相互作用は本物とまったく同じように動作します。つまり CRD 検証、admission、watch イベント、所有参照に基づくガベージコレクションのトリガといった動作を、実際の API セマンティクスで検証できます。

envtest 用のバイナリは setup-envtest ツールでダウンロードします。

# 特定の Kubernetes バージョン向け envtest バイナリをインストール
go run sigs.k8s.io/controller-runtime/tools/setup-envtest@latest use 1.36.x

# インストールパスを環境変数として公開
export KUBEBUILDER_ASSETS=$(setup-envtest use 1.36.x -p path)

Kubebuilder でスキャフォールドすると suite_test.go が生成され、Ginkgo と Gomega をベースにテストスイートを構成します。

package controller

import (
	"context"
	"path/filepath"
	"testing"

	. "github.com/onsi/ginkgo/v2"
	. "github.com/onsi/gomega"

	"k8s.io/client-go/kubernetes/scheme"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/envtest"
	logf "sigs.k8s.io/controller-runtime/pkg/log"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"

	webappv1 "example.com/guestbook-operator/api/v1"
)

var (
	cfg       *rest.Config
	k8sClient client.Client
	testEnv   *envtest.Environment
	ctx       context.Context
	cancel    context.CancelFunc
)

func TestControllers(t *testing.T) {
	RegisterFailHandler(Fail)
	RunSpecs(t, "Controller Suite")
}

var _ = BeforeSuite(func() {
	logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true)))

	ctx, cancel = context.WithCancel(context.TODO())

	By("テスト環境のブートストラップ")
	testEnv = &envtest.Environment{
		CRDDirectoryPaths:     []string{filepath.Join("..", "..", "config", "crd", "bases")},
		ErrorIfCRDPathMissing: true,
	}

	var err error
	cfg, err = testEnv.Start()
	Expect(err).NotTo(HaveOccurred())
	Expect(cfg).NotTo(BeNil())

	Expect(webappv1.AddToScheme(scheme.Scheme)).To(Succeed())

	k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme})
	Expect(err).NotTo(HaveOccurred())
	Expect(k8sClient).NotTo(BeNil())

	mgr, err := ctrl.NewManager(cfg, ctrl.Options{Scheme: scheme.Scheme})
	Expect(err).NotTo(HaveOccurred())

	err = (&GuestbookReconciler{
		Client: mgr.GetClient(),
		Scheme: mgr.GetScheme(),
	}).SetupWithManager(mgr)
	Expect(err).NotTo(HaveOccurred())

	go func() {
		defer GinkgoRecover()
		Expect(mgr.Start(ctx)).To(Succeed())
	}()
})

var _ = AfterSuite(func() {
	cancel()
	By("テスト環境の後始末")
	Expect(testEnv.Stop()).To(Succeed())
})

このスイートが核心的に示しているのは、testEnv.Start() が実際の apiserver と etcd を起動し、その接続情報を cfg として返すという点です。その後マネージャを起動して実際のコントローラを登録すれば、統合テストは本物の reconcile ループが回る環境でオブジェクトを作り、その結果を観察する形で書くことができます。

以下は envtest 上で動く統合テストの例です。Guestbook リソースを作ると、コントローラが Deployment を作成しステータスを更新するかどうかを検証します。

var _ = Describe("Guestbook コントローラ", func() {
	const (
		resourceName = "test-guestbook"
		namespace    = "default"
	)

	Context("Guestbook リソースを reconcile するとき", func() {
		It("Deployment を作成し Ready 状態として表示すべき", func() {
			By("Guestbook オブジェクトの作成")
			gb := &webappv1.Guestbook{
				ObjectMeta: metav1.ObjectMeta{
					Name:      resourceName,
					Namespace: namespace,
				},
				Spec: webappv1.GuestbookSpec{
					Replicas: 3,
					Image:    "nginx:1.27",
				},
			}
			Expect(k8sClient.Create(ctx, gb)).To(Succeed())

			By("コントローラが Deployment を作るか確認")
			deployKey := types.NamespacedName{Name: resourceName, Namespace: namespace}
			createdDeploy := &appsv1.Deployment{}
			Eventually(func() error {
				return k8sClient.Get(ctx, deployKey, createdDeploy)
			}, time.Second*10, time.Millisecond*250).Should(Succeed())

			Expect(*createdDeploy.Spec.Replicas).To(Equal(int32(3)))

			By("所有参照が正しく設定されているか確認")
			Expect(createdDeploy.OwnerReferences).To(HaveLen(1))
			Expect(createdDeploy.OwnerReferences[0].Kind).To(Equal("Guestbook"))
		})
	})
})

Eventually ブロックは、コントローラが非同期に動作するという点を反映しています。オブジェクトを作った直後にはまだ Deployment が存在しないことがあるため、一定時間のあいだ繰り返し取得を試み、条件が満たされるのを待ちます。このパターンは envtest 統合テストのほぼあらゆる場所で登場します。

unit reconcile テスト

すべての分岐を envtest で検証するとテストが遅くなります。reconcile 関数内部の純粋なロジック分岐は、fake client で素早く検証するのがよいです。controller-runtime の fake client はインメモリのオブジェクトストアを模倣するので、apiserver を起動せずに Get/Create/Update/List を試せます。

package controller

import (
	"context"
	"testing"

	appsv1 "k8s.io/api/apps/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client/fake"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"

	webappv1 "example.com/guestbook-operator/api/v1"
)

func TestReconcile_CreatesDeployment(t *testing.T) {
	s := scheme.Scheme
	_ = webappv1.AddToScheme(s)
	_ = appsv1.AddToScheme(s)

	gb := &webappv1.Guestbook{
		ObjectMeta: metav1.ObjectMeta{Name: "gb", Namespace: "ns"},
		Spec:       webappv1.GuestbookSpec{Replicas: 2, Image: "nginx:1.27"},
	}

	cl := fake.NewClientBuilder().
		WithScheme(s).
		WithObjects(gb).
		Build()

	r := &GuestbookReconciler{Client: cl, Scheme: s}

	_, err := r.Reconcile(context.Background(), reconcile.Request{
		NamespacedName: types.NamespacedName{Name: "gb", Namespace: "ns"},
	})
	if err != nil {
		t.Fatalf("reconcile 失敗: %v", err)
	}

	got := &appsv1.Deployment{}
	if err := cl.Get(context.Background(),
		types.NamespacedName{Name: "gb", Namespace: "ns"}, got); err != nil {
		t.Fatalf("Deployment が作成されていない: %v", err)
	}
	if *got.Spec.Replicas != 2 {
		t.Errorf("replicas 期待値 2、実際 %d", *got.Spec.Replicas)
	}
}

このような単体テストはミリ秒単位で実行されるので、エッジケース(たとえば既存の Deployment、replicas の不一致、削除進行中の状態)を何十個書いても負担になりません。fake client は実 API サーバの admission や検証 webhook を実行しないため、CRD 検証のような動作は必ず envtest で確認しなければならない、という点だけ覚えておけば十分です。

OLM の概念: CSV・バンドル・カタログ

テストが終わったら、いよいよ配布です。OLM(Operator Lifecycle Manager)は、クラスタ内で Operator のインストール・アップグレード・依存関係・権限を宣言的に管理するコンポーネントです。OLM を理解するには四つの中核概念を知る必要があります。

概念役割
ClusterServiceVersion (CSV)Operator の一つのバージョンを記述するマニフェスト。デプロイ、RBAC、CRD 所有、インストールモード、バージョンメタデータを含む
バンドル (bundle)一つの CSV とそれに付随する CRD・メタデータをまとめたパッケージング単位
バンドルイメージ (bundle image)バンドルの内容を格納した OCI コンテナイメージ
カタログ (catalog)複数のバンドルとチャネル情報をインデックス化したイメージ。OLM はインストール可能な Operator の一覧をここから読む

CSV は OLM 世界の中心となる文書です。以下は主要なフィールドのみを抜き出した例です。

apiVersion: operators.coreos.com/v1alpha1
kind: ClusterServiceVersion
metadata:
  name: guestbook-operator.v0.2.0
  namespace: placeholder
spec:
  displayName: Guestbook Operator
  version: 0.2.0
  replaces: guestbook-operator.v0.1.0
  maturity: stable
  installModes:
    - type: OwnNamespace
      supported: true
    - type: SingleNamespace
      supported: true
    - type: MultiNamespace
      supported: false
    - type: AllNamespaces
      supported: true
  customresourcedefinitions:
    owned:
      - name: guestbooks.webapp.example.com
        version: v1
        kind: Guestbook
  install:
    strategy: deployment
    spec:
      deployments:
        - name: guestbook-controller-manager
          spec:
            replicas: 1

バンドルは通常、次のようなディレクトリ構成を持ちます。

bundle/
|-- manifests/
|   |-- guestbook-operator.clusterserviceversion.yaml
|   |-- webapp.example.com_guestbooks.yaml
|-- metadata/
|   |-- annotations.yaml
|-- bundle.Dockerfile

このバンドルを OCI イメージとしてビルドしレジストリにプッシュするとバンドルイメージになり、複数のバンドルイメージをまとめてインデックス化するとカタログイメージになります。operator-sdk と opm ツールがこの過程を自動化します。

# CSV のスキャフォールドとバンドルマニフェストの生成
operator-sdk generate kustomize manifests
make bundle VERSION=0.2.0

# バンドルイメージのビルドとプッシュ
make bundle-build bundle-push BUNDLE_IMG=quay.io/example/guestbook-bundle:v0.2.0

# カタログイメージのビルド(バンドル群をインデックス化)
opm index add \
  --bundles quay.io/example/guestbook-bundle:v0.2.0 \
  --tag quay.io/example/guestbook-catalog:latest

チャネルとアップグレードグラフ

OLM ではアップグレードがチャネル(channel)とアップグレードグラフで表現されます。チャネルは stable、alpha、fast のようなリリースストリームであり、各チャネル内でバージョンがどのようにつながるかをグラフで定義します。このグラフを定義する三つのメカニズムがあります。

フィールド意味
replacesこのバージョンが直前のどのバージョンを置き換えるかを明示。線形のアップグレード経路を形成
skipsスキップできるバージョンの一覧。欠陥のある中間バージョンを回避
skipRangesemver の範囲で一度にスキップするバージョンを指定。例: 0.1.0 以上 0.5.0 未満

以下は stable チャネルのアップグレードグラフを可視化したものです。

  stable チャネルのアップグレードグラフ

  v0.1.0 --replaces-- v0.2.0 --replaces-- v0.3.0
                                              |
                                          skips: v0.2.1 (欠陥バージョン)
                                              |
                                              v
  v0.4.0 --skipRange ">=0.1.0 <0.4.0"-- v0.5.0

  インストール時: OLM はチャネルの head(最新)を見つけ、
                  replaces チェーンを段階的にたどるか、
                  skipRange で一気に head までジャンプする

skipRange は特に有用です。replaces チェーンだけを使うと、v0.1.0 のユーザーが v0.5.0 へ進むにはすべての中間バージョンを順番に経由する必要がありますが、skipRange を設定すれば一気に最新バージョンへジャンプできます。ただしその分、中間バージョンのマイグレーションロジックがすべて互換でなければならないため、慎重に設計する必要があります。

# CSV のアノテーションで skipRange を指定
metadata:
  name: guestbook-operator.v0.5.0
  annotations:
    olm.skipRange: '>=0.1.0 <0.5.0'
spec:
  replaces: guestbook-operator.v0.4.0
  skips:
    - guestbook-operator.v0.2.1

OperatorHub による配布

OperatorHub.io はコミュニティ Operator の公開カタログです。自分の Operator をここに登録すると、OLM がインストールされたあらゆるクラスタから検索・インストールできるようになります。登録手続きは、community-operators リポジトリにバンドルマニフェストを PR として提出する方式です。

  OperatorHub 配布フロー

  開発者 --PR--> community-operators リポジトリ
                       |
                       | CI: バンドル検証、CSV lint、
                       |     インストールモード検査、アップグレード経路確認
                       v
                  マージ --> カタログイメージに反映
                              |
                              v
  ユーザークラスタの OLM --> OperatorHub UI に表示
                              |
                              v
                       Subscription の作成でインストール

ユーザーの視点では、Subscription リソース一つでインストールと自動アップグレードを宣言します。

apiVersion: operators.coreos.com/v1alpha1
kind: Subscription
metadata:
  name: guestbook-operator
  namespace: operators
spec:
  channel: stable
  name: guestbook-operator
  source: operatorhubio-catalog
  sourceNamespace: olm
  installPlanApproval: Automatic

installPlanApproval を Manual にすると、新しいバージョンがチャネルに上がっても、管理者が明示的に承認するまでアップグレードが保留されます。本番では Manual を好む場合が多いです。

バージョンアップグレードの安全性

アップグレードは Operator 運用で最も危険な瞬間です。新しいコントローラのバージョンが既存の CRD オブジェクトを誤って reconcile したり、CRD スキーマが非互換に変わったりすると、稼働中のワークロードが破損することがあります。安全なアップグレードのための原則は次のとおりです。

  • CRD スキーマは常に後方互換に変更します。フィールドの追加は安全ですが、フィールドの削除や型変更は新しい API バージョンと変換 webhook を通じて処理します。
  • 複数の API バージョンを同時に提供する場合は、conversion webhook で保存バージョンと提供バージョンの間の変換を保証します。
  • アップグレード経路ごとに e2e テストを置きます。すなわち、以前のバージョンでオブジェクトを作っておいた状態で新しいバージョンのコントローラに置き換え、オブジェクトが正常に reconcile されるかを検証します。
  • skipRange を使う場合は、スキップするすべての中間バージョンのマイグレーションが累積的に互換であることを確認します。
// 変換 webhook: v1beta1 <-> v1 変換の例
func (src *GuestbookV1Beta1) ConvertTo(dstRaw conversion.Hub) error {
	dst := dstRaw.(*GuestbookV1)
	dst.ObjectMeta = src.ObjectMeta
	dst.Spec.Replicas = src.Spec.Count // フィールド名変更を吸収
	dst.Spec.Image = src.Spec.Image
	return nil
}

func (dst *GuestbookV1Beta1) ConvertFrom(srcRaw conversion.Hub) error {
	src := srcRaw.(*GuestbookV1)
	dst.ObjectMeta = src.ObjectMeta
	dst.Spec.Count = src.Spec.Replicas
	dst.Spec.Image = src.Spec.Image
	return nil
}

最小権限 RBAC

Operator はしばしばクラスタ全体にわたる広範な権限を要求しがちです。しかしセキュリティの観点では、コントローラは実際に必要なリソースに対してのみ、必要な動詞(verb)だけを持つべきです。Kubebuilder では RBAC マーカーのコメントで権限を宣言し、controller-tools がそれを読み取って Role と ClusterRole のマニフェストを生成します。

// reconcile 関数のすぐ上に RBAC マーカーを宣言
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=webapp.example.com,resources=guestbooks/finalizers,verbs=update
//+kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
func (r *GuestbookReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	// reconcile ロジック
	return ctrl.Result{}, nil
}

このマーカーに対して make manifests を実行すると、次のような Role が生成されます。

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: manager-role
rules:
  - apiGroups: ['webapp.example.com']
    resources: ['guestbooks']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
  - apiGroups: ['webapp.example.com']
    resources: ['guestbooks/status']
    verbs: ['get', 'update', 'patch']
  - apiGroups: ['apps']
    resources: ['deployments']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']

最小権限設計の実践指針は次のとおりです。

  • ワイルドカードの verb とワイルドカードの resource を避けます。明示的に必要な動詞だけを列挙します。
  • ネームスペーススコープで十分なら、ClusterRole の代わりに Role を使います。
  • メトリクスエンドポイントの保護はかつて kube-rbac-proxy サイドカーで処理していましたが、2026 年現在このサイドカーは削除され、controller-runtime の WithAuthenticationAndAuthorization フィルタで直接保護します。
// メトリクスサーバを認証・認可フィルタで保護
mgr, err := ctrl.NewManager(cfg, ctrl.Options{
	Scheme: scheme,
	Metrics: metricsserver.Options{
		BindAddress:    ":8443",
		SecureServing:  true,
		FilterProvider: filters.WithAuthenticationAndAuthorization,
	},
})

こうすると、別途サイドカーコンテナを置かずとも、メトリクスエンドポイントが TokenReview と SubjectAccessReview を通じて認証・認可されます。結果として Pod の構造が単純になり、管理するコンポーネントが減ります。

マルチネームスペース / マルチテナント運用

Operator をどの範囲で動作させるかは、OLM のインストールモード(install mode)で決まります。CSV の installModes フィールドがどのモードをサポートするかを宣言し、ユーザーは Subscription を作るときにそのうちの一つを選びます。

インストールモード監視範囲利用シナリオ
OwnNamespaceOperator がインストールされたネームスペースのみ単一チーム専用、最も隔離的
SingleNamespace指定した単一ネームスペースOperator とワークロードを分離配置
MultiNamespace明示した複数のネームスペース一部のテナントのみを選択的に管理
AllNamespacesクラスタ全体プラットフォームレベルの共有 Operator

マルチテナント環境では、この選択がセキュリティ境界に直結します。AllNamespaces モードは便利ですが、コントローラがクラスタ全体の権限を持つため、あるテナントの CR が別のテナントに影響を与えないよう、reconcile ロジックでネームスペースの隔離を徹底する必要があります。

  インストールモード別の監視範囲

  OwnNamespace        SingleNamespace      AllNamespaces
  +----------+        +----------+         +------------------+
  | ns-a     |        | operator |         | クラスタ全体     |
  | [op][cr] |        +----+-----+         | +----++----++---+ |
  +----------+             | watch         | |ns-a||ns-b||...| |
                           v               | +----++----++---+ |
                      +----------+          +--------+---------+
                      | ns-b     |              op watch all
                      | [cr]     |
                      +----------+

コントローラのコードでは、マネージャのキャッシュ範囲をインストールモードに合わせて制限するのがよいです。たとえば SingleNamespace モードなら、マネージャが該当ネームスペースだけをキャッシュするように設定します。

mgr, err := ctrl.NewManager(cfg, ctrl.Options{
	Scheme: scheme,
	Cache: cache.Options{
		DefaultNamespaces: map[string]cache.Config{
			"team-a-workloads": {},
		},
	},
})

こうすると、コントローラが不必要にクラスタ全体を watch しなくなるため、メモリ使用量と API 負荷が減り、権限境界も明確になります。

本番チェックリスト

配布の直前に点検すべき項目を整理します。

  • テスト: unit reconcile テストが主要な分岐をすべてカバーしているか。envtest 統合テストが CRD 検証と所有参照を検証しているか。e2e が実ワークロードの起動まで確認しているか。
  • アップグレード: 以前のバージョンから新バージョンへの e2e アップグレードテストが通るか。conversion webhook がすべての保存バージョンを処理するか。skipRange 範囲内のすべてのマイグレーションが互換か。
  • RBAC: ワイルドカード権限がないか。ClusterRole が本当に必要か、それとも Role で十分か。メトリクスが認証・認可フィルタで保護されているか。
  • OLM: CSV の replaces/skips/skipRange が意図したアップグレードグラフを形成しているか。installModes が実際のサポート範囲と一致しているか。
  • 可観測性: コントローラが reconcile エラー率、キュー深さ、reconcile 遅延などのメトリクスを公開しているか。適切なイベントとコンディションをステータスに記録しているか。
  • 回復性: リーダー選出が有効になっているか。コントローラが再起動しても状態が一貫して収束するか(冪等性)。
  • リソース: コントローラ Pod に適切な requests/limits が設定されているか。大規模クラスタでキャッシュメモリが膨張しないか。

おわりに

Operator の価値は、単に動作する reconcile ループを書くことにあるのではありません。そのコントローラを信頼できる形で検証し、ユーザーが安全にインストール・アップグレードできるようパッケージングする、ライフサイクル全体に責任を持つことにあります。テストピラミッドはバグをできるだけ速く安価な層で捕まえさせてくれ、OLM とバンドルパッケージングは配布とアップグレードを宣言的で再現可能なものにしてくれます。

特に 2026 年現在のスタックは以前より単純になりました。kube-rbac-proxy サイドカーがなくなったことでメトリクス保護が controller-runtime の中に統合され、envtest と operator-sdk のツールチェーンは成熟し、テストとバンドリングを自動化しやすくなりました。この記事で扱った原則をチェックリストとして、書き上げた Operator が実験室を越えて本番で信頼されるコンポーネントになることを願っています。

References