ナミスマート合同会社

IaC(インフラのコード化)へのこだわり

PulumiIaCCloudflareセキュリティ

当社のプロダクトは、Cloudflare・GitHub・Stripe・Supabase・Storyblok・Resend の上に 乗っています。これらの設定を 1 つのリポジトリにまとめ、コードにできるところは すべてコードにする方針で運用しています。ダッシュボードを開いて手で作る作業は、 API が用意されていない数か所だけに絞ってあります。

この記事では、その構成と、なぜそうしたのかを書きます。

Pulumi を選んだ理由

当社は「1 つの Cloudflare アカウントに 1〜3 ドメイン」という単位でアカウントを 分けています。障害や誤操作の影響範囲を、プロダクトをまたがせないための区切りです。 この方針だとアカウントが増えるたびにプロバイダの定義が増えるので、 アカウントの一覧(config/accounts.ts)を回してプロバイダを組み立てられる Pulumi + TypeScript を選びました。

// providers/factory.ts の考え方
for (const account of cloudflareAccounts) {
  providers[account.name] = new cloudflare.Provider(account.name, {
    apiToken: process.env[account.tokenRef], // 値ではなく「秘密の名前」を config に置く
  });
}

プロダクト側は cfAccount: 'miyoshisoya' のようにどのアカウントに乗るかだけを 宣言し、対応するプロバイダが注入されます。アカウントを 1 つ増やす作業が、配列に 1 要素足すだけになります。

正直なところ、この差だけで Terraform(OpenTofu)と決定的に分かれるわけではありません。 実際に使ってみて良かったのは、stack の設定に入れた秘密を passphrase で暗号化して 持ってくれるところです。プロバイダの数も、Terraform provider bridge があるので 困りませんでした。Stripe は公式の stripe/stripe、Storyblok と Resend はコミュニティ製の プロバイダを bridge して使っています。

pulumi package add terraform-provider stripe/stripe

秘密は暗号化したままコミットする

秘密の扱いは、次の形に落ち着きました。

  • 実際の値は SOPS で暗号化して secrets/*.enc.yaml としてコミットする
  • 復号鍵(age の秘密鍵)と Pulumi の passphrase の 2 つだけを 1Password に置く
  • config/ には値ではなく秘密の名前tokenRef)しか書かない

暗号化した秘密をリポジトリに入れておく利点は、秘密の変更が Pull Request の 差分として見えることです。誰がいつどのトークンを差し替えたのかが履歴に残るので、 変更はすべてレビューを通ってから入ります。

新しい端末での準備は、1Password の 2 つの鍵と git clone だけで済みます。 鍵は op read で取り出すので、手を動かすのは Touch ID で解錠する一度だけです。 環境変数にトークンを並べたファイルの受け渡しも、この形で消えました。

make apply arukutomaru-prd
# bin/pulumi が op read で 2 つの鍵を取り、sops exec-env でトークンを注入し、
# R2 のバックエンドにログインしてから pulumi を実行する

鍵のローテーションも、この形なら手順が 1 つです。sops updatekeys を全ファイルに かければ暗号化のやり直しが終わります。どこに何の秘密があるかが常に一覧できて、 いつでもローテーションできる状態を保つことが、この設計の一番の目的でした。

API トークンも Pulumi に発行させる

人が値を持っている Cloudflare の API トークンは、アカウントごとに 1 本だけです。 このトークンで Pulumi を認証し、そこから用途別のトークンを発行(mint)しています。 デプロイ用、R2 や D1 を作る用、Secrets Store 用、Zero Trust 用、ゾーンごとの DNS 用。 どれも cloudflare.ApiToken のリソースとして作られます。

各トークンに載る権限は config/products.ts に宣言してあります。

deployTokenPermissions: [
  "Workers Scripts Write",  // wrangler deploy(Worker)
  "Pages Write",            // Pages のデプロイ
  "D1 Write",               // D1 binding + マイグレーション適用
  "Account Settings Read",  // wrangler がアカウント情報を読む
  "Secrets Store Write",    // Worker の secrets_store binding 認可
],
deployTokenZonePermissions: ["Workers Routes Write"],

つまり「このプロダクトのデプロイトークンは何ができるのか」が Pull Request の差分として レビューできます。権限を増やすときは、増やす理由がコミットメッセージに残ります。 ダッシュボードでチェックボックスを 1 つ足す作業との違いはここです。

発行したトークンは tokens という専用の stack に置き、消費する側の stack は StackReference で受け取ります。ここは実際に踏んだ問題への対処です。mint した ApiToken.value を下流のプロバイダに直接つなぐと、その値が preview の時点では 未確定になり、配下のリソースが差分ゼロのまま +- replace と表示されたり、 data source が空応答でクラッシュしたりします。StackReference の値は適用済みの 確定値なので、preview の時点で既知になり、この誤表示が消えます。

もう 1 つ、bridge した Cloudflare プロバイダは permissionGroups の変更を差分として 検出しません。権限を足したときは pulumi up --replace <urn> でトークンを作り直し、 新しい値を消費側の stack に流し直します。この手順はドキュメントに書いてあります。

API が無いので手作業に残した箇所

次の 4 つは、そもそも API が提供されていないので、画面での手作業でしかできません。 こういう箇所こそ手順書に残して、誰がやっても同じ結果になる形にしています。

  • Stripe アカウントそのもの。 Terraform プロバイダに作成のリソースがありません。
  • GitHub App の作成。 GitHub が API を提供しておらず、画面からしか作れません。 作ったあとの秘密鍵と ID は infra が管理します。
  • Cloudflare Email Routing の有効化。 有効化と MX / DKIM の自動レコードは Cloudflare 側が編集をロックしています。ルールだけが管理対象です。
  • メール転送先アドレスの検証。 確認メールのリンクを踏む操作は自動化できません。

こだわっているのは、インフラの冪等性です。コードで持てるところはコードが 何度実行しても同じ状態に収束させ、手作業でしかできないところは手順書が 同じ結果に収束させます。手作業を手順書に落としておけば、同じ環境をもう一度 作るときも、担当が替わったときも、出来上がるものは同じになります。

何が楽になったか

新しいプロダクトを足す作業が、設定ファイルへの追記になりました。 リポジトリ、 デプロイ用のトークン、DNS、Pages プロジェクト、GitHub の Environment と secret。 これらは config/products.ts に 20 行ほど足せば揃います。この blogs のサイト自体も そうやって作りました。

権限を後から絞れるようになりました。 デプロイトークンに必要な権限は プロダクトごとに宣言してあるので、必要な権限だけを持った状態を差分で維持できます。 Worker を持たないこのサイトのトークンは Pages WriteAccount Settings Read の 2 つだけです。

そして、すでに手で作られたインフラを抱えている状態より、むしろ有利だと 考えています。手で作った環境をコード化する作業は、現物とコードの差分を 1 つずつ 突き合わせる、終わりの見えにくい仕事になりがちです。最初からコードで作っていれば、 現物とコードは常に一致しています。新しい環境を作るときも、既存の設定はコードの とおりに保たれたままです。

インフラの構成や移行についてのご相談はお問い合わせからどうぞ。


ナミスマート合同会社

業界最速、最安、高品質なアプリ開発。開発のご相談はお問い合わせからどうぞ。

お問い合わせ