📝

AWSを使ってReact・Django(DRF)のWeBアプリをデプロイしたい

2021/10/09に公開

はじめに

DVA 取得と、デプロイについての知識とそれを含めた開発スキルのレベルアップのために色々とやっているのでそれの途中経過をアウトプット。
前提として以下の話があります。

React と DRF の SPA で JWT を Cookieで管理してみた話
もくもく会アウトプット:Django で JWT を Cookie で処理するやつを理解したい

やったこと

  • フロント側(React)を CodePipeline・CodeBuild・Route53・CroudFront・S3 の構成でデプロイ
  • サーバー側(DRF)を EC2 インスタンス 1 台を使ってデプロイ
  • 上記を Https でやり取りするようにして連携

ひとまず今回は Code シリーズを使った S3 静的 Web ホスティングでの React デプロイと同時にクロスオリジンでの SPA デプロイの体験を行うというのがテーマなので最小限の構成です。
サーバー側も Code シリーズや ElasticBeanStalk などを使ってやろうとしたのですが、フロントと比べると数倍は難しかったのでまずは 1 番基本的なデプロイを試してみて何をする必要があるのかを把握することに重きを置きました。

以下今回のサービス構成図です。

2021-10-08_02h54_59.png

前述の通り、サーバー側は DB も証明書も EC2 インスタンス内においてあります。
当然あまりよろしくはないので、今後リファクタリングを進めたり実際に一定期間デプロイすることを考えるとプライベートサブネットを作って踏み台 Web サーバーを作り、インスタンスはプライベートサブネットに移して、AutoScaling グループにしてさらにそれを ALB で管理することになると思いますし、それに従って証明書は ALB で管理し、DB は RDS 等を用いることになると思います。
ただそうなると個人でやるには割とコストかかるなぁ……と感じますね、やはりインフラは重い……

要点

ネットをうまく探せば大まかなところは見つかるので、見つけられなかったところとか分かりづらいところを載せておきます。

  • 自動化ツールを使う場合、.env は基本的にはリソースに含まれないはずなので AWS System Manager の Parameter Store や CodeBuild の環境変数設定画面を用いて環境変数を設定する
  • 上記で設定した環境変数を buildspec.yml などを使って、ビルドする際に.env として書き出す
  • SPA、つまるところクロスオリジンにする場合、ローカルと違ってフロントからサーバーへの Request も Https で行わないと blocked mixed-content エラーになる
  • よって独自ドメインはフロントとサーバーの 2 つで必要となり、サーバー側の独自ドメインは ElasticIP とで名前解決の紐付けを行う
  • サーバーが Django の場合、SameSite 周りの設定が settigs.py 経由だとうまく行かない場合がある
  • 同様に、if settings.DEBUGが本番環境だと動作しないことがある
  • よって、開発環境でif settings.DEBUGで SameSite の設定を変えていた Middleware をsamesite='None'及びsecure=Trueで固定するように変更する

上記の点さえ把握してれば、あとはネットの海を漁れば以降のことは見なくてもできると思います。
今回は React、Django(DRF)でやってますが多分どの言語でもやることはだいたい同じだと思うので参考になれば幸いです。

フロント側

【AWS】S3+CloudFront+Route53+ACM で SSL 化(https)した静的 Web サイトを公開する
React で作った Web アプリを GitHub で管理して S3 に自動デプロイする
[CodeBuild]buildspec.yml での環境変数指定方法あれこれまとめ
React+Django+AWS でペットの成長をサポートするアプリを開発したので Tips をまとめた

上記サイトを参考にさせていただいてなんとかなりました。
特に下 2 つのサイトのおかげで.env をビルドの自動化のときにどう作ればいいのかというところを解決できたので本当に助かりました。
以下手順をセクションごとに。

準備

  • リポジトリの用意(今回は Github)
  • ASM で環境変数の定義
  • buildspec.yml の作成
  • 独自ドメインの取得(今回は freenom)

なお ASM の ParameterStore は KMS を使って暗号化することもできる。

buildspec.yml は CodeBuild で使う、Build の手順書みたいなもの。


version: 0.2

env:
#  ASMで作成した環境変数の指定
  parameter-store:
    buildspec.yml内での呼び出し方:ASM設定した環境変数のKey名(ex.REACT_APP_API_URL:https://localhost:8000の場合はREACT_APP_API_URL)
    key: "REACT_APP_API_URL"


phases:
  install:
    runtime-versions:
      nodejs: 14
  pre_build:
    commands:
    - if [ -e /tmp/node_modules.tar ]; then tar xf /tmp/node_modules.tar; fi
    - npm install
  build:
    commands:
    #コマンドの実行結果としてBuild Sequence……を出力する
      - echo "Build Sequence……"
    # .envを作成して、そこにREACT_APP_API_URL:https://localhost:8000を定義して出力する
      - echo "REACT_APP_API_URL=$key" >> .env
      - npm run build
  post_build:
    commands:
      - tar cf /tmp/node_modules.tar node_modules
artifacts:
  files:
    - '**/*'
  base-directory: build
cache:
  paths:
    - /tmp/node_modules.tar


phase 部分が実際のビルド時の動作についての指示。
ビルド前、ビルド中、ビルド後とそれぞれコマンドを設定できる。
作った buildspec.yml はプロジェクトルートにおいてリポジトリへ push しておく。

S3 バケットの作成

静的 Web ホスティングができるような設定で作成しておく。
不慣れであるなら、この段階ではバケットポリシーはパブリック読み取りアクセスを許可しておき、ブロックパブリックアクセスもオフにしておくと後でデプロイがうまく行っているか確認ができる。

CodePipeline と CodeBuild、CodeDeploy の設定

CodePipeline からパイプラインを作成するとそのまま一連の設定ができる。

2021-10-09_00h54_45.png

CodeCommit にあたる部分はソースプロパイダで CodeCommit 他用意したリポジトリを設定する。(今回は Github)
すると連携してくれと言われるので画面の通り進めていってリポジトリとビルドに使うブランチを指定する。

CodeBuild

2021-10-09_01h10_55.png

プロジェクトを作成するからビルドプロジェクトを作成する、CodeBuild で既に作ってあるならばそれを指定。
また環境変数はここでも設定できる。

2021-10-09_01h13_10.png

するとこんな感じでビルドに使うマシンの設定になるので OS を選んで任意の設定をする。
特に指定等なければ最新のイメージ、最新のバージョンを選択しておく。

CodeDeploy

2021-10-09_01h31_08.png

任意のデプロイ先を選択、今回は S3 を選択して、作ったバケットを選択する。

これでデプロイが始まる。
デプロイできたかの確認はこの段階であれば静的 Web ホスティングの設定画面にあるバケットウェブサイトエンドポイントの URL を叩いてみるとわかる。
サイトが表示されれば OK。

CloudFront

コンテンツ配信ではこれを使うと便利なので S3 とは基本的にセットで使っていくことが多い。
CDN としての役割の他に証明書を持たせて SSL での通信も行えるようにすることもできる。
この段階ではディストリビューションを作るだけ。

2021-10-09_02h50_09.png
2021-10-09_02h50_36.png

OAI を使ったアクセスにすることでバケットへの直接アクセスを避け、必ず CloudFront を経由したアクセスになる。
今回は S3 は静的 Web ホスティング用途で使っているだけで、インターネットからバケットにアクセスしてデータをどうのこうのとはしないので、なので AWS が作ってくれた OAI に基づいたバケットポリシーで問題ない。

Route53 と Certificate Manager

ここで SSL 通信を行うための設定を行う。
用意した独自ドメインを使って、ホストゾーンを作る。
できたホストゾーンの NS レコードの値をすべてドメインの取得先の DNS 設定(freenom だと NameServer)で登録する。

Certificate Manager では取得したドメインに対して SSL 証明書を作成する。
手順は以下の通り。

  • パブリック証明書のリクエストを行い、先程取得したドメイン名または*.ドメイン名の形式で登録する。
  • 証明書をリクエストできたら、証明書の画面に行き CNAME レコードを作成。作成手順は以下の 2 種
  • A. DNS 設定ファイルをエクスポートして Route53 から任意のホストゾーンでその設定ファイルの値に基づいて作成
  • B. 証明書の画面からドメイン欄をクリックすると Route53 でのレコードを作成というボタンが出るのでそこから作成

CNAME レコードが作成されてしばらくすると証明書が検証実行中のステータスから発行済になるのでそうなったら完了。
CloudFront に戻り、以下の設定を行う。

2021-10-09_02h51_23.png

代替ドメイン名の欄には取得したドメインを登録する。
SSL 証明書を作る際に*.ドメイン名と設定しておくと、画像の様にサブドメインも設定できる。
ここで気をつけておかないといけないことは必ずデフォルトルートオブジェクトオプションに index.html を指定しておくこと、でないと 403 エラーになる。
しかし、実は SPA でwindow.loactionを使ったページ遷移をしている場合これを設定しても 403 になる。
なのでその場合は

CloudFront で公開した SPA でページ遷移を行う時に、403エラーが返ってくる時の対処法

以上を参考に設定をしておく。

ここまで終わったら Route53 で A レコードを代替ドメイン名欄で指定した値を CloudFront のディストリビューションのエイリアスと紐つけて作成すればフロント側は完了。

サーバー側

EC2 インスタンス起動

冒頭に書いた通り、今回は API サーバー 1 台だけなので普通にインスタンスを起動します。
OS は Ubuntu の方がスムーズに行くかと思います。(特に postgres 周りのインストールは LinuxOS だと躓くポイントがいくつかある)
インスタンスを起動したら

  • 任意のクライアントでキーペアを使って SSL 接続でインスタンスに接続(AWS コンソール上でもできるはず)
  • sudo apt-get updateでアップデート、Linux とかだとここはsudo suしてからyum update -yとかだと思います。
  • 必要なものをインストール

今回インストールしたのは以下の通り。


sudo apt-get install python3-pip python3-dev libpq-dev postgresql postgresql-contrib nginx python3-venv

DB の設定

インストールが終わったら DB の設定を行います。
今回は Postgres なので以下の通り。


# postgres起動
sudo -u postgres psql
# DB名は任意
CREATE DATABASE ~;
# USER名、PASSWORDは任意
CREATE USER ~ WITH PASSWORD '~'
# 初期設定
ALTER ROLE ~ SET client_encoding TO 'utf8';
ALTER ROLE ~ SET default_transaction_isolation TO 'read committed';
ALTER ROLE ~ SET timezone TO 'UTC+9';
# 作ったUSERに作成したDBの権限をすべて付与。
GRANT ALL PRIVILEGES ON DATABASE ~db TO jira;
# uuidが使えるようにしておく
CREATE EXTENSION "uuid-ossp";

仮想環境の作成

Python は 3 以降から venv で仮想環境を構築することが推奨されているのでそれで行う。


# 仮想環境作成
python3 -m venv 仮想環境名
# 仮想環境をアクティブ
source 仮想環境名/bin/activate

# ちなみに無効化は以下
deactive

Django のセットアップ

  • sudo -H pip3 install --upgrade pipで pip のインストール
  • 仮想環境に入ってgit cloneでリポジトリをクローンする
  • nano .envで環境変数を作成
  • pip install -r requirements.txtで依存関係すべてインストール

予め、pip freeze > requirements.txtを Django のプロジェクトルート(通常であれば manage.py があるルート)で実行して書き出したものを push したリポジトリを用意しておくと楽。
.env 部分は


SECRET_KEY= ~
DEBUG=False
DATABASE_URL=postgres://ユーザー名:パスワード@localhost:/DB名
ALLOWED_HOSTS=EC2インスタンスのElasticIP,サーバー側に用意した独自ドメイン

と定義して作成。
また settings.py 部分はdjango-environを使って


env = environ.Env()
env.read_env(os.path.join(BASE_DIR, '.env'))

SECRET_KEY = env('SECRET_KEY')
DEBUG = env('DEBUG')
ALLOWED_HOSTS = env.list('ALLOWED_HOSTS')


# django-cors-headers3.4.0の場合
CORS_ORIGIN_WHITELIST = [
    "http://localhost:3000",
    "http://localhost:3000",
    "https://127.0.0.1:3000",
    "https://127.0.0.1:3000",
    "http://localhost:8000",
    "http://127.0.0.1:8000",
    "https://サーバー側の独自ドメイン"
]

# django-cors-headers3.5.0の場合
# CORS_ALLOWED_ORIGINS = [
#     "http://localhost:3000"
# ]

CSRF_TRUSTED_ORIGINS = ['localhost:3000', '127.0.0.1', 'サーバー側の独自ドメイン']

SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True

といったようにしておく、あとから Route53 の設定をしたあとにやってもよし。
また Middleware も開発時には以下のように DEBUG を見て samesite 周りの変更を行っていたところを


class SameSiteMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        from config import settings

        for key in response.cookies.keys():
            response.cookies[key]['samesite'] = 'Lax' if settings.DEBUG else 'None'
            response.cookies[key]['secure'] = not settings.DEBUG
        return response

このように常にsamesite='none'かつsecure=Trueにするようにしておく。


class SameSiteMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)
        from config import settings

        for key in response.cookies.keys():
            response.cookies[key]['samesite'] = 'None'
            response.cookies[key]['secure'] = True
        return response

if settings.DEBUGがうまく動作しない問題は

[Django]settings に DEBUG=True を定義してても、テスト実行すると DEBUG=False になってしまう罠

上記のような記事でも報告されてたりする、Django はこうイマイチそこでトラブル起きる?ってところで詰まることが多い気がする。
そもそも settings.py で指定してるのでそれで動いて欲しいものなのだけども……
閑話休題、.env ファイルの作成以外の変更は予めリポジトリを作成するときに一緒にやってしまっても構わないと思う。
nano エディタで変更するのはつかれるので。

あとは

  • python3 manage.py migrate
  • python3 manage.py collectstatic
  • python3 manage.py createsuperuser

とやっていく。
DB 周りの設定をやっていない migrate でエラーが出るので先に済ましたということになる。

ネットワークの設定

Route53

フロント同じ要領でサーバー側の独自ドメインの設定を行う。
ALB を使うと証明書もここで解決できるはずだが、今回はパス。

Gunicorn

  • sudo nano /etc/systemd/system/gunicorn.service

ファイルは以下の通り。


[Unit]
Description=gunicorn daemon
After=network.target

[Service]
User=ubuntu
Group=www-data
WorkingDirectory=/home/ubuntu/jira_api
ExecStart=/home/ubuntu/仮想環境名/bin/gunicorn --access-logfile - --workers 3 --bind unix:/home/ubuntu/プロジェクトフォルダ名(以下A)/config(wsgiが入っているフォルダ名).sock config.wsgi:application

[Install]
WantedBy=multi-user.target

ここで気をつけないと行けない部分はプロジェクトフォルダ名と wsgi が入っているフォルダ名の部分。
リポジトリから Clone した場合、リポジトリ名がプロジェクトフォルダ名にあたることになるはずである。
いずれにせよ、プロジェクトのルートディレクトリになるフォルダ名を指定すること。
wsgi が入っているフォルダ名はdjango-admin startproject mysiteで作成したフォルダ、つまり特に弄ってなければ settings.py が入っているフォルダになる。
そのフォルダに wsgi も一緒に入っているのでそのフォルダ名を指定すること。
このあたりは個々人で違ってくると思う部分なのに間違えると通信できなくなるので注意、この辺の関係が解説によってまばらだったりしたのでかなり沼りました。

nginx の設定

  • sudo nano /etc/nginx/sites-available/jira_api

一先ずは以下の通りにする。


server {
        listen 80;
        server_name サーバー側の独自ドメイン名;

        location = /favicon.ico {access_log off; log_not_found off;}
        location /static/ {
                root /home/ubuntu/Djangoのstaticフォルダがあるルート;
        }

        location / {
                include proxy_params;
                proxy_pass http://unix:/home/ubuntu/プロジェクトフォルダ名/wsgiがあるフォルダ名.sock;
        }
}

ポート番号を見ればわかるが Http 通信を行うための基本的なテンプレートのようなものらしい。

設定したらsudo ln -s /etc/nginx/sites-available/jira_api /etc/nginx/sites-enabled/でエイリアスを作っておく。
あとはsudo ufw allow 'Nginx Full'

SSL 通信を可能にする

ここまでしたら一応 Gunicorn と nginx を再起動する。

  • sudo systemctl restart gunicorn
  • sudo systemctl enable gunicorn
  • sudo systemctl restart nginx

きちんとデプロイできて、ここまでの設定がうまくいっていれば、ElasticIP/admin で URL で叩けば Django の管理ページが出るはずである。

しかし、このままだと SSL(Https)通信ができないのでそのための設定を行う。
今回は EC2 内部で発行管理するので Certbot で試してみる。

Certbot
参考: EC2 上の Django アプリを独自ドメイン、SSL 対応する

参考記事中の DNS の設定は Route53 で A レコードを同じ要領で作成すればよし。
Certbot の指示通り導入を行って、sudo certbot --nginx行い、さらに指示に従ってうまく作成できればsudo nano /etc/nginx/sites-available/jira_apiで作成したファイルが


server {
        server_name サーバー側の独自ドメイン名;

        location = /favicon.ico {access_log off; log_not_found off;}
        location /static/ {
                root /home/ubuntu/Djangoのstaticフォルダがあるルート;
        }

        location / {
                include proxy_params;
                proxy_pass http://unix:/home/ubuntu/プロジェクトフォルダ名/wsgiがあるフォルダ名.sock;
        }

    listen 443 ssl; # managed by Certbot
    ssl_certificate /etc/letsencrypt/live/challengejiradjango.tk/fullchain.pem; # manage>
    ssl_certificate_key /etc/letsencrypt/live/challengejiradjango.tk/privkey.pem; # mana>
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}
server {
    if ($host = サーバー側の独自ドメイン名) {
        return 301 https://$host$request_uri;
    } # managed by Certbot


        listen 80;
        server_name サーバー側の独自ドメイン名;
    return 404; # managed by Certbot


}

以上のような形に変更され、SSL 通信が可能となっているはずである。
あとはセキュリティグループで Http、SSL、HTTPS を許可しているか確認をし、再度 nginx→gunicorn の順でリセットを行えば全行程が終了、お疲れ様でした。
ちなみに、Nginx でエラーが出ている場合はsudo tail -f /var/log/nginx/error.logでログを確認してみる。

最後に

リファクタリングの余地はかなりあるが、これでクロスオリジン前提での React(というか JS フレームワーク)と Django(DRF)のデプロイをするためには最低限何をすればいいのかということが把握できた。
主にリファクタリングの余地はサーバー側にたくさんあるはずで、いま思いつく限りでも

  • デプロイの自動化
  • EC2 ではなく、ALB を使った SSL 証明書の管理と SSL 通信
  • サブネットを分けて踏み台サーバーを作る
  • AutoScaling を導入し、マルチ AZ にする
  • DB は RDS にする

などがある。
ただ今回やったサーバー側の処理はおそらく API GateWay と Lambda で置き換えることができて、しかもその方が都合が良さそうだなとは感じた。
Django 側で複雑なデータ処理を行う……という工程があればまた別なのかもしれないけども、その場合でも FastAPI の方が適していそうな気がするし、プログラミングの世界は本当に終わりがないんだなということをしみじみと感じました。

もし今回の記事で間違っているところや補足の解説、ベストプラクティスやリファクタリング案などあればぜひ教えて頂けると嬉しいです。
とりあえずは、DVA 取得に向けて頑張ろうと思います。

Discussion