AWSCDK

AWS CDK PythonでTerraformのバックエンドを構築する

AWS CDK を使うとメジャーなプログラミング言語でAWSインフラを構築できるようになります。

記事執筆時点でサポートしているプログラミング言語は TypeScript, JavaScript, Python, Java, C# と開発者プレビュー段階のGoとなります。

今回はPythonでCDKを記述して、TerraformのバックエンドとなるS3とDynamoDBを構築してみます。

スポンサーリンク

前提

いくつか事前準備が必要となります。

ローカルPCのAWS認証情報の設定

ローカルPCのAWS認証情報を使用します。

以下の記事を参考にしながらAWSプロファイルを設定してください。こちらのTipsのようにAWS Vault経由でもCDKを使用できます。

Node.jsのインストール

CDK自体はNode.jsで動いてるようです。Node.js10.13以上(13.0.0から13.6.0を除く)をインストールします。

Pythonのインストール

CDKはPython 3.6以上に対応しています。

CDKの使用方法

CDKのインストール

npmでグローバル環境にcdkをインストールします。

$ npm install -g aws-cdk
...
$ cdk --version
2.22.0 (build 1db4b16)
$

Python周りの必須パッケージのインストール

pipやPythonの仮想環境をインストールします。

$ python -m ensurepip --upgrade
...
$ python -m pip install --upgrade pip
...
$ python -m pip install --upgrade virtualenv
...
$

CDKプロジェクトの作成

cdk initコマンドでCDKプロジェクトを作成します。

$ mkdir terraform_backend
$ cd terraform_backend
$ cdk init app --language python
...
$
## 以下のようなディレクトリが作成される
$ ls -la
total 56
drwxr-xr-x  13 zoo200  staff   416  4 30 12:33 .
drwxr-xr-x   5 zoo200  staff   160  4 30 12:33 ..
drwxr-xr-x  12 zoo200  staff   384  4 30 12:33 .git
-rw-r--r--   1 zoo200  staff   119  4 30 12:33 .gitignore
drwxr-xr-x   6 zoo200  staff   192  4 30 12:33 .venv
-rw-r--r--   1 zoo200  staff  1658  4 30 12:33 README.md
-rw-r--r--   1 zoo200  staff   975  4 30 12:33 app.py
-rw-r--r--   1 zoo200  staff   871  4 30 12:33 cdk.json
-rw-r--r--   1 zoo200  staff    14  4 30 12:33 requirements-dev.txt
-rw-r--r--   1 zoo200  staff    47  4 30 12:33 requirements.txt
-rw-r--r--   1 zoo200  staff   437  4 30 12:33 source.bat
drwxr-xr-x   4 zoo200  staff   128  4 30 12:33 terraform_backend
drwxr-xr-x   4 zoo200  staff   128  4 30 12:33 tests
$

初期化完了後、Python仮想環境を起動しAWS CDKパッケージをインストールします。

$ source .venv/bin/activate
(.venv) $
(.venv) $ python -m pip install -r requirements.txt
...
(.venv) $

バックエンド構築用CDKファイルの作成

terraform_backend/terraform_backend_stack.py というファイルが既に用意されています。これを以下のように修正します。

(.venv) $ vi terraform_backend/terraform_backend_stack.py
from aws_cdk import (
    Stack,
    RemovalPolicy,
    aws_s3,
    aws_dynamodb,
)
from constructs import Construct
import os

class TerraformBackendStack(Stack):

    ## 適宜変更
    ORGANIZATION = 'zoo200'
    PREFIX = 'terraform-backend'

    def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
        super().__init__(scope, construct_id, **kwargs)

        resouce_name = '-'.join([self.ORGANIZATION, self.PREFIX])
        class_name = self.__class__.__name__

        ## tfstate保存用のS3バケット
        aws_s3.Bucket(
            self,
            class_name + 'S3Bucket',
            versioned=True,
            bucket_name=resouce_name,
            encryption=aws_s3.BucketEncryption.S3_MANAGED,
            block_public_access=aws_s3.BlockPublicAccess(
                    block_public_acls=True,
                    block_public_policy=True,
                    ignore_public_acls=True,
                    restrict_public_buckets=True
            ),
            removal_policy=RemovalPolicy.DESTROY,
        )

        ## Terraform実行時の排他制御用のDynamoDB
        aws_dynamodb.Table(
            self,
            class_name + 'DynamoDB',
            table_name=resouce_name,
            partition_key=aws_dynamodb.Attribute(
                name="LockID",
                type=aws_dynamodb.AttributeType.STRING
            ),
            removal_policy=RemovalPolicy.DESTROY,
            billing_mode=aws_dynamodb.BillingMode.PAY_PER_REQUEST
        )
(.venv) $

ORGANIZATION と PREFIX はS3のバケット名に使用するため、グローバルで一意になるように適宜変更してください。

S3は状態情報が誤って削除されるなどの事故に備えてバージョニングを有効にしています。

DynamoDBは LockID という名前でString型のパーティションキーを作成しています。この設定がないとTerraformロックが動作しません。

またこちらの記事を見る限り、バックエンド用途ではオンデマンドのほうがコストを節約できそうなのでBillingMode.PAY_PER_REQUESTとしています。

CDKの実行

cdk bootstrap

各AWS環境で最初の一回のみ cdk bootstrap を実行します。

CDKToolkitというCloudFormationのスタックが作成され、そのスタックからCDKの実行に必要なS3やIAMロールが作成されます。

(.venv) $ cdk bootstrap
...
(.venv) $

cdk synth

cdk synth (synthesize 合成という意味) で実際に実行されるCloudFormationのテンプレートを確認できます。

(.venv) $ cdk synth
Resources:
  TerraformBackendStackS3Bucket678E59F6:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: zoo200-terraform-backend
...
  TerraformBackendStackDynamoDB63FEF5FF:
    Type: AWS::DynamoDB::Table
    Properties:
...
      TableName: zoo200-terraform-backend
...
(.venv) $

cdk deploy

cdk deploy を実行するとPythonのクラス名(今回はTerraformBackendStack)でCloudFormationのスタックが作成され、そのスタックからTerraformバックエンド用のS3とDynamoDBが作成されます。

(.venv) $ cdk deploy
...
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:123456789012:stack/TerraformBackendStack/1b27xxx-yyy-zzz
...
(.venv) $

デプロイが完了したらPythonの仮想環境を停止します。

(.venv) $ deactivate
...
$

Terraformの実行確認

Terraformを実行してバックエンドにS3が使用され排他ロックが動作するか確認します。

Terraform実行ファイル作成

S3をバックエンドとしてVPCを作成します。

$ vi demo.tf
terraform {
  backend "s3" {
    bucket = "zoo200-terraform-backend"
    region = "ap-northeast-1"
    key = "vpc/terraform.tfstate"
    encrypt = true
    dynamodb_table = "zoo200-terraform-backend"
  }
}

resource "aws_vpc" "vpc" {
  cidr_block                       = "10.1.0.0/16"

  tags = {
   Name = "demo-vpc"
 }
}
$

Terraform実行

vpcが正常に作成されます。

$ terraform init
...
$
$ terraform apply
...
$

Terraform状態情報の確認

S3に保存されたtfstateファイルをローカルPCにダウンロードすると正常に状態情報が保存されていることが確認できます。

$ aws s3 ls s3://zoo200-terraform-backend/vpc/terraform.tfstate
2022-04-30 15:18:46       1706 terraform.tfstate
$
$ aws s3 cp s3://zoo200-terraform-backend/vpc/terraform.tfstate .
download: s3://zoo200-terraform-backend/vpc/terraform.tfstate to ./terraform.tfstate
$
$ cat ./terraform.tfstate
{
  "version": 4,
  "terraform_version": "0.13.5",
  "serial": 0,
  "lineage": "8f48xxx-yyy-zzz",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "aws_vpc",
      "name": "vpc",
...
$

S3のバージョニングを有効しているため、以下のようにtfstateファイルの世代管理もできています。

$ aws s3api list-object-versions --bucket zoo200-terraform-backend
{
    "Versions": [
        {
            "ETag": "\"d9d6xxx\"",
            "Size": 1702,
            "StorageClass": "STANDARD",
            "Key": "vpc/terraform.tfstate",
            "VersionId": "21GIyyy",
            "IsLatest": true,
...
        },
        {
            "ETag": "\"8a67xxx\"",
            "Size": 1706,
            "StorageClass": "STANDARD",
            "Key": "vpc/terraform.tfstate",
            "VersionId": "Ryshyyy",
            "IsLatest": false,
...
        }
    ]
}
$

作成されたリソースの削除

Terraformで作成されたリソースの削除

まずterraform destroyコマンドでTerraformによって作成されたリソースを削除します。

$ terraform destroy
...
  Enter a value: yes          -> yesを入力
...
$

バックエンド用リソースの削除

次にcdk deployコマンドで作成されたバックエンド用のS3とDynamoDBをcdk destroyコマンドで削除します。

注意点として削除前にS3バケットを空にする必要があります。更にバージョニングを有効化しているため過去のバージョンの状態ファイルもすべて削除しなければなりません

AWSマネジメントコンソールで、S3バケット選択 → [ 空にする ] → [ 完全に削除 ] と入力 → [ 空にする ] をクリックして一括削除するか、こちらの記事で紹介したようなAWS CLIコマンドを実行して一括削除してください。

(.venv) $ bucket=zoo200-terraform-backend && aws s3api list-object-versions --bucket $bucket | jq -r '.Versions[] | [.Key, .VersionId] |@tsv' | while read k v;do aws s3api delete-object --bucket $bucket --key $k --version-id $v; done
...
(.venv) $
## S3バケットを空にした後にdestroyを実行
(.venv) $ cdk destroy
Are you sure you want to delete: TerraformBackendStack (y/n)? y         -> yを入力
...
(.venv) $

以下のようにs3 rm --recursiveを実行しても論理的にオブジェクトが削除される(削除マーカーが付く)のみで、物理的にはS3上に存在するためcdk destroyがエラーとなります。

(.venv) $ aws s3 rm s3://zoo200-terraform-backend --recursive
delete: s3://zoo200-terraform-backend/vpc/terraform.tfstate
(.venv) $
(.venv) $ cdk destroy
Are you sure you want to delete: TerraformBackendStack (y/n)? y
TerraformBackendStack: destroying...
4:11:07 | DELETE_FAILED        | AWS::S3::Bucket      | TerraformBackendStackS3Bucket678E59F6
The bucket you tried to delete is not empty. You must delete all versions in the bucket. (Service: Amazon S3; Status Code: 409; Error Code: BucketNotEmpty; Request ID: RJ1CCNxxxx;
S3 Extended Request ID: sxKpF6xxxx; Proxy: null)


 ❌  TerraformBackendStack: destroy failed Error: The stack named TerraformBackendStack is in a failed state. You may need to delete it from the AWS console : DELETE_FAILED (The following resource(s) failed to delete: [TerraformBackendStackS3Bucket678E59F6]. ): The bucket you tried to delete is not empty. You must delete all versions in the bucket. (Service: Amazon S3; Status Code: 409; Error Code: BucketNotEmpty; Request ID: RJ1CCNxxxx; S3 Extended Request ID: sxKpF6xxxx; Proxy: null)
...
The stack named TerraformBackendStack is in a failed state. You may need to delete it from the AWS console : DELETE_FAILED (The following resource(s) failed to delete: [TerraformBackendStackS3Bucket678E59F6]. ): The bucket you tried to delete is not empty. You must delete all versions in the bucket. (Service: Amazon S3; Status Code: 409; Error Code: BucketNotEmpty; Request ID: RJ1CCNFSRW3VEPJG; S3 Extended Request ID: sxKpF6xxxx; Proxy: null)
(.venv) $

CDK用リソースの削除

最後にcdk bootstrapコマンドで作成されたCDK用のS3やIAMロールを削除します。専用の削除コマンドがなさそうなので普通にCloudFormationのスタックを削除します。

ただCDK用S3バケットもバージョニングが有効化されているので、これもバケットを空にしてからスタックを削除します。

$ bucket=cdk-hnb65xxxx-assets-123456789012-ap-northeast-1 && aws s3api list-object-versions --bucket $bucket | jq -r '.Versions[] | [.Key, .VersionId] |@tsv' | while read k v;do aws s3api delete-object --bucket $bucket --key $k --version-id $v; done
...
$
$ aws cloudformation delete-stack --stack-name CDKToolkit
$


Tips

DynamoDBの中身と排他ロック

DynamoDBにはS3オブジェクトのエンティティタグ(Etag)の値が保存されます。

$ aws dynamodb scan --table-name zoo200-terraform-backend
{
    "Items": [
        {
            "Digest": {
                "S": "d9d6xxx"
            },
            "LockID": {
                "S": "zoo200-terraform-backend/vpc/terraform.tfstate-md5"
            }
        }
    ],
    "Count": 1,
    "ScannedCount": 1,
    "ConsumedCapacity": null
}
$

Terraform実行時には、Infoという属性を持つアイテムを新規に作成してロックをかけているようです。

$ aws dynamodb scan --table-name zoo200-terraform-backend
{
    "Items": [
        {
            "Digest": {
                "S": "d9d6xxx"
            },
            "LockID": {
                "S": "zoo200-terraform-backend/vpc/terraform.tfstate-md5"
            }
        },
        {
            "LockID": {
                "S": "zoo200-terraform-backend/vpc/terraform.tfstate"
            },
            "Info": {
                "S": "{\"ID\":\"e0e6xxx-yyy-zzz\",\"Operation\":\"OperationTypeApply\",\"Info\":\"\",\"Who\":\"zoo200@local\",\"Version\":\"0.13.5\",\"Created\":\"2022-04-30T06:58:52.006886Z\",\"Path\":\"zoo200-terraform-backend/vpc/terraform.tfstate\"}"
            }
        }
    ],
    "Count": 2,
    "ScannedCount": 2,
    "ConsumedCapacity": null
}

ロックがかかっている状態で別のターミナルからTerraformを実行すると以下のように実行エラーとなります。

$ terraform apply
Error: Error locking state: Error acquiring the state lock: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        e0e6xxx-yyy-zzz
  Path:      zoo200-terraform-backend/vpc/terraform.tfstate
  Operation: OperationTypeApply
  Who:       zoo200@local
  Version:   0.13.5
  Created:   2022-04-30 06:58:52.006886 +0000 UTC
  Info:


Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.

$
## ロックを解除するとInfoのアイテムが削除される
$ terraform force-unlock e0e6xxx-yyy-zzz
Do you really want to force-unlock?
  Terraform will remove the lock on the remote state.
  This will allow local Terraform commands to modify this state, even though it
  may be still be in use. Only 'yes' will be accepted to confirm.

  Enter a value: yes

Terraform state has been successfully unlocked!

The state has been unlocked, and Terraform commands should now be able to
obtain a new lock on the remote state.
$

AWS Vaultを通してCDKを使用

AWS Vaultで2段階認証(MFA)制限のスイッチロールを行いつつ、CDKを使用できます。

(.venv) $ aws-vault exec zoo200 -- cdk deploy
Enter token for arn:aws:iam::123456789012:mfa/zoo200: 123456  -> MFAコードを入力
...
(.venv) $

今回は以上です〜ノシ

参考

(`・ω・´)ノ アリガトウゴザイマス!!

AWS Cloud Development Kit (CDK) v2
Terraform Backend S3
TerraformでtfstateファイルをS3で管理する