はじめに
Kubernetes上でRailsアプリのシークレット管理に 1Password Connect Operator を使っています。OnePasswordItem というCRDを作るだけで、1Password上の値をKubernetesのSecretとして自動同期してくれる便利なツールです。
ところがある日、CI/CDのDeployステージが複数環境で一斉に失敗するようになりました。原因を追うと、operator自体がクラッシュしていたわけではなく、1Password側のAPIレート制限に引っかかっていたことが分かりました。
この記事では、
- レート制限を回避するためにpollingを止めて再起動駆動の同期に切り替えた話
- その過程で見直した、Terraform・Helm chart・operator間の型と値の確認方法
- 最終的にoperatorの管理主体を見直した判断
を、実際に対応した順番で共有します。
背景: pollingの積み上げがレート制限を超えた
1Password Connect Operatorはデフォルトで10分間隔(600秒)ごとに、管理下のOnePasswordItemをすべてポーリングして1Passwordの最新値と同期します。
このときKubernetesクラスタ上で同期対象になっていたOnePasswordItemは複数環境ぶん(本番・プレビュー・別プロジェクト用など)で計6件。調査当時、まず「10分間隔で6件」という最も単純な仮定でざっくり見積もると、
1日の分数 ÷ 10分 × 6件 = 144 × 6 = 864リクエスト/日
となり、1Password Service Account(Personal/Familiesプラン)の日次リクエスト上限(1,000リクエスト/日)にかなり近い水準まで積み上がっていました。
ただしこれは「1件のポーリングにつきAPIリクエスト1回」という単純化した見積もりです。実際のoperatorは、vault名・item名をパスで指定している場合はvaultのID解決・item名の解決・item本体の取得といった形で1件あたり複数回のConnect API呼び出しが発生しうる(ファイルを含むitemならさらにGetFileContentが加わる)ため、実際のリクエスト数は見積もりより数倍多くなる可能性があります。加えて複数のoperatorインスタンスや手動操作も同じ日次上限を消費します。そのため実際にはこの概算値よりずっと早く上限に達し、レート制限(rate limit exceeded)でリクエストが拒否される状態になっていました。正確な値が必要な場合は、見積もりに頼らずoperatorのログや実測から1回のreconcileあたりのリクエスト数を確認するのが確実です。
症状として現れたのは、Podがクラッシュするのではなく、
- 1Password Connect Operatorが同期を続けられず、Kubernetesの
Secretが最新化されない - 結果としてHelmのアップグレードが
pending-upgradeロックのまま止まる - CI/CDのDeployステージが複数環境で軒並み失敗する
という間接的な壊れ方でした。ログにPodのクラッシュが出ないぶん、最初は原因の特定に時間がかかりました。
対策1: pollingをやめて再起動駆動の同期にする
1Password Connect Operatorの同期は、controller-runtimeの仕組み上、起動時に管理下のリソース全件を1回reconcileするという挙動も持っています。つまりoperatorを再起動さえすれば、pollingに頼らなくても即座に同期が走ります。
そこで、TerraformでHelm releaseに渡しているpollingIntervalを、実質無効化に近い値(315360000秒 ≒ 約10年)まで伸ばし、通常時はpollingに一切頼らない運用にしました。1Passwordの値を更新したいときはoperatorを再起動する(または対象のOnePasswordItemをannotateする)ことで即時反映させます。
variable "onepassword_polling_interval_seconds" {
type = number
default = 315360000 # ≒ 10年。実質pollingしない
}
ハマりどころ: Terraform・Helm・operatorの境界で値を確認する
上記の変更をapplyしてしばらく経ってから、pollingIntervalが意図通り反映されていないことに気づきました。当初は、Terraformが生成した中間YAMLに3.1536e+08が現れたため、yamlencode()による科学的記数法化が原因だと判断し、tostring()で明示的に文字列化しました。
ただし、この挙動をyamlencode()一般の仕様として説明するのは正確ではありません。現在のgo-cty-yamlは数値を固定小数点表記で出力します。また、現在の1Password Connect Helm chartはoperator.pollingIntervalをDeploymentの引用符付きPOLLING_INTERVAL環境変数として描画します。当時使用したTerraform・chart・operatorの正確なバージョンと、operatorが最終的に受け取った値は記録に残っていないため、315360000が常に科学的記数法になり、operatorが600秒へフォールバックするとは断定できません。
この経験から、値が反映されないときはTerraformの入力だけで原因を決めず、次の各段階を同じデプロイで確認するようにしました。
-
terraform consoleまたはplanで、Helmへ渡す値と型を確認する -
helm templateで、DeploymentのPOLLING_INTERVALが引用符付きの10進文字列になっているか確認する -
kubectl get deployment ... -o yamlで、実際に適用された環境変数を確認する - operatorのログと実測した同期間隔で、最終的に解釈された値を確認する
tostring()はTerraformからHelmへ渡す型を明示する手段としては有効です。
pollingInterval = tostring(var.onepassword_polling_interval_seconds)
しかし、これだけで原因を証明したことにはなりません。Terraform、chart、operatorのバージョンと、生成YAML・適用後の環境変数・実際の挙動をセットで残すことが、同じ問題を再現して切り分けるために必要です。
運用の見直し: 完全restart-onlyは手動運用の負荷が大きすぎた
pollingを実質無効化した運用にしてみたものの、1Passwordの値を更新するたびに手動でoperatorを再起動する必要があり、これは想定以上に手間でした。
前述の単純化した見積もりでは、デフォルトの10分間隔(600秒)でも1日864リクエストで日次上限(1,000リクエスト/日)には収まる計算になります。ただし前述の通り実際のリクエスト数は見積もりより多くなりうること、他の自動化やCLI操作も同じ日次上限を共有することを踏まえると、デフォルト間隔まで戻すのは安全余裕が乏しいと判断しました。そこで、手動運用の負荷と安全余裕のバランスを取り、**1日(86400秒)**まで戻すことにしました。
1日の分数 ÷ 1440分 × 6件 = 1 × 6 = 6リクエスト/日
見積もり上のリクエスト数を大きく減らしつつ、日次上限に対して十分な安全余裕を持たせる間隔です。即座に反映させたい場合のために、operatorを手動再起動するためのworkflowは別途用意してあるので、「普段は1日1回の自動同期、急ぎのときは手動再起動」という2段構えの運用に落ち着きました。
最終的な判断: operatorの管理主体をアプリ側のCIへ移す
その後、この1Password Connect Operatorを共有していた別プロジェクトが終了し、複数プロジェクトで共有するTerraformリポジトリからoperatorのHelm releaseを外に出す機会がありました。
ここで改めて考えたのは、「このoperatorを本当に必要としているのは誰か」という点です。実際にこのoperatorが同期するシークレットを使うのは、特定のRailsアプリケーションだけでした。それなら、共有インフラのTerraformで管理し続けるより、そのアプリケーション自身のCIでoperatorをデプロイするほうが、変更の影響範囲も権限の所在もアプリのリポジトリ内で完結して分かりやすくなります。
移行の手順は次の通りです。
- アプリ側のCIに、
helm upgrade --installでoperatorをデプロイし、OnePasswordItemをapplyするステップを追加する - 追加した仕組みが実際に正常動作することを確認する
- 確認できてから、共有Terraformリポジトリ側で**
terraform state rmを使い、既存のKubernetesリソースを一切変更・削除せずに、Terraformの管理対象からだけ外す** - リソースがTerraformの管理下でなくなったことを確認してから、対応するTerraformコードを削除する
ポイントは3のterraform state rmです。これは「Terraformの状態(state)からリソースの記録を消す」操作であり、実際のクラウド/Kubernetesリソースには一切手を加えません。そのため、既に稼働中のoperatorやSecretを再作成・再起動することなく、管理の主体だけを安全に付け替えることができました。
まとめ
- 外部SaaSのAPIをpollingするコンポーネントを複数環境ぶん動かすときは、
ポーリング間隔 × 対象件数はあくまで下限の概算に過ぎない(1件あたり複数回のAPI呼び出しが発生しうる)と踏まえたうえで、実測かログでサービス側のレート制限との余裕を確認しておく - TerraformからHelm chartへ値を渡すときは、入力値だけでなく、生成YAML・適用後の環境変数・operatorの実測値まで確認する
- 共有インフラの一部を、実際に使っている側のリポジトリへ寄せたくなったときは、
terraform state rmを使えば既存リソースを壊さずに管理主体を移せる
関連リンク
この記事の要点は Slidictのスライド にもまとめています。
