はじめに

以前、以下のようなブログを書きました。CloudFront + S3 で構成されたアプリケーションに対して、CloudFront の継続的デプロイメント機能を用いてパイプラインを構成する話です。

これはこれで Blue/Green の動作が実現できていて便利なのですが、CloudFront の API 呼び出しやフロー制御をすべてシェルスクリプトで書いたので非常につらかったです。

記事の最後に「この辛いシェルスクリプトを Step Functions で置き換えられるか試す」と書いていますが、それを検証できていなかったので今回チャレンジしてみました。

これ以降、記事の煩雑さを軽減する目的で以下のように省略しています。

  • プライマリディストリビューション -> プライマリ
  • ステージングディストリビューション -> ステージング

前提

前提として、処理を以下の 2 つに分けてステートマシン化します。本記事では Configure に焦点を当て、Promote は別記事で紹介します。

  • Configure: ステージングを作成または有効化し、Continuous deployment を構成する (今回)
  • Promote: ステージングの設定でプライマリを上書きすることでリリースする (次回)

Configure については以下のフローに従ってステートマシン化します。このフロー自体は前の記事と変わりません。

フロー

なお、Configure の前段で S3 バケットの新しいプレフィックスにビルドアーティファクトをデプロイしますが、今回はスコープ外にしています。簡単にステートマシン化できるならそのようにするかもしれません。

フロー

実際に実装したものがこちらになります。

中央 – 上

ステート名 アクション 説明
Check application frontend version Systems Manager: GetParameter パラメータストアからデプロイ対象のアプリバージョン情報を取得
Check staging distribution exists Systems Manager: GetParameter パラメータストアからステージングの ID を取得
Choice for staging distribution Choice state ステージングの ID が有効な値か確認する

左 (すでにステージングがある場合の処理)

ステート名 アクション 説明
Get staging distribution from parameter CloudFront: GetDistribution ステージングから Continuous deployment policy ID を取得
Get continuous deployment policy CloudFront: GetContinuousDeploymentPolicy Continuous deployment policy を更新するための情報を取得
Enable continuous deployment policy CloudFront: UpdateContinuousDeploymentPolicy Continuous deployment policy を有効化する

右 (まだステージングがない場合の処理)

ステート名 アクション 説明
Get primary distribution CloudFront: GetDistribution のちの処理で使うプライマリの情報 (特に ETag) を取得
Create staging distribution CloudFront: CopyDistribution プライマリをコピーしてステージングを作成
Create continuous deployment policy CloudFront: CreateContinuousDeploymentPolicy ステージングの情報 (ドメイン名) を使って Continuous deployment policy を作成
Attach continuous deployment policy to primary distribution CloudFront: UpdateDistribution プライマリに Continuous deployment policy をアタッチする
Put parameter for staging distribution id Systems Manager: PutParameter ステージングの ID をパラメータストアに PUT する
Get staging distribution CloudFront: GetDistribution のちの処理で使うステージングの情報 (特に ETag) を取得

中央 – 下

ステート名 アクション 説明
Update S3 origin path for primary distribution CloudFront: UpdateDistribution S3 バケットのオリジンパスを上書きする

比較的シンプルなフローですが、肝はステージングがすでに存在するかどうかで分岐する点です。ステージングの ID をパラメータストアで保持する仕様にしており、初回処理時は dummy というプレースホルダーがセットされた状態です。CDK で構築する段階でそのように設定しています。

余談ですが、CDK でステージングと Continuous Deployment Policy をあらかじめ構成しておく方法も検証したのですが、以下の背景から採用せず、パイプラインで制御する方式にしています。

  • L2 コンストラクトから実現しようとするとエスケープハッチがかなり必要になる
  • Continuous Deployment Policy がアタッチされた状態でプライマリを構築することができない (未サポート)

初期パラメータ

ステートマシンに渡す初期パラメータは以下を想定しています。

{
  "ParameterKeyFrontendVersion": "<S3 オリジンパスに指定するアプリのバージョン情報が入った SSM パラメータの名前>",
  "ParameterKeyStagingDistributionId": "<ステージングの ID 情報が入った SSM パラメータの名前>",
  "PrimaryDistributionId": "<プライマリの ID>"
}

詳細

ステートの設定で重要な点を紹介します。

Choice for staging distribution

分岐は Step Functions 組み込みの Choice を使い、以下の条件でステージングの ID が入っているかチェックします。

このチェックで有効な ID (dummy 以外) が検出された場合は左に流れ、必要な情報を逐次取得して無効化されている Continuous deployment policy を有効化します。

今回ステージングは昇格後もそのままにしておいて、必要に応じて使い回す想定です。一度昇格すると自動で無効化されるので、このように有効化する処理が必要になります。

dummy が検出された場合は右に流れ、初回処理として判断してステージングと Continuous deployment policy をセットアップします。セットアップ後にはパラメータストアにステージングの ID を PUT し、次回からフローが左に流れるようにします。同じパラメータの上書きになるので、Overwrite フラグが必須です。

なお昇格後に毎回ステージングを削除する要件であれば、右のフローに一本化できると思います。一見こちらのほうが理にかなっているように見えますが、今回のように最終的にパイプライン化が必要で、かつ手動承認を挟む場合などは考慮するポイントが発生します。

Create staging distribution

CallerReference のように任意の値が必要な場合は、組み込み関数の States.UUID() を使ってユニーク値を生成してやればよいでしょう。組み込み関数はこちらに情報があります。

Update S3 origin path for primary distribution

いちばん苦しんだところです。UpdateDistribution には ETag のほかに、変更のない箇所も含めて DistributionConfig を丸ごと渡す必要があります。直前のステートの出力を以下のように設定して結果に含めたとして、

こんな感じですべてを渡すことで対応しました。部分的に値を更新する必要があり、バラして設定しなければならないのがつらいところです。

{
  "Id.$": "$.StagingDistribution.Id",
  "IfMatch.$": "$.StagingDistribution.ETag",
  "DistributionConfig": {
    "Aliases.$": "$.StagingDistribution.DistributionConfig.Aliases",
    "CacheBehaviors.$": "$.StagingDistribution.DistributionConfig.CacheBehaviors",
    "ContinuousDeploymentPolicyId.$": "$.StagingDistribution.DistributionConfig.ContinuousDeploymentPolicyId",
    "CallerReference.$": "$.StagingDistribution.DistributionConfig.CallerReference",
    "Comment.$": "$.StagingDistribution.DistributionConfig.Comment",
    "CustomErrorResponses.$": "$.StagingDistribution.DistributionConfig.CustomErrorResponses",
    "DefaultCacheBehavior.$": "$.StagingDistribution.DistributionConfig.DefaultCacheBehavior",
    "DefaultRootObject.$": "$.StagingDistribution.DistributionConfig.DefaultRootObject",
    "Enabled.$": "$.StagingDistribution.DistributionConfig.Enabled",
    "HttpVersion.$": "$.StagingDistribution.DistributionConfig.HttpVersion",
    "IsIPV6Enabled.$": "$.StagingDistribution.DistributionConfig.IsIPV6Enabled",
    "Logging.$": "$.StagingDistribution.DistributionConfig.Logging",
    "OriginGroups.$": "$.StagingDistribution.DistributionConfig.OriginGroups",
    "Origins": {
      "Items": [
        {
          "ConnectionAttempts.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].ConnectionAttempts",
          "ConnectionTimeout.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].ConnectionTimeout",
          "CustomHeaders.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].CustomHeaders",
          "DomainName.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].DomainName",
          "Id.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].Id",
          "OriginAccessControlId.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].OriginAccessControlId",
          "OriginPath.$": "States.Format('/{}', $.Parameter.Get.FrontendVersion.Value)",
          "OriginShield.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].OriginShield",
          "S3OriginConfig.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].S3OriginConfig"
        }
      ],
      "Quantity": 1
    },
    "PriceClass.$": "$.StagingDistribution.DistributionConfig.PriceClass",
    "Restrictions.$": "$.StagingDistribution.DistributionConfig.Restrictions",
    "Staging.$": "$.StagingDistribution.DistributionConfig.Staging",
    "ViewerCertificate.$": "$.StagingDistribution.DistributionConfig.ViewerCertificate",
    "WebACLId.$": "$.StagingDistribution.DistributionConfig.WebACLId"
  }
}

肝は Origins のところですが、Items 配下が配列になっているので、添字でアクセスして最初の要素を取得しています。今回は S3 オリジンがひとつだけある想定なので最初の要素だけ取得すればいいのですが、全要素をイテレーションしたい場合は Map を使うことになります。

"Id.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].Id",

今回 OriginPath で参照させるコンテンツを制御するので、以下のような形でプレフィックスとして / を付与して対応しました。

"OriginPath.$": "States.Format('/{}', $.Parameter.Get.FrontendVersion.Value)",

パラメータストアから取得した値は以下のような形で保持しているので、それを使っています。

補足

前の記事でも書いていますが、CloudFront 関連の自動化を煩雑にしている原因のひとつに IfMatch の面倒さが挙げられます。UpdateDistribution系の API には IfMatch というパラメータを渡す必要があります。これは事前に GetDistribution系の API で ETag を取得し、それを IfMatch に渡さなければならないことを意味します。とはいえ、この対応をシェルスクリプトでやるよりはずっとマシでした。

全体像

ASL はこんな感じになりました。

{
  "Comment": "Configure",
  "StartAt": "Check application frontend version",
  "States": {
    "Check application frontend version": {
      "Type": "Task",
      "Next": "Check staging distribution exists",
      "Parameters": {
        "Name.$": "$.ParameterKeyFrontendVersion"
      },
      "Resource": "arn:aws:states:::aws-sdk:ssm:getParameter",
      "ResultSelector": {
        "Key.$": "$.Parameter.Name",
        "Value.$": "$.Parameter.Value"
      },
      "ResultPath": "$.Parameter.Get.FrontendVersion"
    },
    "Check staging distribution exists": {
      "Type": "Task",
      "Parameters": {
        "Name.$": "$.ParameterKeyStagingDistributionId"
      },
      "Resource": "arn:aws:states:::aws-sdk:ssm:getParameter",
      "ResultSelector": {
        "Id.$": "$.Parameter.Value"
      },
      "ResultPath": "$.StagingDistribution",
      "Next": "Choice for staging distribution"
    },
    "Choice for staging distribution": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.StagingDistribution.Id",
          "StringEquals": "dummy",
          "Next": "Get primary distribution"
        }
      ],
      "Default": "Get staging distribution from parameter"
    },
    "Get staging distribution from parameter": {
      "Type": "Task",
      "Next": "Get continuous deployment policy",
      "Parameters": {
        "Id.$": "$.StagingDistribution.Id"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:getDistribution",
      "ResultSelector": {
        "Id.$": "$.Distribution.Id",
        "DistributionConfig.$": "$.Distribution.DistributionConfig",
        "ETag.$": "$.ETag"
      },
      "ResultPath": "$.StagingDistribution"
    },
    "Get continuous deployment policy": {
      "Type": "Task",
      "Next": "Enable continuous deployment policy",
      "Parameters": {
        "Id.$": "$.StagingDistribution.DistributionConfig.ContinuousDeploymentPolicyId"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:getContinuousDeploymentPolicy",
      "ResultSelector": {
        "Id.$": "$.ContinuousDeploymentPolicy.Id",
        "ContinuousDeploymentPolicyConfig.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig",
        "ETag.$": "$.ETag"
      },
      "ResultPath": "$.ContinuousDeploymentPolicy"
    },
    "Enable continuous deployment policy": {
      "Type": "Task",
      "Next": "Update S3 origin path for primary distribution",
      "Parameters": {
        "IfMatch.$": "$.ContinuousDeploymentPolicy.ETag",
        "ContinuousDeploymentPolicyConfig": {
          "Enabled": "true",
          "StagingDistributionDnsNames": {
            "Quantity.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig.StagingDistributionDnsNames.Quantity",
            "Items.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig.StagingDistributionDnsNames.Items"
          },
          "TrafficConfig.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig.TrafficConfig"
        },
        "Id.$": "$.ContinuousDeploymentPolicy.Id"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:updateContinuousDeploymentPolicy",
      "ResultSelector": {
        "Id.$": "$.ContinuousDeploymentPolicy.Id",
        "ContinuousDeploymentPolicyConfig.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig",
        "ETag.$": "$.ETag"
      },
      "ResultPath": "$.ContinuousDeploymentPolicy"
    },
    "Get primary distribution": {
      "Type": "Task",
      "Next": "Create staging distribution",
      "Parameters": {
        "Id.$": "$.PrimaryDistributionId"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:getDistribution",
      "ResultSelector": {
        "Id.$": "$.Distribution.Id",
        "DistributionConfig.$": "$.Distribution.DistributionConfig",
        "ETag.$": "$.ETag"
      },
      "ResultPath": "$.PrimaryDistribution"
    },
    "Create staging distribution": {
      "Type": "Task",
      "Parameters": {
        "PrimaryDistributionId.$": "$.PrimaryDistributionId",
        "CallerReference.$": "States.UUID()",
        "Staging": "True",
        "IfMatch.$": "$.PrimaryDistribution.ETag"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:copyDistribution",
      "Next": "Create continuous deployment policy",
      "ResultSelector": {
        "Id.$": "$.Distribution.Id",
        "DomainName.$": "$.Distribution.DomainName"
      },
      "ResultPath": "$.StagingDistribution"
    },
    "Create continuous deployment policy": {
      "Type": "Task",
      "Parameters": {
        "ContinuousDeploymentPolicyConfig": {
          "Enabled": true,
          "StagingDistributionDnsNames": {
            "Quantity": 1,
            "Items.$": "States.Array($.StagingDistribution.DomainName)"
          },
          "TrafficConfig": {
            "SingleHeaderConfig": {
              "Header": "aws-cf-cd-staging",
              "Value": "true"
            },
            "Type": "SingleHeader"
          }
        }
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:createContinuousDeploymentPolicy",
      "Next": "Attach continuous deployment policy to primary distribution",
      "ResultPath": "$.ContinuousDeploymentPolicy",
      "ResultSelector": {
        "Id.$": "$.ContinuousDeploymentPolicy.Id",
        "ContinuousDeploymentPolicyConfig.$": "$.ContinuousDeploymentPolicy.ContinuousDeploymentPolicyConfig",
        "ETag.$": "$.ETag"
      }
    },
    "Attach continuous deployment policy to primary distribution": {
      "Type": "Task",
      "Parameters": {
        "Id.$": "$.PrimaryDistributionId",
        "IfMatch.$": "$.PrimaryDistribution.ETag",
        "DistributionConfig": {
          "Aliases.$": "$.PrimaryDistribution.DistributionConfig.Aliases",
          "CacheBehaviors.$": "$.PrimaryDistribution.DistributionConfig.CacheBehaviors",
          "ContinuousDeploymentPolicyId.$": "$.ContinuousDeploymentPolicy.Id",
          "CallerReference.$": "$.PrimaryDistribution.DistributionConfig.CallerReference",
          "Comment.$": "$.PrimaryDistribution.DistributionConfig.Comment",
          "CustomErrorResponses.$": "$.PrimaryDistribution.DistributionConfig.CustomErrorResponses",
          "DefaultCacheBehavior.$": "$.PrimaryDistribution.DistributionConfig.DefaultCacheBehavior",
          "DefaultRootObject.$": "$.PrimaryDistribution.DistributionConfig.DefaultRootObject",
          "Enabled.$": "$.PrimaryDistribution.DistributionConfig.Enabled",
          "HttpVersion.$": "$.PrimaryDistribution.DistributionConfig.HttpVersion",
          "IsIPV6Enabled.$": "$.PrimaryDistribution.DistributionConfig.IsIPV6Enabled",
          "Logging.$": "$.PrimaryDistribution.DistributionConfig.Logging",
          "OriginGroups.$": "$.PrimaryDistribution.DistributionConfig.OriginGroups",
          "Origins.$": "$.PrimaryDistribution.DistributionConfig.Origins",
          "PriceClass.$": "$.PrimaryDistribution.DistributionConfig.PriceClass",
          "Restrictions.$": "$.PrimaryDistribution.DistributionConfig.Restrictions",
          "Staging.$": "$.PrimaryDistribution.DistributionConfig.Staging",
          "ViewerCertificate.$": "$.PrimaryDistribution.DistributionConfig.ViewerCertificate",
          "WebACLId.$": "$.PrimaryDistribution.DistributionConfig.WebACLId"
        }
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:updateDistribution",
      "Next": "Put parameter for staging distribution id",
      "ResultPath": "$.PrimaryDistribution",
      "ResultSelector": {
        "DistributionConfig.$": "$.Distribution.DistributionConfig",
        "ETag.$": "$.ETag"
      }
    },
    "Put parameter for staging distribution id": {
      "Type": "Task",
      "Parameters": {
        "Name.$": "$.ParameterKeyStagingDistributionId",
        "Value.$": "$.StagingDistribution.Id",
        "Type": "String",
        "Overwrite": "true"
      },
      "Resource": "arn:aws:states:::aws-sdk:ssm:putParameter",
      "Next": "Get staging distribution",
      "ResultSelector": {
        "Version.$": "$.Version"
      },
      "ResultPath": "$.Parameter.Put.StagingDistribution"
    },
    "Get staging distribution": {
      "Type": "Task",
      "Next": "Update S3 origin path for primary distribution",
      "Parameters": {
        "Id.$": "$.StagingDistribution.Id"
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:getDistribution",
      "ResultSelector": {
        "Id.$": "$.Distribution.Id",
        "DistributionConfig.$": "$.Distribution.DistributionConfig",
        "ETag.$": "$.ETag"
      },
      "ResultPath": "$.StagingDistribution"
    },
    "Update S3 origin path for primary distribution": {
      "Type": "Task",
      "Parameters": {
        "Id.$": "$.StagingDistribution.Id",
        "IfMatch.$": "$.StagingDistribution.ETag",
        "DistributionConfig": {
          "Aliases.$": "$.StagingDistribution.DistributionConfig.Aliases",
          "CacheBehaviors.$": "$.StagingDistribution.DistributionConfig.CacheBehaviors",
          "ContinuousDeploymentPolicyId.$": "$.StagingDistribution.DistributionConfig.ContinuousDeploymentPolicyId",
          "CallerReference.$": "$.StagingDistribution.DistributionConfig.CallerReference",
          "Comment.$": "$.StagingDistribution.DistributionConfig.Comment",
          "CustomErrorResponses.$": "$.StagingDistribution.DistributionConfig.CustomErrorResponses",
          "DefaultCacheBehavior.$": "$.StagingDistribution.DistributionConfig.DefaultCacheBehavior",
          "DefaultRootObject.$": "$.StagingDistribution.DistributionConfig.DefaultRootObject",
          "Enabled.$": "$.StagingDistribution.DistributionConfig.Enabled",
          "HttpVersion.$": "$.StagingDistribution.DistributionConfig.HttpVersion",
          "IsIPV6Enabled.$": "$.StagingDistribution.DistributionConfig.IsIPV6Enabled",
          "Logging.$": "$.StagingDistribution.DistributionConfig.Logging",
          "OriginGroups.$": "$.StagingDistribution.DistributionConfig.OriginGroups",
          "Origins": {
            "Items": [
              {
                "ConnectionAttempts.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].ConnectionAttempts",
                "ConnectionTimeout.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].ConnectionTimeout",
                "CustomHeaders.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].CustomHeaders",
                "DomainName.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].DomainName",
                "Id.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].Id",
                "OriginAccessControlId.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].OriginAccessControlId",
                "OriginPath.$": "States.Format('/{}', $.Parameter.Get.FrontendVersion.Value)",
                "OriginShield.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].OriginShield",
                "S3OriginConfig.$": "$.StagingDistribution.DistributionConfig.Origins.Items[0].S3OriginConfig"
              }
            ],
            "Quantity": 1
          },
          "PriceClass.$": "$.StagingDistribution.DistributionConfig.PriceClass",
          "Restrictions.$": "$.StagingDistribution.DistributionConfig.Restrictions",
          "Staging.$": "$.StagingDistribution.DistributionConfig.Staging",
          "ViewerCertificate.$": "$.StagingDistribution.DistributionConfig.ViewerCertificate",
          "WebACLId.$": "$.StagingDistribution.DistributionConfig.WebACLId"
        }
      },
      "Resource": "arn:aws:states:::aws-sdk:cloudfront:updateDistribution",
      "Next": "Success"
    },
    "Success": {
      "Type": "Succeed"
    }
  }
}

おわりに

CloudFront の継続的デプロイについて、今回は特にステージングディストリビューションの作成や Continuous depolyment policy の設定など、事前準備の部分について紹介しました。Step Functions を使うことで、シェルスクリプトでがんばっていた処理が JSON の定義ファイルに置き換わりました。もっと早く検証しておけばよかったです。

次回は Promote を紹介します。