はじめに
以前、AWSコンソールよりインフラを定義し、Djangoで作ったWebアプリをAWS Fargateでデプロイしました。
今回はTerraformよりインフラを定義し、同じようにデプロイしてみました。
対象
Djangoアプリはこちら。
アーキテクチャ
せっかくなので前回とは違うリソースで構築してみます。主に図の赤字のところです。
- Private SubnetからVPC外への接続はEndpointではなくNAT経由のアクセスとする
- PostgresのデータはEFSではなくRDSへ保存する
- 割高なCloudWatchのログをKinesisを仲介してS3に永続化する
- ストレージ等の暗号化のための暗号鍵はKMSで管理する
- デバッグ用のオペレーションサーバーとしてEC2を置く
Terraform基本文法
よく使う文法です。
# リソースの記述
# ブロック名(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ロールモジュールの定義
variable "name" {}
variable "policy" {}
resource "aws_iam_role" "default" {
# 親モジュールより渡された変数を使う
name = var.name
}
output "iam_role_arn" {
value = aws_iam_role.default.arn
}
# 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など利用しながら適宜補完していきます。
コード量がそれなりにあるので、必要なところのみ抜粋して記載します。
まずは同一ディレクトリで全てのリソースを定義します。
この時、./配下で 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
# 子2から受け取った値を他の子1へ受け渡す
module "batch" {
source = "./modules/batch"
ecs_task_execution_role_arn = module.ecs.ecs_task_execution_role_arn
}
# 親から
variable "ecs_task_execution_role_arn" {
type = string
}
# var.xxxとして使用
resource "aws_ecs_task_definition" "example_batch" {
execution_role_arn = var.ecs_task_execution_role_arn
}
# 親へ
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
#!/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に集約しました。
resource "aws_ssm_parameter" "secret_key" {
name = "/django/secret_key"
# 後で書き換え
value = "xxxxxxxxxx"
type = "SecureString"
description = "Djangoのシークレットキー"
lifecycle {
ignore_changes = [value]
}
}
# 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スクリプトにまとめる
aws ssm put-parameter \
--name "/django/secret_key" \
--value "<新しい値>" \
--type SecureString \
--overwrite
...
また、DBパスワードを書き換える際は要注意です。
RDSを定義するterraformではパスワードの設定が必須かつtfstateファイルへ平文で書き込まれるため、初めにSSMのダミーデータを置き、ignore_changesでパスワードの変更を無視する設定としています。
# 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インスタンスのパスワードを直接変更する必要があります。
# 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側で認識させます。
# ECSサービスを更新
aws ecs update-service \
--cluster <クラスター名> \
--service <サービス名> \
--force-new-deployment
RDSのHOSTの属性はendpointではなくaddressを使う
Djangoはsettings/でDBへの接続情報を記す際、以下のように定義し、HOSTとPORTを分けます。
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属性を使う必要があります。
# DBインスタンスの定義
resource "aws_db_instance" "example" {
...
}
# output "db_endpoint" {
# value = aws_db_instance.example.endpoint
# } ←PORTも含まれてしまうためNG
output "db_address" {
value = aws_db_instance.example.address
}
"environment": [
{
"name": "DB_HOST",
"value": "${db_address}"
},
スローapply問題への対応
RDSのapplyに時間がかかるため、開発中はinstance_classをアップグレードして時間短縮するのがおすすめです。ただし、その分コストがかかるため注意です。
# DBインスタンスの定義
resource "aws_db_instance" "example" {
...
instance_class = "db.t4g.small" # db.t3.smallからアップグレード
JSONファイルの読み込みと変数の渡し方
ECSタスク定義の元データとなるJSONファイルについて
- templatefile("< jsonのパス >", {<変数>}) により、JSONファイルを読み込み変数を渡す
- ${path.module}により絶対パスを取得
- JSON側では "${<変数>}" で使う
# タスク定義
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
})
"environment": [
{
"name": "DB_HOST",
"value": "${db_address}"
},
デバッグ方法
RDSへの接続がなかなかできず苦労したので、デバッグ方法を記します。
まずはECS execコマンドでコンテナに入るための設定します。
- enable_execute_command = true
- ssmmessagesでSSMエージェントの実行用のポリシーをアタッチ
# 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への接続ができませんでした。
以上のステップでエラーの原因を突き止めることができました。
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'),
}
}
おわりに
コンソール上で四苦八苦して設定していた各種リソースが、コマンド一発で生成・削除出来ることに感動しました・・・。初めの設定は面倒ですが、一度設定してしまえば運用がかなり楽になると実感出来ました。
参考文献
前述で挙げた文献の他に参考とさせていただいたものです。