Djangoで作ったWebアプリをAWS Fargateでデプロイした（Terraform編）

# はじめに
以前、AWSコンソールよりインフラを定義し、Djangoで作ったWebアプリをAWS Fargateでデプロイしました。
今回はTerraformよりインフラを定義し、同じようにデプロイしてみました。

# 対象
Djangoアプリはこちら。

https://qiita.com/So1111/items/a9b14141833db9dfb502

# アーキテクチャ
せっかくなので前回とは違うリソースで構築してみます。主に図の赤字のところです。
- Private SubnetからVPC外への接続はEndpointではなくNAT経由のアクセスとする
- PostgresのデータはEFSではなくRDSへ保存する
- 割高なCloudWatchのログをKinesisを仲介してS3に永続化する
- ストレージ等の暗号化のための暗号鍵はKMSで管理する
- デバッグ用のオペレーションサーバーとしてEC2を置く

![Architecture3.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/c667e035-1bc5-4b5e-bf5f-a566a962bb3f.jpeg)

# Terraform基本文法
よく使う文法です。
```main.tf
# リソースの記述
# ブロック名(resource)によりラベル(aws_sqs_queue, my_queue)の数が決まる
resource "aws_sqs_queue" "my_queue" {
  # <key = value> or <他のブロック>を記述
  name = "test-queue-tf"
}

# 同一モジュール内(一つのディレクトリ内)でのリソース属性の参照 → [リソースのアドレス].[属性]
# "aws_sqs_queue" と "my_queue" を.で繋いだものがアドレス
resource "xxx" "xxx" {
  url = aws_sqs_queue.my_queue.url
}

# 既存リソースの参照
data "aws_sqs_queue" "example" {
 name = "sqs-test"
}
```

子モジュールの参照では子側で変数のvariable、出力のoutput、親側で参照のmoduleをよく使います。
```:ファイルレイアウト
.
├── iam_role
│   └── main.tf
└── main.tf
```
```子側：iam_role/main.tf
# IAMロールモジュールの定義
variable "name" {}
variable "policy" {}

resource "aws_iam_role" "default" {
  # 親モジュールより渡された変数を使う
  name = var.name
}

output "iam_role_arn" {
  value = aws_iam_role.default.arn
}
```
```親側：main.tf
# ECSタスク実行IAMロールの定義
# 親moduleで子モジュールに変数を渡し呼び出す(関数呼び出しのイメージ)
module "ecs_task_execution_role" {
  source = "./iam_role"
  name = "ecs-task-execution"
  policy = data.aws_iam_policy_document.ecs_task_execution.json
}

# タスク定義
resource "aws_ecs_task_definition" "example" {
  # 子モジュールのoutputを使う
  execution_role_arn = module.ecs_task_execution_role.iam_role_arn
}
```

# 手順
## AWS準備
TerraformでAWSのリソースを操作するため、Terraform用のIAMユーザーを作成する必要があります。

AWSコンソール上
- プログラムによるアクセスを許可
- AdministratorAccessをアタッチ
- クレデンシャル(アクセスキーとシークレットキー)を設定

AWS CLI
- インストール後、環境変数にクレデンシャルを設定
```:ターミナル
# Macを想定
pip install awscli --upgrade
aws configure --profile terraform ←IAMユーザー名
```

## Terraform準備
バージョンアップによる影響を避けるためにtenvをインストールし、所定のバージョンのTerraformをインストールします。本検証はv1.9.8を用いました。
```:ターミナル
# Macを想定
brew install cosign
brew install tenv
tenv tf install 1.9.8
```

以降は各リソース定義となるtfファイルを作成後、以下のコマンドで実行していきます。
```:ターミナル
# 初期化
terraform init

# 実行計画の確認(リソースは変更しない)
terraform plan

# リソース変更の適用
terraform apply --auto-approve

# リソースの削除
terraform destroy --auto-approve
```
## 各種AWSリソースの定義
基本的にこちらの書籍を参考にリソースを定義していきます。コードの一部に現在では非推奨の部分がありますが、AIなど利用しながら適宜補完していきます。

コード量がそれなりにあるので、必要なところのみ抜粋して記載します。

https://nextpublishing.jp/book/10983.html

まずは同一ディレクトリで全てのリソースを定義します。
この時、./配下で terraform apply をすると全てのリソースが一括で定義できます。
```:ファイルレイアウト
.
├── batch.tf
├── cicd.tf
├── config.tf
├── datastore.tf
├── ...
```

次にリソース毎にディレクトリを分けてリファクタします。
この時、./配下で terraform apply をし、全てのリソースを一括で定義するには、親子間（main.tf - modules/）でデータの受け渡しをできるようにする必要があります。
```:ファイルレイアウト
.
├── modules/
│   ├── batch/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   ├── cicd/
│   │   ├── main.tf
│   │   ├── outputs.tf
│   │   └── variables.tf
│   ├── ...
│
├── main.tf
├── outputs.tf
├── variables.tf
```
```親 main.tf
# 子2から受け取った値を他の子1へ受け渡す
module "batch" {
  source = "./modules/batch"
  ecs_task_execution_role_arn = module.ecs.ecs_task_execution_role_arn
}
```
```子1 variables.tf
# 親から
variable "ecs_task_execution_role_arn" {
  type = string
}
```
```子1 modules/batch/main.tf
# var.xxxとして使用
resource "aws_ecs_task_definition" "example_batch" {
  execution_role_arn = var.ecs_task_execution_role_arn
}
```
```子2 modules/ecs/outputs.tf
# 親へ
output "ecs_task_execution_role_arn" {
  value = module.ecs_task_execution_role.iam_role_arn
}
```

次にTerraformとDjangoアプリを各々のディレクトリで管理します。
この時、terraform/配下で terraform apply をすると、Dockerイメージのビルドとプッシュの前にECSなどのインフラが定義されてしまいエラーとなるため、2回に分けてapplyしていきます。
```:ファイルレイアウト
.
├── terraform/
│   ├── cicd/
│   │   ├── create/
│   │   │   ├── main.tf
│   │   │   ├── outputs.tf
│   │   │   └── variables.tf
│   │   └── reference/
│   │       ├── main.tf
│   │       ├── outputs.tf
│   │       └── variables.tf
│   └── infra/
│       ├── modules/
│       │   ├── batch/
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── cicd/
│       │   │   ├── main.tf
│       │   │   ├── outputs.tf
│       │   │   └── variables.tf
│       │   ├── ...
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── twitter_clone/ ←Djangoアプリ
│   ├── ...
│   │
│
├── build_and_push.sh
```

まずはECRリポジトリをapplyします。
後でecsのtfファイルから参照したいため、別途reference/でdataによりECRリポジトリを取得します。create/を参照するとresourceによりECRリポジトリを作成する処理が走るためエラーになります。
```:ターミナル
cd/terraform/cicd/create
terraform apply --auto-approve
```

次にDockerイメージのビルドとプッシュ用のshスクリプトを実行します。
```:ターミナル
sh build_and_push.sh
```
```sh:build_and_push.sh
#!/bin/bash

# ECRリポジトリのURLを取得
# terraform/cicdに移動して生の文字列を取り出すように実行
REPOSITORY_URL_WEB=$(terraform -chdir=terraform/cicd/create output -raw web_ecr_repository_url)
REPOSITORY_URL_NGINX=$(terraform -chdir=terraform/cicd/create output -raw nginx_ecr_repository_url)

echo "Repository URL: ${REPOSITORY_URL_WEB}"
echo "Repository URL: ${REPOSITORY_URL_NGINX}"

# ECRにログイン
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${REPOSITORY_URL_WEB}
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${REPOSITORY_URL_NGINX}

# イメージのビルドとプッシュ
cd twitter_clone
docker build -t web-ecr -f Dockerfile .
docker tag web-ecr:latest ${REPOSITORY_URL_WEB}:latest
docker push ${REPOSITORY_URL_WEB}:latest

docker build -t nginx-ecr -f Dockerfile.nginx .
docker tag nginx-ecr:latest ${REPOSITORY_URL_NGINX}:latest
docker push ${REPOSITORY_URL_NGINX}:latest
```

最後に各AWSリソースをapplyします。
```:ターミナル
cd/terraform/infra
terraform apply --auto-approve
```

手順は以上となります。

# ポイント
上記手順でエラーなくapplyするまでに、Djangoの設定含め色々と試行錯誤しました。その際のポイントを記します。

## 環境変数をSSMに集約・再設定
従来はDjangoの設定ファイルの.envなどに記載していた値を環境変数としてSSMに集約しました。
```tf:infra/modules/config/main.tf
resource "aws_ssm_parameter" "secret_key" {
  name = "/django/secret_key"
  # 後で書き換え
  value = "xxxxxxxxxx"
  type = "SecureString"
  description = "Djangoのシークレットキー"

  lifecycle {
    ignore_changes = [value]
  }
}
```
```python:twitter_clone/config/settings/production.py
# env = environ.Env()
# environ.Env.read_env(env_file=str(BASE_DIR)+"/.env.production")
# SECRET_KEY = env("SECRET_KEY") ←従来
SECRET_KEY = os.environ.get("SECRET_KEY")
```

また、上記だとgitでコミット対象となるため、各環境変数について初めはダミーデータ("xxxxxxxxxx")を置き、後からAWS CLIで書き換えるようにしました。
```sh:overwrite_parameters.sh
# 複数あるためshスクリプトにまとめる
aws ssm put-parameter \
  --name "/django/secret_key" \
  --value "<新しい値>" \
  --type SecureString \
  --overwrite

...
```

また、DBパスワードを書き換える際は要注意です。
RDSを定義するterraformではパスワードの設定が必須かつtfstateファイルへ平文で書き込まれるため、初めにSSMのダミーデータを置き、ignore_changesでパスワードの変更を無視する設定としています。
```tf:infra/modules/datastore/main.tf
# DBインスタンスの定義
resource "aws_db_instance" "example" {
  identifier = "django-tw-terraform-db"
  password = var.db_password
  ...

  lifecycle {
    ignore_changes = [password]
  }
}
```

そうすると、Django側で使うSSMの環境変数としてのDBパスワードの書き換えでは、RDSのDBインスタンスのパスワードが変わらず、RDSへの接続エラーを起こします。よって、追加でRDSのDBインスタンスのパスワードを直接変更する必要があります。
```sh:overwrite_parameters.sh
# SSMに保存しただけで、RDSの実インスタンスのパスワードは変わらない
aws ssm put-parameter \
  --name "/db/password" \
  --value "<DBパスワード>" \
  --type SecureString \
  --overwrite

# RDSの実インスタンスのパスワードを直接変更する
aws rds modify-db-instance \
  --db-instance-identifier "django-tw-terraform-db" \
  --master-user-password "<DBパスワード>"
```

最後にECSサービスを更新して、SSMの環境変数の更新をDjango側で認識させます。
```sh:overwrite_parameters.sh
# ECSサービスを更新
aws ecs update-service \
  --cluster <クラスター名> \
  --service <サービス名> \
  --force-new-deployment
```

## RDSのHOSTの属性はendpointではなくaddressを使う
Djangoはsettings/でDBへの接続情報を記す際、以下のように定義し、HOSTとPORTを分けます。
```python:twitter_clone/config/settings/production.py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_DATABASE'),
        'USER': os.environ.get('DB_USERNAME'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST'),
        'PORT': os.environ.get('DB_PORT'),
    }
}
```

この時、terafformでaws_db_instanceのendpoint属性を使うと、PORTの情報も含まれてしまうため、address属性を使う必要があります。
```tf:infra/modules/datastore/main.tf
# DBインスタンスの定義
resource "aws_db_instance" "example" {
  ...
}
```

```tf:infra/modules/datastore/output.tf
# output "db_endpoint" {
#   value = aws_db_instance.example.endpoint
# } ←PORTも含まれてしまうためNG

output "db_address" {
  value = aws_db_instance.example.address
}
```

```json:infra/modules/ecs/container_definitions.json
"environment": [
  {
    "name": "DB_HOST",
    "value": "${db_address}"
  },
```

## スローapply問題への対応
RDSのapplyに時間がかかるため、開発中はinstance_classをアップグレードして時間短縮するのがおすすめです。ただし、その分コストがかかるため注意です。
```infra/modules/datastore/main.tf
# DBインスタンスの定義
resource "aws_db_instance" "example" {
  ...
  instance_class = "db.t4g.small" # db.t3.smallからアップグレード
```

## JSONファイルの読み込みと変数の渡し方
ECSタスク定義の元データとなるJSONファイルについて
- templatefile("< jsonのパス >", {<変数>}) により、JSONファイルを読み込み変数を渡す
- ${path.module}により絶対パスを取得
- JSON側では "${<変数>}" で使う

```tf:infra/modules/ecs/main.tf
# タスク定義
resource "aws_ecs_task_definition" "django_tw_terraform_task" {
  ...
  container_definitions = templatefile("${path.module}/container_definitions.json", {
    nginx_ecr_repository_url = var.nginx_ecr_repository_url
    web_ecr_repository_url = var.web_ecr_repository_url
    db_address = var.db_address
  })
```
```json:infra/modules/ecs/container_definitions.json
"environment": [
  {
    "name": "DB_HOST",
    "value": "${db_address}"
  },
```

# デバッグ方法
RDSへの接続がなかなかできず苦労したので、デバッグ方法を記します。

まずはECS execコマンドでコンテナに入るための設定します。
- enable_execute_command = true
- ssmmessagesでSSMエージェントの実行用のポリシーをアタッチ

```tf:infra/modules/ecs/main.tf
# ECSサービスの定義
resource "aws_ecs_service" "example" {
  ...
  enable_execute_command = true
}
  
# ECSタスクロールのポリシードキュメントの定義
data "aws_iam_policy_document" "ecs_task_role" {
  ...
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
  }
}

# ECSタスク実行IAMロールのポリシーとドキュメントの定義
data "aws_iam_policy_document" "ecs_task_execution" {
  ...
  statement {
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
  }
}
```

設定したらapplyしてECS execコマンドでコンテナに入ります。
```:ターミナル
aws ecs execute-command \
    --cluster <クラスター名> \
    --task <タスクID> \
    --container <コンテナ名> \
    --interactive \
    --command "/bin/bash"
```

### ステップ1. コンテナ内で環境変数を確認する
→ここでミスがあればDjangoの設定ミス
```:ターミナル
root@ip-10-0-66-73:/code# env | grep DB
DB_PASSWORD=<DBパスワード>
DB_PORT=<DBポート>
DB_USERNAME=<DBユーザー名>
DB_HOST=<DBエンドポイント>
DB_DATABASE=<DB名>
```

### ステップ2. RDSエンドポイントへ直接疎通できるか
→失敗したらネットワーク・セキュリティグループのどれかの設定ミス
```:ターミナル
apt update && apt install -y netcat-openbsd
nc -zv <DBエンドポイント> <DBポート>
```

### ステップ3. PostgreSQLクライアントで直接ログインできるか
→失敗したらパスワード・ユーザー名・DB名のどれかの設定ミス
```:ターミナル
apt update && apt install -y postgresql-client
PGPASSWORD=<DBパスワード> psql -h <DBエンドポイント> -U postgres -d <DB_DATABASE>
```

### ステップ4. Django側だけ接続できない場合
上記までで成功した場合、失敗の原因はDjangoのDATABASES設定のミスの可能性が高いです。今回の検証では、従来DB情報はenvファイルから読み込んでおり、その後環境変数から読み込むように変更したのですが、その際のapplyがうまく反映されなかったことで、RDSへの接続ができませんでした。
以上のステップでエラーの原因を突き止めることができました。
```python:twitter_clone/config/settings/production.py
DATABASES = {
    # "default": env.db(), ←従来はenvファイルから読み込んでいた
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.environ.get('DB_DATABASE'),
        'USER': os.environ.get('DB_USERNAME'),
        'PASSWORD': os.environ.get('DB_PASSWORD'),
        'HOST': os.environ.get('DB_HOST'),
        'PORT': os.environ.get('DB_PORT'),
    }
}
```

# おわりに
コンソール上で四苦八苦して設定していた各種リソースが、コマンド一発で生成・削除出来ることに感動しました・・・。初めの設定は面倒ですが、一度設定してしまえば運用がかなり楽になると実感出来ました。

# 参考文献
前述で挙げた文献の他に参考とさせていただいたものです。

https://gihyo.jp/book/2025/978-4-297-14724-2