TypeScriptフルスタックフレームワークのfrourioをAWSのサーバレスな環境で動かす
はじめに
最近話題のfrourioというTypeScript製のフルスタックフレームワークがあります。
簡単かつ快適にTypeScriptフルスタック環境を構築できるので、結構遊んでます。
今回はこちらのfrourioをAWSで使い、サーバーレスな環境を構築してみることにします。
私個人がTerraformに使い慣れているので、基本Terraformベースで環境構築を行っていきます。
記事では以下のものを使って環境を構築していきます。適宜ご自身の環境に合わせて対応していただければと思います。
- AWSアカウント
- Route53で取得してあるドメイン
- Terraform、ServerlessFramework、Docker、aws-cliが動作する環境
構成
簡単な構成図は以下のようになります。
バックエンドはECSでFargateを使います。
フロントエンドはS3+CloudFrontな環境でLambda@Edgeを使います。
ただフロント部分を手動で設定するとなるとそこそこ面倒なので、ここではServerlessFrameworkのNext.jsPluginを使います。
ローカル環境構築
最初はローカル環境でアプリを動かしてみましょう。
既にAWS環境にデプロイしたいfrourioアプリがある方は飛ばしてOKです。
早速frourioをセットアップしていきたいところですが、今回はDBにPostgreSQLを使うので、まずはローカルにPostgreSQLを用意してみます。
方法は何でもいいですが、ここではDockerを使ってサクッと立ち上げてみます。
$ docker run --rm -d \
-p 5432:5432 \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=frourio \
-e DATABASE_HOST=localhost \
postgres:12-alpine
続いてローカル環境にfrourioをセットアップしていきます。
create-frourio-appを使うことでGUIベースで簡単にセットアップできます。
$ npx create-frourio-app
こちらのコマンドを実行するとlocalhost:3000でセットアップ用のGUIが開くので、ポチポチ入力していきます。
今回は以下のような選択で構成します。
- Directory name ...
frourio-aws
- Server engine ...
Fastify
- Client framework ...
Next.js
- Building mode ...
Static
- HTTP client of aspida ...
axios
- React Hooks for data fetching ...
SWR
- Daemon process manager ...
None
- O/R mapping tool ...
Prisma
- Database type of Prisma ...
PostgreSQL
(先程立ち上げたPostgreSQLの設定に沿って入力します)
server/prisma/.env HOST= ...localhost
server/prisma/.env PORT= ...5432
server/prisma/.env USER= ...postgres
server/prisma/.env PASSWORD= ...password
server/prisma/.env DATABASE= ...frourio
- Testing framework ...
Jest
- Package manager ...
Yarn
- CI config ...
None
入力してしばらく待つとlocalhost:3000でfrourioアプリが立ち上がっているのが確認できると思います。
デフォルトではtodoアプリのようなものと、ログイン機能が搭載されているようです。
(ログインに必要な情報はserver/.envに記載されています)
バックエンドのDocker化
バックエンドのAPIサーバーはFargateで動作させるので、Docker化させます。
serverディレクトリ下がバックエンドのファイル群になっています。
バックエンドのDockerfileを書きます。
FROM node:15.7.0-slim
RUN apt-get update && apt-get install -y python g++ make
RUN mkdir /src
WORKDIR /src
COPY package.json ./
RUN yarn && yarn cache clean
COPY . .
RUN apt-get clean && rm -rf /var/lib/apt/lists/* && rm -rf /var/cache/debconf/*
fastifyはデフォルトではlocalhostでlistenしています。Dockerで使うため0.0.0.0でlistenするように変更しておきます。
fastify.listen(SERVER_PORT, '0.0.0.0')
serverのscriptsをちょっとだけいじっておきます。
"scripts": {
"ecs": "npm run build:frourio && webpack --mode=production && cross-env NODE_ENV=production node index.js" // 追加
}
バックエンドのAWS環境の構築
AWS環境の構築を始めます。
冒頭に書いたとおり、AWS環境は基本Terraformで書いていきます。
プロジェクトのルートディレクトリにTerraform用のディレクトリを作成します。
$ mkdir terraform
このディレクトリにtfファイルを配置していきます。
terraformのセットアップとしてvariables.tfにプロバイダーの設定と、basename変数の定義して、initします。
provider "aws" {
region = "ap-northeast-1"
profile = "default"
}
variable basename {
default = "frourio-app"
}
$ cd terraform
$ terraform init
これでtfファイルを書く準備は整いました。ゴリゴリ書いていきます。
VPC
まずはVPCの構築です。
冒頭にあった構成図のようなネットワークを作るには、プライベートサブネットにDBを配置し、パブリックサブネットにAPIサーバーを配置させます。
プライベートサブネットはパブリックサブネットからのポート5432のインバウンドを許可、
パブリックサブネットはAPIサーバー用のポート8080のインバウンドを許可するようなセキュリティグループを作成しておきます。
ALB用のセキュリティグループもここで作成します。
プライベートサブネット用にNATゲートウェイも作成しておきましょう。
resource "aws_vpc" "vpc" {
cidr_block = "20.0.0.0/16"
instance_tenancy = "default"
enable_dns_support = "true"
enable_dns_hostnames = "false"
tags = { Name = var.basename }
}
resource "aws_internet_gateway" "internet_gateway" {
vpc_id = aws_vpc.vpc.id
tags = { Name = "${var.basename}-ig" }
}
resource "aws_subnet" "public_subnet_1" {
vpc_id = aws_vpc.vpc.id
cidr_block = "20.0.1.0/24"
availability_zone = "ap-northeast-1a"
tags = { Name = "${var.basename}-public-subnet-1" }
}
resource "aws_subnet" "public_subnet_2" {
vpc_id = aws_vpc.vpc.id
cidr_block = "20.0.2.0/24"
availability_zone = "ap-northeast-1c"
tags = { Name = "${var.basename}-public-subnet-2" }
}
resource "aws_subnet" "private_subnet_1" {
vpc_id = aws_vpc.vpc.id
cidr_block = "20.0.3.0/24"
availability_zone = "ap-northeast-1a"
tags = { Name = "${var.basename}-private-subnet-1" }
}
resource "aws_subnet" "private_subnet_2" {
vpc_id = aws_vpc.vpc.id
cidr_block = "20.0.4.0/24"
availability_zone = "ap-northeast-1c"
tags = { Name = "${var.basename}-private-subnet-2" }
}
resource "aws_route_table" "public_route" {
vpc_id = aws_vpc.vpc.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.internet_gateway.id
}
tags = { Name = "${var.basename}-public-route" }
}
resource "aws_route_table_association" "puclic" {
subnet_id = aws_subnet.public_subnet_1.id
route_table_id = aws_route_table.public_route.id
}
resource "aws_route_table" "private_route" {
vpc_id = aws_vpc.vpc.id
tags = { Name = "${var.basename}-private-route" }
}
resource "aws_route_table_association" "private_1" {
subnet_id = aws_subnet.private_subnet_1.id
route_table_id = aws_route_table.private_route.id
}
resource "aws_route" "private" {
route_table_id = aws_route_table.private_route.id
nat_gateway_id = aws_nat_gateway.private_db.id
destination_cidr_block = "0.0.0.0/0"
}
resource "aws_nat_gateway" "private_db" {
allocation_id = aws_eip.nat_gateway.id
subnet_id = aws_subnet.public_subnet_1.id
tags = { Name = "${var.basename}-private-db-nat-gateway" }
}
resource "aws_eip" "nat_gateway" {
vpc = true
depends_on = [aws_internet_gateway.internet_gateway]
tags = { Name = "${var.basename}-nat-gateway-eip" }
}
resource "aws_security_group" "public" {
name = "${var.basename}-public"
vpc_id = aws_vpc.vpc.id
tags = { Name = "${var.basename}-public" }
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "private" {
name = "${var.basename}-private"
vpc_id = aws_vpc.vpc.id
tags = { Name = "${var.basename}-private" }
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.public.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "alb" {
name = "${var.basename}-alb-sg"
vpc_id = aws_vpc.vpc.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.basename}-alb-sg" }
}
RDS
RDSを作成します。
resource "aws_db_subnet_group" "db_subnet_group" {
name = "${var.basename}-db-subnet-group"
subnet_ids = [
aws_subnet.private_subnet_1.id,
aws_subnet.private_subnet_2.id
]
tags = { Name = "${var.basename}-db-subnet-group" }
}
resource "aws_db_instance" "postgres" {
identifier = "${var.basename}-postgres"
allocated_storage = 20
storage_type = "gp2"
engine = "postgres"
engine_version = "12"
instance_class = "db.t2.micro"
name = "frourio"
username = "postgres"
password = "uninitialized"
vpc_security_group_ids = [aws_security_group.private.id]
db_subnet_group_name = aws_db_subnet_group.db_subnet_group.name
skip_final_snapshot = true
lifecycle {
ignore_changes = [password, engine_version]
}
}
サブネットグループは先程作成した2つのプライベートサブネットを指定します。
エンドポイントを出力できるようにoutput.tfに定義しておきます。
output "db_endpoint" {
value = aws_db_instance.postgres.address
}
DBインスタンスのパスワードですが、ここで直接設定してしまうとtfstateに書き込まれてしまうので、一旦適当なパスワードを設定してapplyします。
このときに出力されるDBインスタンスのエンドポイントは後で使うので控えておきます。
$ terraform apply
その後aws-cliで以下のようなコマンドを使い、正しいパスワードを設定してあげてください。
$ aws rds modify-db-instance --db-instance-identifier 'frourio-app-postgres' \
--master-user-password 'NewPassword'
DBリソースを作成できたので、prisma用のDATABASE_URLを作成します。
ECSリソースを作成する際のタスク定義で指定するために、SSMパラメーターに定義しておきましょう。
resource "aws_ssm_parameter" "database_url" {
name = "/${var.basename}/database_url"
value = "uninitialized"
type = "SecureString"
lifecycle {
ignore_changes = [value]
}
}
これもそのままvalueにDATABASE_URLを直接書いてしまうとtfstateに平文で書かれてしまうので、まずは上のようにvalueを適当な値で埋めてapplyします。
$ terraform apply
その後、aws-cliで実際に使うDATABASE_URLを登録してあげます。
$ aws ssm put-parameter --name '/frourio-app/database_url' --type SecureString \
--value 'postgresql://DBユーザー名:DBパスワード@DBエンドポイント:5432/DB名' --overwrite
ALB
ALBをセットアップします。特に面倒なことはしてないです。
httpsで通信を受け取り、この後作るFargateに流すようにします。
https用の証明書もここで取っています。面倒なので認証方法をEMAILにしていますが、何でもいいです。
resource "aws_lb" "service" {
name = "${var.basename}-alb"
load_balancer_type = "application"
internal = false
ip_address_type = "ipv4"
idle_timeout = 60
enable_deletion_protection = false
subnets = [
aws_subnet.public_subnet_1.id,
aws_subnet.public_subnet_2.id
]
security_groups = [aws_security_group.alb.id]
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.service.arn
port = "443"
protocol = "HTTPS"
certificate_arn = aws_acm_certificate.frourio.arn
ssl_policy = "ELBSecurityPolicy-2016-08"
lifecycle {
ignore_changes = [default_action]
}
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
resource "aws_lb_target_group" "main" {
name = "${var.basename}-service-tg"
vpc_id = aws_vpc.vpc.id
port = 8080
protocol = "HTTP"
target_type = "ip"
health_check {
path = "/api/tasks"
healthy_threshold = 5
unhealthy_threshold = 2
timeout = 5
interval = 30
matcher = 200
port = 8080
protocol = "HTTP"
}
depends_on = [aws_lb.service]
}
resource "aws_acm_certificate" "frourio" {
domain_name = "frourio.example.com"
validation_method = "EMAIL"
}
Route53
Route53でドメインの設定をしておきます。
example.comをご自身のドメインに書き換えて参照ください。
data "aws_route53_zone" "default" {
name = "example.com"
}
resource "aws_route53_record" "api" {
zone_id = data.aws_route53_zone.default.zone_id
name = "frourio.example.com"
type = "A"
alias {
name = aws_lb.service.dns_name
zone_id = aws_lb.service.zone_id
evaluate_target_health = true
}
}
ECS
ECSのリソースを作成していきます。
まずはECRのリポジトリを作成します。
resource "aws_ecr_repository" "api" {
name = "${var.basename}-api"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
URLを出力させて、applyします。
output "ecr_url" {
value = aws_ecr_repository.api.repository_url
}
$ terraform apply
一旦ここで先程作成したDockerイメージをECRリポジトリにpushします。
$ aws ecr get-login-password | docker login --username AWS --password-stdin https://<aws_account_id>.dkr.ecr.<region>.amazonaws.com
$ cd server
$ docker build . -t <先程出力させたECRのURL>:latest
$ docker push <先程出力させたECRのURL>:latest
ECRにlatestバージョンのイメージをpushできたので、これをpullするようなFargateリソースを作成していきます。
余談ですがガチガチに本番運用する際はタグをlatestで固定するのはおすすめしません。
CI/CDのフローでECRにpushする際にgitのコミット番号等をタグに付与することで、トレーサビリティを高める設計にするのがおすすめです。
では他のECRリソースを作成していきます。
database_urlに先程のSSMパラメーターで作ったものを指定しておきます。
Fargateのスペックはお好みで。
resource "aws_ecs_cluster" "ecs_cluster" {
name = "${var.basename}-cluster"
}
resource "aws_ecs_service" "ecs_service" {
name = "${var.basename}-service"
cluster = aws_ecs_cluster.ecs_cluster.id
task_definition = aws_ecs_task_definition.task_definition.arn
desired_count = "1"
launch_type = "FARGATE"
network_configuration {
subnets = [aws_subnet.public_subnet_1.id, aws_subnet.public_subnet_2.id]
security_groups = [aws_security_group.public.id]
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.main.arn
container_name = "${var.basename}-api"
container_port = 8080
}
lifecycle {
ignore_changes = [desired_count, task_definition]
}
depends_on = [aws_lb_target_group.main]
}
resource "aws_ecs_task_definition" "task_definition" {
family = "${var.basename}-api"
cpu = 256
memory = 512
container_definitions = data.template_file.api_task_difinition.rendered
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
task_role_arn = aws_iam_role.ecs_task_execution_role.arn
execution_role_arn = aws_iam_role.ecs_task_execution_role.arn
}
data "template_file" "api_task_difinition" {
template = file("./taskdef.json")
vars = {
basename = var.basename
database_url = aws_ssm_parameter.database_url.name
api_origin = "https://frourio.example.com"
ecr_api_url = aws_ecr_repository.api.repository_url
logs_group_api = "/${var.basename}/ecs_app/api"
}
}
resource "aws_cloudwatch_log_group" "api" {
name = "/${var.basename}/ecs_app/api"
retention_in_days = 180
}
resource "aws_iam_role" "ecs_task_execution_role" {
name = "${var.basename}_ecs_task_execution_role"
assume_role_policy = data.aws_iam_policy_document.assume_role.json
}
data "aws_iam_policy_document" "assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
data "aws_iam_policy" "ecs_task_execution_role_policy_base" {
arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "ssm" {
source_json = data.aws_iam_policy.ecs_task_execution_role_policy_base.policy
statement {
effect = "Allow"
actions = ["ssm:GetParameters", "kms:Decrypt"]
resources = ["*"]
}
}
resource "aws_iam_policy" "ecs_task_execution_role_policy" {
name = "${var.basename}-task-execution-policy"
policy = data.aws_iam_policy_document.ssm.json
}
resource "aws_iam_role_policy_attachment" "ecs_task_execution_role" {
role = aws_iam_role.ecs_task_execution_role.name
policy_arn = aws_iam_policy.ecs_task_execution_role_policy.arn
}
[
{
"name": "${basename}-api",
"image": "${ecr_api_url}:latest",
"memoryReservation": 512,
"cpu": 256,
"command": ["yarn", "ecs"],
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-region": "ap-northeast-1",
"awslogs-stream-prefix": "api",
"awslogs-group": "${logs_group_api}"
}
},
"portMappings": [
{
"containerPort": 8080,
"hostPort": 8080
}
],
"secrets": [{ "name": "DATABASE_URL", "valueFrom": "${database_url}" }],
"environment": [
{ "name": "SERVER_PORT", "value": "8080" },
{ "name": "BASE_PATH", "value": "/api" },
{ "name": "API_ORIGIN", "value": "${api_origin}" },
{ "name": "JWT_SECRET", "value": "supersecret" },
{ "name": "USER_ID", "value": "id" },
{ "name": "USER_PASS", "value": "pass" }
]
}
]
この段階でapplyして、ALBのターゲットグループが正常なStatusであれば、対象のドメインにアクセスするとAPIレスポンスが返ってくるはずです。
$ terraform apply
例)/api/tasks
一旦ここでバックエンドの環境は完了です。
フロントエンドのAWS環境の構築
ではフロントエンドのAWS環境を構築していきます。
バックエンドの環境構築ではTerraformを使ってゴリゴリ書いてましたが、フロント部分はServerlessFrameworkのNext.js Pluginを使おうと思います。
プロジェクトのルートディレクトリにserverless.ymlを書きます。
inputsで細かい設定が可能です。
ビルド時の環境変数の設定では、先程作成したFargateに通じるURLを設定しておきます。
また、componentは常に最新版を使うようにします。ここでリリースされているcomponentから最新のものを選びます。
frourio-app:
component: "@sls-next/serverless-component@1.19.0-alpha.30"
inputs:
bucketName: "frourio-app"
bucketRegion: "ap-northeast-1"
build:
cmd: "yarn"
args: ["build:client"]
env:
API_ORIGIN: "https://frourio.example.com"
BASE_PATH: "/api"
serverlessコマンドを実行します。
$ npx serverless
私だけかもしれませんが、ここでnpmエラーが発生してデプロイに失敗しました。Mac環境とWSL環境両方で発生したのであるあるなのかもしれないです。
error:
Error: Command failed: npm install @sls-next/serverless-component@1.19.0-alpha.30 --prefix /home/takamura/.serverless/components/registry/npm/@sls-next/serverless-component@1.19.0-alpha.30
npm ERR! code ENOENT
...
こんなエラーですが、ディレクトリを作れてないっぽいので下記のコマンドのように作ってあげると成功します。
$ mkdir -p /home/takamura/.serverless/components/registry/npm/@sls-next/serverless-component@1.19.0-alpha.30
$ npx serverless
デプロイが完了すると、S3バケット、Lambda@Edge用のLambda関数、CloudFrontリソース等がAWSにデプロイされているはずです。
CloudFrontのエンドポイントがコンソールに出力されているはずなので、アクセスできれば成功です。
ローカル環境で動作したTODO機能や、ログイン機能も問題なく使えています。
注意点・補足など
フロントエンドはServerlessFrameworkのNext.js Pluginを使用しましたが、ISRには対応していないようです。
また、今回はCI/CDフローには触れていませんが、ECS環境でDockerを使う以上あったほうが良いのは間違いないです。そこらへんは好みや事情によると思いますが、フルAWSでやるのであればCodeシリーズが良いのかなと思ってます。
バックエンドのTerraformは説明の都合上、端折っているところが色々あります。例えばログの出力など…。自分好みにカスタマイズしていただければと思っています。
おわりに
以上でFrourioをAWS環境でサーバーレスに動かす説明は終わりです。
結構駆け足でバックエンドをTerraformで書き上げ、フロントはServerlessFramework万歳な感じで構築してみましたが、いかがだったでしょうか。
Terraformを使ってガッツリ書いているのであまり簡単な構築方法とは言えませんが、少しでも参考になれば幸いです。他にも色んな構成があると思いますが、今回はサーバーレスな環境を重点的に書きました。
frourioはフルスタックなTypeScript環境を簡単に提供してくれる良いフレームワークです。
これからもこれを使って遊んでいきたいなと思ってます。
皆さんもぜひ試してみてください!
Discussion