TypeScriptフルスタックフレームワークのfrourioをAWSのサーバレスな環境で動かす

21 min read読了の目安(約19100字

はじめに

最近話題のfrourioというTypeScript製のフルスタックフレームワークがあります。
簡単かつ快適にTypeScriptフルスタック環境を構築できるので、結構遊んでます。

https://github.com/frouriojs/frourio

今回はこちらのfrourioをAWSで使い、サーバーレスな環境を構築してみることにします。

私個人がTerraformに使い慣れているので、基本Terraformベースで環境構築を行っていきます。

記事では以下のものを使って環境を構築していきます。適宜ご自身の環境に合わせて対応していただければと思います。

  • AWSアカウント
  • Route53で取得してあるドメイン
  • Terraform、ServerlessFramework、Docker、aws-cliが動作する環境

構成

簡単な構成図は以下のようになります。

バックエンドはECSでFargateを使います。

フロントエンドはS3+CloudFrontな環境でLambda@Edgeを使います。
ただフロント部分を手動で設定するとなるとそこそこ面倒なので、ここではServerlessFrameworkのNext.jsPluginを使います。

https://www.serverless.com/plugins/serverless-nextjs-plugin

ローカル環境構築

最初はローカル環境でアプリを動かしてみましょう。
既に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を書きます。

frourio-aws/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するように変更しておきます。

frourio-aws/server/indexx.ts
fastify.listen(SERVER_PORT, '0.0.0.0')

serverのscriptsをちょっとだけいじっておきます。

frourio-aws/server/package.json
"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します。

frourio-aws/terraform/variables.tf
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ゲートウェイも作成しておきましょう。

frourio-aws/terraform/vpc.tf
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を作成します。

frourio-aws/terraform/rds.tf
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に定義しておきます。

frourio-aws/terraform/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パラメーターに定義しておきましょう。

frourio-aws/terraform/ssm.tf
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にしていますが、何でもいいです。

frourio-aws/terraform/alb.tf
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をご自身のドメインに書き換えて参照ください。

frourio-aws/terraform/route53.tf
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のリポジトリを作成します。

frourio-aws/terraform/ecs.tf
resource "aws_ecr_repository" "api" {
  name                 = "${var.basename}-api"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

URLを出力させて、applyします。

frourio-aws/terraform/output.tf
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のスペックはお好みで。

frourio-aws/terraform/ecs.tf
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
}
frourio-aws/terraform/taskdef.json
[
  {
    "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で細かい設定が可能です。

https://www.serverless.com/plugins/serverless-nextjs-plugin#inputs

ビルド時の環境変数の設定では、先程作成したFargateに通じるURLを設定しておきます。

また、componentは常に最新版を使うようにします。ここでリリースされているcomponentから最新のものを選びます。

https://github.com/serverless-nextjs/serverless-next.js/releases
frourio-aws/serverless.yml
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環境を簡単に提供してくれる良いフレームワークです。

https://github.com/frouriojs/frourio

これからもこれを使って遊んでいきたいなと思ってます。

皆さんもぜひ試してみてください!