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

# はじめに
Djangoで作ったWebアプリをAWS Fargateでデプロイした時のポイントをまとめました。

# アーキテクチャ

![Architecture4.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/e5c3617f-b2e9-1c1c-278e-5b077bb49d16.jpeg)

- Public subnetから外部への接続はIGWを使う
- Private subnetからVPC外への接続はVPC Endpointを使う
- ロードバランサーはALBを使う
- マルチAZ構成にする
- ECS FargateはPrivate subnetに構築する
- デプロイ対象のコンテナは3つで、WebサーバのNginx、AppサーバのGunicorn・Django、DBサーバのPostgres
- アプリはフロントエンドとバックエンドをDjangoで作成したTwitterクローンのWebアプリ
- ECSの同一タスク内に3つのコンテナをたてる
- Route53でドメインを購入しhttpsで通信できるようにする
- CloudWatchでコンテナのログを取得する
- S3に画像を保存する
- SSMでコンテナをデバッグする
- EFSでDBデータを永続化する
- SESでSMTPによりメールを送信する

# まずはローカル上で開発環境から本番環境への移行準備
## Nginx, Gunicornの導入
Djangoの簡易サーバではなく本番用のサーバとして設定が簡単なNginx, Gunicornを採用しました。

Gunicornはpip installしてgunicornコマンドで簡単に起動できました。
```
gunicorn config.wsgi:application --bind 0.0.0.0:8000
```

しかし、Nginxは設定が複雑で戸惑いました。
以下を参考にconfファイルを作成し、Gunicornへのリクエストを設定しました。

https://gihyo.jp/book/2024/978-4-297-14017-5

https://book.impress.co.jp/books/1115101148


<ins>ポイント：</ins>
etc以下にconfファイルを作成し、nginxコンテナにコピーし、かつ、default.confを削除する。
```:ディレクトリ構成
etc
|-nginx
  |-conf.d
    |-upstream.conf
    |-web.conf
```
```Dockerfile:Dockerfile.nginx
FROM nginx:1.25.1

COPY ./etc/nginx/conf.d/* /etc/nginx/conf.d/
RUN rm /etc/nginx/conf.d/default.conf
```

<ins>ポイント：</ins>
upstreamはリクエスト先を指定するため、Gunicornのportを指定し、**今回はECSの同一タスク内にコンテナをたてるため、同一ホストとみなし、コンテナ間通信はlocalhostで行う（以下同様）。**

[参考：Fargate 起動タイプの Amazon ECS タスクのネットワークオプション](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/fargate-task-networking.html)

```conf:upstream.conf
upstream django_tw {
  server localhost:8000 max_fails=3 fail_timeout=10s;
}
```

<ins>ポイント：</ins>
serverはnginx自身のポート、ファイルへのルーティング、リクエスト先を指定する。
```conf:web.conf
server {
    listen 80;
    server_name nginx;

    # favicon.icoのログを無効化
    location = /favicon.ico { 
        access_log off; 
        log_not_found off; 
    }

    # 静的ファイルへのルーティング
    location /static/ {
        alias /var/www/assets/static/;
    }

    # メディアファイルへのルーティング
    # S3のURLをそのまま利用するので以下は設定しない
    # location /media/ {
    #     alias /var/www/assets/media/;
    # }

    # アプリケーションへのリクエスト
    location / {
        proxy_pass http://django_tw;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        access_log /dev/stdout main;
        error_log /dev/stderr;
        client_max_body_size 10M;
    }
}
```

なお、上記はTCPソケット通信を前提として設定しましたが、今回はECSの同一タスク内にコンテナをたてるため、同一ホストとみなし、Unixドメインソケット通信も可能と考えられます。

## 設定ファイルの編集
開発用の各設定ファイルから差分を記載する形で本番用の各設定ファイルを作りました。

<ins>ポイント：</ins>
本番用にsettings.pyからproduction.pyを作成し、DEBUGをFalseにし、Nginxからのリクエストを許可する。
```python:production.py
# 本番用に差分のみ記載する
DEBUG = False
CSRF_TRUSTED_ORIGINS = [
    'http://localhost:80',  # Nginx経由のURLを追加
]
```

<ins>ポイント：</ins>
本番用に.envから.env.productionを作成し、**DBへのリクエストURLのIPをlocalhostとする。**
```env:.env.production
 # コンテナ名ではなくlocalhostを指定する点注意
DATABASE_URL="postgres://<ユーザ名>:<パスワード>@localhost:5432/<DB名>"
```

<ins>ポイント：</ins>
本番用にdocker-compose.ymlからdocker-compose.production.ymlを作成し、
- production.pyを読み込むようにする
- nginxコンテナを設定し、Djangoの静的ファイルを共有するためにstatic-dataを定義する
- **※これらは本番ではAWS上で設定するため注意**
```yml:docker-compose.production.yml
# 本番用に差分のみ記載する
services:
  web
    DJANGO_SETTINGS_MODULE: config.settings.production
    volumes:
      - static-data:/code/staticfiles
  nginx
    volumes:
      - static-data:/var/www/assets/static
    depends_on:
      web:
        condition: service_healthy

volumes:
  # 静的ファイル用のボリュームを定義
  static-data:
```

<ins>ポイント：</ins>
DockerfileのCMDで実行されるが複数行あるためshに分離
```sh:start.production.sh
python manage.py migrate
python manage.py collectstatic --noinput
gunicorn config.wsgi:application --bind 0.0.0.0:8000
```

```:ディレクトリ構成（編集前）
config
|-settings.py

.env

docker-compose.yml

Dockerfile
```
```:ディレクトリ構成（編集後）
config
|-settings
  |-base.py
  |-local.py
  |-production.py

.env
.env.production

docker-compose.yml
docker-compose.production.yml

Dockerfile
Dockerfile.db
Dockerfile.nginx

start.sh
start.production.sh
```

## ビルド・デプロイ
<ins>ポイント：</ins>
AWS用にビルド・デプロイをする前に、docker-compose.production.ymlを使ってローカル上で擬似的に本番用の設定を読み込み手戻りが少なくなるようする。
```:ターミナル
docker compose -f docker-compose.yml -f docker-compose.production.yml up -d --build
docker compose -f docker-compose.yml -f docker-compose.production.yml down
```

<ins>ポイント：</ins>
AWS用にビルド・デプロイをする時は、docker-compose.yml, docker-compose.production.ymlでの環境変数等の設定はAWSコンソールで行うためdocker composeコマンドは使用しない。
```:ターミナル
docker build -t django-tw/web -f Dockerfile .
docker build -t django-tw/db -f Dockerfile.db .
docker build -t django-tw/db -f Dockerfile.nginx .
```

# ここからAWS上の設定
全体的に以下を参考にしました。

[AWSでDockerを本番運用！AmazonECSを使って低コストでコンテナを運用する実践コース](https://www.udemy.com/course/ecsfargate/?couponCode=KEEPLEARNING)

https://www.shoeisha.co.jp/book/detail/9784798180168

https://www.sbcr.jp/product/4815607654/

## ECSタスク定義
<ins>ポイント：</ins>
- docker-compose.production.ymlで設定した環境変数等はECSタスクには反映されないため、コンソール上で再度設定する

```json:e.g. webコンテナ
            "environment": [
                {
                    "name": "DJANGO_SETTINGS_MODULE",
                    "value": "config.settings.production"
                },
                {
                    "name": "DJANGO_ENV",
                    "value": "production"
                }
            ],
            "mountPoints": [
                {
                    "sourceVolume": "static-data",
                    "containerPath": "/code/staticfiles",
                    "readOnly": false
                }
            ],
            "dependsOn": [
                {
                    "containerName": "db",
                    "condition": "HEALTHY"
                }
            ],
```

- dbのhealthCheckはpsql,-U,postgresだと対話型モードに移行し短時間で終了しないためエラーとなる。**よってpg_isreadyを使う**
```:dbコンテナ
            "healthCheck": {
                "command": [
                    "CMD-SHELL",
                    "pg_isready -U postgres || exit 1"
                ],
```

## プライベートサブネットからECRへのアクセス
NATゲートウェイかエンドポイント経由のアクセスがあり、コスト面からエンドポイント経由のアクセスを選択しました（ただ、結局は複数個設定したためNATの方がよいかも・・・）。
ECRからイメージをpullする場合は以下上から3つのエンドポイントを作成する必要があり、ログも含めると計4つになります。

<ins>ポイント：</ins>
プライベートサブネットからVPC外へのアクセスはエンドポイントを設定する
- com.amazonaws.ap-northeast-1.ecr.api
- com.amazonaws.ap-northeast-1.ecr.dkr
- com.amazonaws.ap-northeast-1.s3
- com.amazonaws.ap-northeast-1.logs

## 同一タスク内のコンテナ間通信
DjangoコンテナからPostgresコンテナへのアクセスが何度やってもエラーとなりました。そこでFargateでもexecコマンドでコンテナの中に入りデバッグできるので、それで原因を突き止めました。

<ins>ポイント：</ins>
- **同一タスク内のコンテナ間通信は、ホスト名をコンテナ名（db）ではなくlocalhostにする**
```env:.env.production
DATABASE_URL="postgres://<ユーザー名>:<パスワード>@<ホスト名>:<ポート>/<DB名>"
```

- execコマンドは必要な権限をロールに付与し、SSM経由でVPC外からのアクセスとなるためエンドポイントを作成する
  - com.amazonaws.ap-northeast-1.ssmmessages
  - com.amazonaws.ap-northeast-1.ec2messages

[参考：Amazon VPC エンドポイントを作成することで、インターネットへのアクセスを必要とせずに、Systems Manager を使用してプライベート Amazon EC2 インスタンスを管理できるようにする方法を教えてください。](https://repost.aws/ja/knowledge-center/ec2-systems-manager-vpc-endpoints)
[ECS Exec を使用して Amazon ECS コンテナをモニタリングする](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/developerguide/ecs-exec.html)

```:ターミナル
aws ecs execute-command --region ap-northeast-1 --cluster <クラスター名> --task <タスクID> --container <コンテナ名> --interactive --command "/bin/sh"
```

## IPやPortの設定
ルートテーブルやセキュリティグループでアクセスを制限しますが、ここもはまりポイントで、適切に設定しないとすぐエラーになります。そのため、まずは定義を確認し、初めは前広にアクセスを授受できるように設定しました。

**ルートテーブル**
<ins>ポイント：</ins>
- サブネット間の通信経路
- Public subnetは外部向けの送信先0.0.0.0/0 IGWと内部向けの送信先0.0.0.0/16 localを設定
- Private subnetはS3向けのものと内部向けの送信先0.0.0.0/16 localを設定

**セキュリティグループ**
<ins>ポイント：</ins>
- VPC内のリソースのアクセス制限
- **デフォルトのインバウンドは外部通信ではなく内部通信となるため、VPC内の通信のインバウンドはデフォルトを使う**
- アウトバウンドは基本的に0.0.0.0を使えばよい

[参考：デフォルトのセキュリティグループとは何かを改めて整理してみた](https://dev.classmethod.jp/articles/what-is-default-sg/)

## S3に画像を保存する
<ins>ポイント：</ins>
- S3ロールの作成
- コンソールからS3バケットを作成
- 必要なライブラリのインストール
```txt:requirements.txt
pip install boto3 django-storages
```

- 環境変数の設定（ローカル上で設定）
```python:production.py
# DEFAULT_FILE_STORAGE, MEDIA_URLの設定がポイント
DEFAULT_FILE_STORAGE = 'config.settings.storage_backends.MediaStorage'
MEDIA_URL = f'https://{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com/media/'

AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
```

- S3にstatic/とmedia/ディレクトリを作成するためにconfig/settings/storage_backends.pyを作成（static/は今回は使用しない）
```python:storage_backends.py
from storages.backends.s3boto3 import S3Boto3Storage


class StaticStorage(S3Boto3Storage):
    """S3のstatic/ディレクトリに保存"""
    location = 'static'

class MediaStorage(S3Boto3Storage):
    """S3のmedia/ディレクトリに保存"""
    location = 'media'
```

S3のmedia/に画像が保存されることを確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/426727e1-8e2c-ca88-c6ce-d9fd5326d2f5.png)

## EFSでDBデータを永続化する
コンテナのvolume領域だとタスク更新時に消えてしまうので、永続化するためにコスト面からRDSではなくEFSとしました。

<ins>ポイント：</ins>
- コンソールからファイルシステムの作成
- Private subnetへのアタッチ
  - **エンドポイントのPrivate subnetだとエラーとなるためdbコンテナのPrivate subnetとする**
- **エンドポイントの作成**
  - com.amazonaws.region.elasticfilesystem

[参考：Amazon EFS でのインターフェイス VPC エンドポイントの使用](https://docs.aws.amazon.com/ja_jp/efs/latest/ug/efs-vpc-endpoints.html)

## SESでSMTPによりメールを送信する
<ins>ポイント：</ins>
- コンソールから使用を開始
- **エンドポイントの作成**
  - com.amazonaws.ap-northeast-1.email-smtp

[参考：Amazon Simple Email Service エンドポイントとクォータ](https://docs.aws.amazon.com/ja_jp/general/latest/gr/ses.html)

- 必要なライブラリのインストール
```txt:requirements.txt
pip install django-ses
```

- 環境変数の設定（ローカル上で設定）
```python:production.py
EMAIL_HOST = "email-smtp.ap-northeast-1.amazonaws.com"
EMAIL_PORT = 587
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
EMAIL_USE_TLS = True
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL")
```

メールの送付を確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/2574d8f3-9a05-d8df-423b-70460c3c52d7.png)

# 動作確認
問題なく起動・操作できることを確認しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/81bdef06-c0be-0a91-5e47-9edc0d2f9e19.png)

# おわりに
構築するのにかなり時間がかかり苦労しました・・・。ただ、本番環境へのデプロイの難しさがわかり、苦労した分理解も深まりました。
今後のTODOとして以下を記します。

- EC2で構築しFargateとの違いを確認
- 必要な通信のみに絞る、環境変数を見直す等セキュリティ面の強化
- NATやRDSなど今回使用しなかったリソースを使ってみる
- SPAのデプロイ
- Terraformで自動化