1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

対象

Djangoアプリはこちら。

アーキテクチャ

せっかくなので前回とは違うリソースで構築してみます。主に図の赤字のところです。

  • Private SubnetからVPC外への接続はEndpointではなくNAT経由のアクセスとする
  • PostgresのデータはEFSではなくRDSへ保存する
  • 割高なCloudWatchのログをKinesisを仲介してS3に永続化する
  • ストレージ等の暗号化のための暗号鍵はKMSで管理する
  • デバッグ用のオペレーションサーバーとしてEC2を置く

Architecture3.jpg

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など利用しながら適宜補完していきます。

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

まずは同一ディレクトリで全てのリソースを定義します。
この時、./配下で 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
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に集約しました。

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]
  }
}
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で書き換えるようにしました。

overwrite_parameters.sh
# 複数あるためshスクリプトにまとめる
aws ssm put-parameter \
  --name "/django/secret_key" \
  --value "<新しい値>" \
  --type SecureString \
  --overwrite

...

また、DBパスワードを書き換える際は要注意です。
RDSを定義するterraformではパスワードの設定が必須かつtfstateファイルへ平文で書き込まれるため、初めにSSMのダミーデータを置き、ignore_changesでパスワードの変更を無視する設定としています。

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インスタンスのパスワードを直接変更する必要があります。

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側で認識させます。

overwrite_parameters.sh
# ECSサービスを更新
aws ecs update-service \
  --cluster <クラスター名> \
  --service <サービス名> \
  --force-new-deployment

RDSのHOSTの属性はendpointではなくaddressを使う

Djangoはsettings/でDBへの接続情報を記す際、以下のように定義し、HOSTとPORTを分けます。

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属性を使う必要があります。

infra/modules/datastore/main.tf
# DBインスタンスの定義
resource "aws_db_instance" "example" {
  ...
}
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
}
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側では "${<変数>}" で使う
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
  })
infra/modules/ecs/container_definitions.json
"environment": [
  {
    "name": "DB_HOST",
    "value": "${db_address}"
  },

デバッグ方法

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

まずはECS execコマンドでコンテナに入るための設定します。

  • enable_execute_command = true
  • ssmmessagesでSSMエージェントの実行用のポリシーをアタッチ
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への接続ができませんでした。
以上のステップでエラーの原因を突き止めることができました。

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'),
    }
}

おわりに

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

参考文献

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?