一覧に戻る
    PulumiのバグからTerraformへ:IaCツール間移行で踏んだ3つの罠
    開発ラボ
    PRこの記事には広告が含まれています

    PulumiのバグからTerraformへ:IaCツール間移行で踏んだ3つの罠

    18 分で読める

    背景と動機

    外形監視(CloudWatch Synthetics Canary)の設定を管理しているリポジトリで、ある日突然 pulumi refresh が失敗するようになりました。

    原因は Pulumi AWS Provider の回帰バグ。SecurityGroupIngressRule リソースに対して refresh をかけると、エラーで止まってしまう。既知の問題で、すぐには直りそうにない。

    このまま Pulumi で管理を続けるのは難しいと判断し、Canary 用のセキュリティグループ(SG)管理を Terraform に移行することにしました。

    移行自体はそれほど複雑ではないだろうと思っていたのですが、実際にやってみると 3 つの罠を踏むことになりました。その記録です。

    構成の概要

    移行前の状態はこんな感じでした。

    Pulumi が Canary 用 SG を作り、各 ECS サービスの SG に対して「Canary からの TCP/80 を許可する」ingress ルールを追加していました。今回の移行後はこうなります。

    SG の管理だけを Terraform に渡し、Pulumi は Canary リソース本体の管理に専念する形です。

    罠① inline ルールと awssecuritygroup_rule の混在

    Terraform で SG を書くとき、ingress/egress ルールの定義方法は 2 つあります。

    • aws_security_groupingress / egress ブロック(inline)
    • aws_security_group_rule または aws_vpc_security_group_ingress_rule(個別リソース)

    今回の既存コードは inline で書かれていたので、新しい Canary SG も inline で合わせました。

    resource "aws_security_group" "canary_sg" {
      name        = "your-app-synthetics-canary-sg-${var.env}"
      description = "Security group for CloudWatch Synthetics Canary"
      vpc_id      = var.vpc_id
    
      ingress = []  # Canary SG 自体は inbound を受け付けない
    
      egress {
        from_port   = 80
        to_port     = 80
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      }
    
      egress {
        from_port   = 443
        to_port     = 443
        protocol    = "tcp"
        cidr_blocks = ["0.0.0.0/0"]
      }
    }

    各 ECS サービスの SG にも inline で ingress ルールを追加します。

    ここで気をつけるべきことがあります。inline ルールと個別リソース(aws_security_group_rule 等)を混在させてはいけないという点です。

    Terraform の AWS Provider は、inline と個別リソースを混在させると apply のたびにルールが消えたり戻ったりする「plan/apply の無限ループ」が起きることがあります。今回の Pulumi 側の問題(apply 時のルール消失)も、実はこの混在が遠因になっていた可能性が高い。

    今回は「既存の inline に合わせて inline で統一する」という方針で対処しました。将来的に aws_vpc_security_group_ingress_rule に寄せることも選択肢ですが、それは別の話として切り出すことにして、今回のスコープには含めませんでした。

    PRを分けた理由

    最初は Terraform と Pulumi の変更を 1 つの PR にまとめていました。

    しかし、これが問題でした。Terraform の apply と Pulumi のデプロイには依存関係があるのです。

    1. Terraform で Canary SG を作成(apply)
    2. 作成された SG の ID を Pulumi の設定ファイルに書き込む
    3. Pulumi side で既存の SG 管理コードを削除してデプロイ

    この順序を崩すと、Pulumi が存在しないはずの SG を destroy しようとしたり、新旧のルールが AWS 上で二重に存在したりする危険があります。

    チームのレビューアからも「Terraform と Pulumi で PR を分けてほしい」という要望があったこともあり、以下のように分割しました。

    • PR-A(Terraform側): Canary SG の新規作成 + 各 ECS SG への ingress ルール追加
    • PR-B(Pulumi側): SG 管理コードの削除 + Terraform で作成した SG ID を参照するように変更

    PR-B は PR-A の apply が完了するまでマージできないため、最初は Draft で出しておきました。PR-A の apply 後、実際に払い出された SG ID を設定ファイルに書き込んでから Ready for review に切り替える流れです。

    Terraform plan の確認

    PR-A の内容を dev 環境で検証するため、ローカルから terraform plan を実行しました。

    terraform plan -var-file ./tfvars/secrets_dev.tfvars

    結果のサマリ:

    Plan: 1 to add, 6 to change, 0 to destroy.
    • 追加(1件): Canary 用 SG の新規作成
    • 変更(6件): 各 ECS サービスの SG への ingress ルール追加(in-place update)
    • 削除(0件): 既存リソースへの破壊的変更なし

    destroy が 0 件というのが重要です。既存インフラを壊さずに追加のみで済んでいることを確認してから apply に進みました。

    plan 結果は PR のコメントにそのまま貼っておきました。「自分がどこまで確認したか」をログとして残しておくことで、レビュアが判断しやすくなりますし、後から振り返るときにも助かります。

    罠② IAM の eventual consistency

    PR-A のマージ・apply が完了し、PR-B の Pulumi デプロイを実行したところ、エラーが出ました。

    CREATE_FAILED: The provided execution role does not have permissions
    to call CreateNetworkInterface on EC2
    (AWSLambda; InvalidParameterValueException)

    5 つある Canary のうち、1 つだけが失敗しました。

    これは AWS IAM の eventual consistency が原因です。

    Pulumi の state を一度クリアして全リソースを新規作成したため、IAM ロールとポリシーの作成直後に Canary の作成が走りました。IAM の変更は即座に反映されるわけではなく、数十秒の伝播遅延があります。同じ IAM ロールを使う Canary が 5 つあって 4 つは成功し 1 つだけ失敗したのも、このタイミングのズレが原因です。

    対処:@pulumiverse/time で明示的な待機を挟む

    Terraform では time_sleep というリソースで待機を入れるパターンがよく使われます。Pulumi でも @pulumiverse/time という同様のパッケージが存在します。

    import * as time from "@pulumiverse/time";
    
    // IAM RolePolicy を変数に代入(dependsOn で参照するため)
    const canaryRolePolicy = new aws.iam.RolePolicy(`${namePrefix}-canary-policy-${environment}`, {
      name: `${namePrefix}-canary-policy-${environment}`,
      role: canaryRole.name,
      policy: /* ... */,
    });
    
    // IAM 反映待ち(eventual consistency 対策)
    // IAM ロール/ポリシー作成直後に Canary を作成すると
    // "CreateNetworkInterface permission not found" で失敗することがある
    // 30秒の sleep で安定させる
    const canaryIamPropagation = new time.Sleep(
      `${namePrefix}-canary-iam-propagation`,
      { createDuration: "30s" },
      { dependsOn: [canaryRolePolicy] },
    );
    
    // 各 Canary に dependsOn を追加
    new aws.synthetics.Canary(
      canaryName,
      { /* ... */ },
      {
        replaceOnChanges: ["runtimeVersion"],
        deleteBeforeReplace: true,
        dependsOn: [canaryIamPropagation],  // ← これを追加
      },
    );

    ポイントは canaryRolePolicy をいったん変数に代入することです。元のコードでは new aws.iam.RolePolicy(...) を変数に入れていなかったため、dependsOn で参照できない状態でした。

    この修正で typecheck・テストは全件パスしました。新規作成時に毎回 30 秒余分にかかりますが、既存リソースの更新時には走りません。許容範囲です。

    なお、この sleep パターンは AWS 公式が直接推奨しているものではなく、Terraform・Pulumi コミュニティで事実上の標準対処になっているパターンです。AWS ドキュメントは「IAM は eventual consistency である」とは述べていますが、sleep 秒数等の具体的な対処法までは案内していません。

    罠③ ERROR状態のCanaryが残るステート不整合

    IAM propagation 対策を入れて再度 pulumi up を実行したところ、今度は別のエラーが出ました。

    ConflictException: Canary is in a state that can't be modified: ERROR

    前回の pulumi up で Canary の作成が失敗したとき、AWS 上には該当 Canary が ERROR 状態で残ってしまっていました。Pulumi state 上は「存在する」扱いになっているため、今回は update として処理されましたが、AWS は ERROR 状態の Canary に対する変更を受け付けない。そのためこのエラーが出ていました。

    IAM propagation 対策自体は問題ではなく、前回失敗の残骸が邪魔をしていただけです。

    対処:AWS CLI で削除 → pulumi refresh で状態同期

    まず AWS CLI で ERROR 状態の Canary を直接削除します。

    # ERROR 状態の Canary を削除
    aws synthetics delete-canary \
      --name your-app-canary-service-dev \
      --region us-west-2

    次に Pulumi state と AWS の実態を同期させます。すでに削除済みのリソースが Pulumi state から自動的に除去されるため、pulumi state delete を手動で実行しなくても済みます。

    # Jenkins or CI経由で実行
    pulumi refresh  # 実リソースと state を同期
    pulumi preview  # 差分確認
    pulumi up       # 適用(IAM propagation 対策込みで Canary が再作成される)

    これで問題が解消し、dev 環境のデプロイが完了しました。

    振り返り:今日の作業でわかったこと

    IaCツール間の移行は「順序」が命

    Terraform apply → Pulumi deploy の順序を守ること、そしてその前提として PR を分割することが重要でした。1 つの PR に両方の変更を混ぜると、レビューも難しくなりますし、適用順序を間違えるリスクも上がります。

    Terraform の inline ingress と rule リソースは混在させない

    これが今回の Pulumi 側の問題の遠因でもありました。どちらかに統一する。迷ったらコードベースの既存方針に従う。

    AWS IAM の eventual consistency は本番でも起きる

    @pulumiverse/timetime.Sleep を使って 30 秒の待機を挟むのが、現時点での現実的な対処です。「本番でも同じ問題が起きる」と考えて、dev の段階で対策を入れておく判断は正しかったと思っています。

    ERROR状態リソースのクリーンアップを手順書に書いておく

    今回は dev 環境だったので柔軟に対処できましたが、本番で同じことが起きると深夜対応になりかねない。「失敗したらどうするか」を事前に手順書に書いておくことの重要性を改めて感じました。

    現在の状況と次のステップ

    • ✅ Terraform 側(Canary SG 作成 + ECS SG ingress ルール追加):dev apply 完了
    • ✅ Pulumi 側(SG 管理削除 + IAM propagation 対策):dev deploy 完了
    • 🟡 本番環境への適用:次のフェーズ

    本番環境への適用は、手動での SG 作成 → 設定ファイル更新 → Terraform apply → Pulumi deploy という手順で進める予定です。dev での試行錯誤のおかげで、踏みそうな罠はだいたい把握できた状態で臨めそうです。

    参考

    AWS の運用で踏みがちな罠やベストプラクティスを体系的に学びたい方には、以下の書籍が参考になります。今回のような IAM 周りの挙動やリソース管理の考え方を整理するのに役立ちます。

    Terraform を本格的に学びたい方には、こちらの書籍もおすすめです。

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について