Amazon Copilot で Django アプリを ECS にデプロイしたときに気付いたいろいろ

Amazon Copilot は自社内の API の置き換えに試して便利だなと思ったものの、ちょっと**ブラックボックス過ぎる**かなあと感じており、案件に使うにはちょっと時期尚早かなとおもっていたんですが、実際作ってみたらあっさり出来てしまったのでそのまま案件のインフラに採用することになりました。

「あっさり出来た」とは書きましたがちょっとハマったところもあったので、同じような構成をとりたい人の参考になるかもしれないと思い気付いたところを記載しておきたいと思います。

前提としてアプリの構成を書いておくと、メインのアプリはタイトル通り Django でつくったものです。で、リバースプロキシで Nginx を立てており、これらふたつのコンテナがひとつのサービスのタスクです（Django がメインコンテナ、Nginx がサイドカーコンテナとして起動）。コンテナの外部には S3 と RDS（Aurora） があり、アプリケーションとデータのやりとりをします。今回は Pipeline も作成しました。マージしたら開発環境に自動的にデプロイされるところまで、Copilot で作りました。

# 初期ユーザの作成どうする問題

Django の初期ユーザは

```sh
python manage.py createsuperuser
```

で作成しますので、デプロイしたあとに

```sh
copilot svc exec
```

でコンテナの中に入って実行するだけです。このexecコマンドがあるおかげで助かりました。コンテナの中に入らずに1回だけ実行するのは……よくわかりません。どうやってやるんだろう？

# ALLOWED_HOSTS どうする問題

Django には DEBUG という設定値があります。クラウド環境ではこれを false にしてデプロイしていますが、そのばあい ALLOWED_HOSTS という設定値が有効になります。これは**アプリケーションがリクエストを受け付けるホストをホワイトリスト方式で設定する**ことで、それ以外からのアクセスを拒否し、アプリをセキュアに保つ仕組みです。

hoge.com というドメインでアプリをホストすることを考えた場合、

```python
ALLOWED_HOSTS = ['localhost','hoge.com']
```

のような設定になるでしょう。しかしクラウド環境ではこれだけではダメでした。

前にリバースプロキシとして Nginx を立てているので、Django 自体は Nginx からアクセスされるわけで、それはつまり **Nginx のコンテナの IP が ALLOWED_HOSTS に必要**なのです。

それって、どうやって知るの？

ってわけで、実は **ECS はコンテナの IP をメタデータとして返す API を持っています。そのエンドポイントはコンテナが暗黙的な環境変数 ECS_CONTAINER_METADATA_URI として取得** できます。

というわけで最終的には次のように記載しました。

```python
ALLOWED_HOSTS = ['localhost','hoge.com','dev.hoge.com','django.dev.hoge.hoge.com']
METADATA_URI = env.str('ECS_CONTAINER_METADATA_URI', default = None)
if METADATA_URI is not None:
    container_metadata = requests.get(METADATA_URI).json()
    ALLOWED_HOSTS.append(container_metadata['Networks'][0]['IPv4Addresses'][0])
```

これでアクセス元の IP を直接知らなくても動的に設定することが出来るようになりました。

# マイグレーションどうする問題

データベースのマイグレーションは Django に限らずこの手のアプリであれば考える必要がありそうです。実はここでマイグレーションと同時に collectstatic というコマンドを実行していますがこれについては Django 特有の問題がありまして、これは後述しますのでいったんマイグレーションについて考えます。

Copilot を使うと copilot svc exec とかでコンテナに入って作業が出来てしまうので、コンテナのお腹の中で `python manager.py migration` とか打てば出来てしまいますが、せっかく Pipeline で自動化したのに**手でマイグレーションを実行するのはナシにしたい**ですよね。

まず考えたのは CodePipeline 中に行えば良いのかなと言うことでした。ここで問題になるのがマイグレーション時の DB への接続情報で、たとえば CodePipeline の途中でこれをやろうとすると CodePipeline が動いている環境内にDB接続情報を入れなくてはいけなくなります。というか、接続情報だけじゃなくて **`python manager.py migration` が動くためには Django のアプリそのものが正常に動く状態でないとダメ**で、デプロイ中にデプロイするためのコードを別環境で起動させるというよくわからないことをしないといけないため、この方法は止めました。

次にサイドカーコンテナでおなじイメージを実行させてマイグレーションを行う方法でした。こちらの[記事を参考](https://www.wildfish.com/blog/2023/10/20/deploying-django-via-aws-copilot/) にしています。これは、本体のWebアプリとおなじイメージを使ってコンテナを起動させて実行させて終わったら落とす、というかなりスマートなやりかたです。しかし、なぜかDBへの接続情報を環境変数から上手く取得してくれませんでした。もう少し工夫をすればなんとかなるかもしれないと思いつつもいったんこの方法は諦めました（追記：サイドカーには環境変数は継承されないようです https://github.com/aws/copilot-cli/issues/5639 ）

というわけで、最終的には **entorypoint.sh を作成してそのなかでマイグレーションを実行させるやり方**に落ち着きました。

entorypoint.sh はこんな感じになります。

```sh
#!/bin/sh
set -e

echo 'Collect Static'
python manage.py collectstatic --noinput 
echo 'Migrate DB'
python manage.py migrate

exec "$@"
```

Django の Dockerfile はこう。

```Dockerfile
FROM public.ecr.aws/docker/library/python:3.8
ENV PYTHONUNBUFFERED 1
ENV APP_HOME=/hogehoge

WORKDIR $APP_HOME
COPY apps/requirements.txt $APP_HOME/
RUN pip3 install -r requirements.txt
COPY apps/ $APP_HOME/

COPY apps/entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]

EXPOSE 8000

CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]
```

ENTRYPOINT と CMD の使い分けの知識が必要かもしれません。ENTRYPOINT に CMD の内容が引数になって渡ってくるので、CMD を上書きしていても動きます。 copilot のマニフェストをもとにしたクラウド側での実行だけではなく、**ローカルで docer-compose.yml で CMD を上書きしてもちゃんとマイグレーションが動きます**。

# collectstatic どうする問題

Django では app という単位でいくつもの機能をフォルダに作成します。その際、各フォルダに静的ファイルが作成されたりします。また管理画面は実行時に生成された静的ファイルを使ったりします。

**これら静的ファイル群を一つの場所にまとめて移動してくれるのが  `python manager.py collectstatic` というコマンド**です。このコマンドを打つと、各フォルダ毎の静的ファイルを全て1箇所にまとめてくれます。

このとき、どのパスにまとめるか、というのは settings.py にある次のような記述に従います。

```python
STATIC_URL = '/static/'
STATICFILES_DIRS = [ BASE_DIR / "config/static" ]
STATIC_ROOT = '/static'
```

最初は STATIC_ROOT の記述が存在しないのと、STATICFILES_DIRS が static のルートを指していたことで上手くいきませんでした（特定のアプリの static のフォルダと、それらをまとめるためのフォルダが同じ場所を指していたのが良くなかったらしい）

これを設定したうえで、先の entrypoint.sh で実行時に /static にファイルをまとめることでローカルの Docker Compose では動くようになりました。

が。ECS では CSS がまったく見つけられなくなっていました。

これは実は当然と言えば当然でした。今回構成上、Django の前にリバースプロキシとして Nginx を立てています。コンテナとしては sidecar の設定で動かしています。

Nginx.conf の設定はこんな感じでした。

```nginx
upstream django {
    server localhost:8000;
}

server {
    listen 80;
    listen [::]:80;
    server_name 0.0.0.0;

    location / {
        proxy_pass http://django;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_redirect off;
    }
    
    location /static/ {
		alias /static/;
	}
}
```

Ngnix の Dockerfile はシンプルです。

```Dockerfile
FROM public.ecr.aws/nginx/nginx:1.24-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/nginx.conf /etc/nginx/conf.d/nginx.conf
```

設定にある alias の設定の通り static ディレクトリにあるデータはDjangoには到達せずこの Nginx が直接返すのですが、当然 Nginx のコンテナには `python manager.py collectstatic` の結果はありませんからファイルを参照することが出来ないのです（ローカルでテストする際は Nignx を使っていなかったので問題ありませんでした）


そこで **Django と Nginx で static フォルダを共有する**ことにしました。**共有には Copilot のマニフェストで EFS の設定**を記述します。

メインのコンテナの storage 設定に次のように書き、

```yaml
storage:
  volumes:
    django-static:
      efs: true
      path: /static
      read_only: false
```

 サイドカーの storage で同じ場所をマウントします。

 ```yaml
 sidecars:
  nginx:
    port: 80
    image:
      build:
        dockerfile: nginx/Dockerfile
        context: /
    mount_points:
      - source_volume: django-static
        path: /static
        read_only: false
    variables:
      NGINX_PORT: 80
```

これで `python manager.py collectstatic` の結果が /static に集約され、静的ファイルは Nginx がそのまま返すようになりました。

（これは、ルートにまんま /static でマウントされちゃうのでパスはもうすこしWeb サーバぽいものにしてもよかったかもしれないですね）

## 途中で Fargate Spot に変えられない問題

Fargate には Fargate Spot という EC2 での spot instance にあたる安価なサービスがあります。これも Copilot で設定出来、count.spot という設定値を変えます。ある数以上レプリカが作成されると一部を spot インスタンスにする、などの複雑な設定出来ますが、今回はとくに冗長化をする気は無かったので、コンテナは一つ、かつそのコンテナを Fargate Spot にするという意味で、1に設定してみました。

```yaml
count:
  spot: 1
```

ところがこれはエラーになります。Fargate か Fargate Spot かの設定をキャパシティプロバイダ戦略というようですが、すでにキャパシティープロバイダ戦略が Fargate でデプロイされているアプリを更新するとエラーになるらしいのです。[ここ](https://github.com/aws/copilot-cli/issues/4651)でやりとりされていましたがこれ自体はバグのようで、1.13.2 現在直っていないようです。

ワークアラウンドとして、

```yaml
network:
  connect: true
```

この設定を true->false にすることで変更が出来ることのこと。ただ、この設定はサービスコネクトの設定値なので、これを false にしてしまうとコンテナ間の通信に問題が生じます（たぶん？）。そこでややこしいですが、**いったん false にして spot:1 でデプロイ、そのあとで、trueにして再デプロイをする**（2段階デプロイ）ことにしました。これでうまく変更できました。

これはさすがにバッドノウハウ過ぎるので、修正を待ちたいと思います。

# Pipeline のターゲットブランチがひとつしかない問題

Pipeline のマニフェストは


```yaml
name: zenk-nuhw-aws-develop

version: 1

source:
  provider: CodeCommit
  properties:
    branch: develop
    repository: https://ap-northeast-1.console.aws.amazon.com/codesuite/codecommit/repositories/hogehoge/browse

stages:
  - 
    name: dev
```

こんなふうになっていて、ステージの下にブランチの設定はありません。この設定ファイルでは develop ブランチをどこの環境にデプロイするか、しか設定出来ないんですよね。でもやりたいのは develop は dev 環境へ、master は prod 環境へ、みたいなことかと思います。

その場合は**パイプラインをブランチの数だけ作る**のが現時点での正解っぽいです（パイプラインの数分料金もかかりますのであまりしたくはないのですが……）