AWS AppConfig＋Terraformでアプリケーションの設定情報を安全にデプロイする

# はじめに
AWS AppConfigは、AWSの設定情報を安全にデプロイするためのマネージドサービスだ。
アプリケーションの設定変更をカナリアリリースすることができ、デプロイ中の異常に対してワンクリックでの迅速なロールバックを提供することもできる。

https://docs.aws.amazon.com/ja_jp/appconfig/latest/userguide/what-is-appconfig.html

今回は、AWS AppConfigによる簡単な設定情報をTerraformで自動構築できるようにし、カナリアリリースにおける運用を考察する。

前提となる知識は、以下があれば良い。

- カタログレベルでのAWS AppConfigの知識
- Terraformの基礎知識
- Pythonの基礎知識(AWS AppConfigを呼び出すアプリケーションをPythonで作成)

# AWS AppConfigの構成要素
AWS AppConfigは、以下の構成要素から構築される。

|構成要素|説明|
|:---|:---|
|アプリケーション|複数の「設定プロファイル」を束ねる一番大きな単位。実際に設定を利用するアプリケーションの単位と考えると分かりやすい|
|設定プロファイル|複数の「ホストされた設定バージョン」を束ねたもの。たとえば、「予約値」「予約適用日」のように関連した設定を一括りにまとめるようなイメージ|
|ホストされた設定バージョン|設定プロファイルの実際の値を記述する部分|
|環境|設定プロファイルを適用する先の環境|
|デプロイ戦略|どういう設定でデプロイをするか(LINEARなのかEXPONENTIALなのか/どれくらいの時間をかけてデプロイするか/ベーキングの時間をどれくらい取るかの設定)
|デプロイ|どの環境にどのバージョンを何のデプロイ戦略でデプロイするかといったデプロイメントの設定情報|

分かりにくいので、AWSのマネージメントコンソール上でのまとまり方を図で整理すると以下のようになる。

![構成要素まとめ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/f470b7d9-5352-f17a-96e4-45cc9499a4af.png)

デプロイバージョン1の時にAWS AppConfigから値を取得すると```{ 'version': 'stable' }```が取得され、デプロイバージョン2の時に取得すると```{ 'version': 'new' }```となる。デプロイバージョン2のデプロイ中は、参照のタイミングによって値が混在して返されることになる。

# AWS AppConfigをTerraformで構築する
それぞれの構成要素をTerraformで書くと以下のようになる。

## アプリケーション
アプリケーションは```aws_appconfig_application```のリソースで表現する。
```description```は適宜分かりやすい説明を入れておこう。

```HCL
resource "aws_appconfig_application" "example" {
  name        = local.appconfig_application_name
  description = "Example AppConfig Application"
}
```

## 設定プロファイル
設定プロファイルは```aws_appconfig_configuration_profile```のリソースで表現する。
```location_uri```を設定して、この後ホストされた設定バージョンとの紐づけを行う。
```description```は適宜分かりやすい説明を(以下同文)

```HCL
resource "aws_appconfig_configuration_profile" "example" {
  name        = local.appconfig_configuration_profile_name
  description = "Example AppConfig Configuration Profile"

  application_id = aws_appconfig_application.example.id
  location_uri   = "hosted"
}
```

## ホストされた設定バージョン
ホストされた設定バージョンは```aws_appconfig_hosted_configuration_version```のリソースで表現する。
```content```には実際にアプリケーションに返す設定情報を記述しよう。
```description```は適宜分かりやすい(以下同文)

```HCL
resource "aws_appconfig_hosted_configuration_version" "example" {
  description = "Example AppConfig Hosted Configuration Version"

  application_id           = aws_appconfig_application.example.id
  configuration_profile_id = aws_appconfig_configuration_profile.example.configuration_profile_id

  content_type = "application/json"
  content = jsonencode({
    version = "stable",
  })
}
```

## 環境
環境は```aws_appconfig_environment```のリソースで表現する。
```description```は適宜(以下同文)

```HCL
resource "aws_appconfig_environment" "example" {
  name        = local.appconfig_environment_name
  description = "Example AppConfig Environment"

  application_id = aws_appconfig_application.example.id
}
```

## デプロイ戦略
デプロイ戦略は```aws_appconfig_deployment_strategy```のリソースで表現する。
```description```は(以下同文)

```final_bake_time_in_minutes```は、AWSマネージメントコンソールで「ベーキング」と表現される部分で、デプロイが100%新機能への切り替わりを迎えた後に、ロールバックのために何分間その状態で残しておくかという設定だ。Blue/GreenデプロイメントのBlue環境を残存させる期間と考えればよい。
この期間内であれば、マネージメントコンソールのロールバックボタンからワンタッチでのロールバックが行える。

```HCL
resource "aws_appconfig_deployment_strategy" "example" {
  name        = local.appconfig_deployment_strategy_name
  description = "Example AppConfig Deployment Strategy"

  deployment_duration_in_minutes = 10
  final_bake_time_in_minutes     = 1
  growth_factor                  = 10
  growth_type                    = "EXPONENTIAL"

  replicate_to = "NONE"
}
```

## デプロイメント
デプロイメントは```aws_appconfig_deployment```のリソースで表現する。
```description```(以下同文)
ここまで作ってきたもろもろのリソースを指定すれば、新しいデプロイバージョンが払い出されて、そのバージョンの応答として設定値が返却される。

```HCL
resource "aws_appconfig_deployment" "example" {
  description = "My example deployment"

  application_id           = aws_appconfig_application.example.id
  configuration_profile_id = aws_appconfig_configuration_profile.example.configuration_profile_id
  environment_id           = aws_appconfig_environment.example.environment_id
  deployment_strategy_id   = aws_appconfig_deployment_strategy.example.id
  configuration_version    = aws_appconfig_hosted_configuration_version.example.version_number
} 
```

# 実際にAWS AppConfigの設定を呼び出してみる
以下のようなAWS Lambda関数からAWS AppConfigを呼び出してみよう。

なお、AWS LambdaからAWS AppConfigを呼び出す場合、ユーザーガイドに従い、既にAWSが準備してくれているLambda Layerを呼び出す。Lambda Layerがプロキシ・キャッシュサーバとなって良い感じにAWS AppConfigとの通信を肩代わりしてくれる。

https://docs.aws.amazon.com/ja_jp/appconfig/latest/userguide/appconfig-integration-lambda-extensions.html

https://docs.aws.amazon.com/ja_jp/appconfig/latest/userguide/appconfig-integration-lambda-extensions-versions.html

```Python:script/lambda_example.py
"""Lambda Function Example for AWS AppConfig"""
from urllib import request
import ast
import json
import os

APPCONFIG_APPLICATION_NAME = os.environ['APPCONFIG_APPLICATION_NAME']
APPCONFIG_ENVIRONMENT_NAME = os.environ['APPCONFIG_ENVIRONMENT_NAME']
APPCONFIG_CONFIGURATION_PROFILE_NAME = os.environ['APPCONFIG_CONFIGURATION_PROFILE_NAME']

def lambda_handler(event, context):
  """Lambda Handler"""
  url = f'http://localhost:2772/applications/{APPCONFIG_APPLICATION_NAME}/environments/{APPCONFIG_ENVIRONMENT_NAME}/configurations/{APPCONFIG_CONFIGURATION_PROFILE_NAME}'
  config = {}

  with request.urlopen(url) as response:
    try:
      config = ast.literal_eval(response.read().decode())
    except Exception as e:
      print(e)

  return {
    'statusCode': '200',
    'body': json.dumps({
      'message': 'OK.',
      'version': config['version'],
    }),
  }
```

おためしのスクリプトは以上で、このスクリプトを以下のようにAWS Lambdaから呼び出せるようにする。
関数の実行は、Lambda Function URLを用いてGETリクエストで行えるようにする。

```HCL
data "archive_file" "lambda_example" {
  type        = "zip"
  output_path = "../output/lambda_example.zip"

  source {
    filename = "lambda_example.py"
    content  = file("../script/lambda_example.py")
  }
}

resource "aws_lambda_function" "example" {
  depends_on = [
    aws_cloudwatch_log_group.lambda,
  ]

  function_name    = local.lambda_function_name
  filename         = data.archive_file.lambda_example.output_path
  role             = aws_iam_role.lambda.arn
  handler          = "lambda_example.lambda_handler"
  source_code_hash = data.archive_file.lambda_example.output_base64sha256
  runtime          = "python3.10"

  memory_size = 128
  timeout     = 60

  layers = ["arn:aws:lambda:ap-northeast-1:980059726660:layer:AWS-AppConfig-Extension:125"]

  environment {
    variables = {
      APPCONFIG_APPLICATION_NAME = aws_appconfig_application.example.name
      APPCONFIG_ENVIRONMENT_NAME = aws_appconfig_environment.example.name
      APPCONFIG_CONFIGURATION_PROFILE_NAME = aws_appconfig_configuration_profile.example.name
    }
  }
}

resource "aws_lambda_function_url" "example" {
  function_name      = aws_lambda_function.example.function_name
  authorization_type = "NONE"
}
```

また、AWS Lambdaには以下のようにAWS AppConfigへのアクセス権を設定しておこう。
実際に必要な権限は、```appconfig:GetLatestConfiguration```、```appconfig:StartConfigurationSession```の２つだ。

```HCL
resource "aws_iam_role" "lambda" {
  name               = local.iam_lambda_role_name
  assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}

data "aws_iam_policy_document" "lambda_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "Service"
      identifiers = [
        "lambda.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role_policy" "lambda" {
  name   = local.iam_lambda_policy_name
  role   = aws_iam_role.lambda.id
  policy = data.aws_iam_policy_document.lambda_custom.json
}

data "aws_iam_policy_document" "lambda_custom" {
  statement {
    effect = "Allow"

    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents",
    ]

    resources = [
      aws_cloudwatch_log_group.lambda.arn,
      "${aws_cloudwatch_log_group.lambda.arn}:log-stream:*",
    ]
  }

  statement {
    effect = "Allow"

    actions = [
      "appconfig:GetLatestConfiguration",
      "appconfig:StartConfigurationSession",
    ]

    resources = [
      "${aws_appconfig_environment.example.arn}/configuration/${aws_appconfig_configuration_profile.example.configuration_profile_id}",
    ]
  }
}
```

これを```terraform apply```してから、curlでLambda Function URLを呼び出すと、```{"message": "OK.", "version": "stable"}```という出力が得られるはずだ。

これで、初回のデプロイは完了した。続いて、カナリアのデプロイを実行してみる。

# 2度目のデプロイをカナリアリリースで行う
## 2度目のデプロイ中にLambdaから設定値を取得する
2度目のデプロイをするために、以下の変更を行おう。
```aws_appconfig_hosted_configuration_version```の新しいバージョンを作成し、```aws_appconfig_deployment```から新しいリソースに向けた状態で```terraform apply```する。

```diff_HCL
+ resource "aws_appconfig_hosted_configuration_version" "example_new" {
+   description = "Example AppConfig Hosted Configuration New Version"
+ 
+   application_id           = aws_appconfig_application.example.id
+   configuration_profile_id = aws_appconfig_configuration_profile.example.configuration_profile_id
+ 
+   content_type = "application/json"
+   content = jsonencode({
+     version = "new",
+   })
+ }

resource "aws_appconfig_deployment" "example" {
  description = "My example deployment"

  application_id           = aws_appconfig_application.example.id
  configuration_profile_id = aws_appconfig_configuration_profile.example.configuration_profile_id
  environment_id           = aws_appconfig_environment.example.environment_id
  deployment_strategy_id   = aws_appconfig_deployment_strategy.example.id
- configuration_version    = aws_appconfig_hosted_configuration_version.example.version_number
+ configuration_version    = aws_appconfig_hosted_configuration_version.example_new.version_number
} 
```

すると、先ほどのLambda Function URLのレスポンスが```{"message": "OK.", "version": "new"}```に変わる。

この状態でマネージメントコンソールを見てみると、デプロイ中の状態となっている。

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/9833d153-ac2c-904a-b21d-b58ac005d56a.png)

しばらく待っていると、ステータスがベーキングとなり、

![キャプチャ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/646d4e27-f4bf-2747-471d-695d47b3b0d0.png)

さらに設定した時間(今回は１分)経過すると、完了となってロールバックが非活性化されて戻せない状態になる。

![キャプチャ3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/60bfc549-098c-563e-7ec9-348a9493b262.png)

これで、無事、デプロイは完了したと言える！

……と思いきや、デプロイの実行中にアプリの動作を検証してみると、一度```new```が返ってくると、二度と```stable```が返ってくることがなくなる。
これでは、カナリアリリースにならないではないか！

## どういうことなのか？
こういうときは、AWS AppConfigのAPI仕様書を確認してみよう。

https://docs.aws.amazon.com/ja_jp/appconfig/2019-10-09/APIReference/API_appconfigdata_GetLatestConfiguration.html#API_appconfigdata_GetLatestConfiguration_ResponseSyntax

> ***VersionLabel***
The user-defined label for the AWS AppConfig hosted configuration version. This attribute doesn't apply if the configuration is not from an AWS AppConfig hosted configuration version. If the client already has the latest version of the configuration data, this value is empty.
> ***訳) クライアントがすでに最新バージョンの構成データを持っている場合、この値は空です。***

というわけで、最新値の場合は何も情報が入ってこないようだ。

AWS AppConfigのカナリアリリースにおける設定値の推移のイメージは以下のカミナシ社の資料が分かりやすい。

https://speakerdeck.com/kaminashi/a-story-about-low-risk-low-stress-rollouts-with-aws-appconfig?slide=37

AWS AppConfigは、最初にセッションを作ってそれを持ち回っている都合上、セッション単位での切り替え状態を管理している、というのが正しい理解のようだ。

では、セッションを新しく張り直した場合はどうだろうか？

以下のようなスクリプトを作って試してみよう。

今度のスクリプトでは、GitHubで公開されているAWS AppConfig Helperを使用する。

https://github.com/aws-samples/sample-python-helper-aws-appconfig

通常の利用では、AppConfigHelper()で初期化した後は、update_config()でキャッシュ情報を読みつつ、キャッシュがexpireした時には初期化時のセッションを使ってAWS AppConfigに値を取りに行くという、エージェントと同等の動作をしてくれるが、今回はこのスクリプトを繰り返し呼ぶことで、都度新規セッションでの接続を試行してみる。

```Python
from appconfig_helper import AppConfigHelper
import pprint

appconfig = AppConfigHelper(
    "appconfig-terraform-example-application",
    "appconfig-terraform-example-environment",
    "appconfig-terraform-example-configuration-profile",
    45  # minimum interval between update checks
)

appconfig.update_config()
pprint.pprint(appconfig.config)
```

このスクリプトを上記のデプロイ中に動かして、値を集計すると、以下のようになる。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/5f7dcba4-9cc9-cfcb-11d6-63e95b3324f4.png)

流量が少ないせいか、最初の2,3分は```new```がほとんど発生していないが、徐々に```stable```から```new```に切り替わる動作となっているのが分かる。

ということで、新規のセッションであればちゃんとカナリアリリースの動作となることが分かった。
セッションが多くあるときも、結果的にキャッシュがexpireした後に参照する都度、セッション単位でのカナリア判定が行われることで徐々に切り替わっていく動作になるだろう。

ただし、キャッシュの時間を短くしたりするモデルでは、想定より早く切り替わってしまうのではないか、というのは気になった(ここは詳細な検証を行っていないので、実際にキャッシュの時間を短くする場合は検証をしてみることを推奨する)。

いずれにしろ、これで、AWS AppConfigを用いた安全なリリースを行えるようになった、と言えるだろう！

:::note warn
なお、一般的なクラウドネイティブのアプリケーションの作りにしていると、コンテナアプリケーションやAWS Lambda関数はステートレスな作りになっているだろう。
せっかくエージェントがセッション単位での新旧バージョンの値が新バージョンになった後、旧バージョンに戻ることがないようになっていても、ユーザトラフィック単位で見た場合に新旧の動作が混在になることは避けられない(ALBのスティッキーセッションの設定を使用している場合を除く)。

AWS AppConfigを使っているから安全、と過信せず、過渡期に新旧両バージョンが動作してもアプリケーションの整合性が保たれることは、都度設計を行って確認していくようにしよう。
:::

# AWS AppConfigのSLAについて
ユーザーガイドに書いてある通り、AWS AppConfigはAWS Systems Managerの一機能として提供されている。
AWS Systems Managerは、以下のSLAのサイトで謳われているとおり、***99.9%のSLA***となっている。

https://aws.amazon.com/jp/systems-manager/sla/?did=sla_card&trk=sla_card

ただし、前述した通り、AWS AppConfigのクライアントはエージェント型になっていて、

- 取得した情報をアプリケーション内にキャッシュするモデルである
- キャッシュ後に取得失敗したとしてもエラーにならず、前の値を継続して使用する

という特性から、安定動作しているアプリケーション上では決して低いSLAではないと言える(逆に、初回起動時にAWS AppConfigが障害中だったりすると起動ができないなどの影響があるため、注意が必要)。

プロダクション利用する際は、この辺りのSLAの情報とアプリケーションの作りを正しく理解し、AWSのSLAだけにとらわれないサービスレベルの定義を行おう。