Spring Boot/Reactで実装したWebアプリをCDKでECSにデプロイする

2021.05.12

こんにちは、CX事業本部のうらわです。

今回はバックエンドはSpring Boot、フロントエンドはReact(SPA)のWebアプリケーションをAWS CDK(以下、CDK)でAmazon ECSデプロイしてみました。フロントエンドはS3/Cloud Frontで配信するのではなく、Spring Bootの一部としてデプロイします。

本記事ではSpring BootやReactで実装するアプリ自体のコードの説明は重要な点のみです。全てのコード例は以下のGitHubを参照ください。

urawa72/spring-boot-react-app

作業環境

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.15.7
BuildVersion:   19H15

$ java --version
openjdk 11.0.11 2021-04-20
OpenJDK Runtime Environment AdoptOpenJDK-11.0.11+9 (build 11.0.11+9)
OpenJDK 64-Bit Server VM AdoptOpenJDK-11.0.11+9 (build 11.0.11+9, mixed mode)

$ gradle --version
------------------------------------------------------------
Gradle 6.8.3
------------------------------------------------------------

Build time:   2021-02-22 16:13:28 UTC
Revision:     9e26b4a9ebb910eaa1b8da8ff8575e514bc61c78

Kotlin:       1.4.20
Groovy:       2.5.12
Ant:          Apache Ant(TM) version 1.10.9 compiled on September 27 2020
JVM:          11.0.11 (AdoptOpenJDK 11.0.11+9)
OS:           Mac OS X 10.15.7 x86_64

$ node -v
v14.15.4

$ npm -v
6.14.10

$ yarn -v
1.22.10

Spring Bootでバックエンド(API)を実装する

以下のチュートリアルの前半を参考にして/api/employeesでEmployeeデータを得ることができるAPIを作成します。

HATEOAS で REST API の構築

フロントエンドはReactで実装するため、バックエンドでは/へのリクエストでindex.htmlを返却するのみのコントローラを用意しておきます。

src/main/java/com/example/payroll/HomeController.java

package com.example.payroll;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class HomeController {
    @GetMapping("/")
    public String index() {
        return "forward:/index.html";
    }
}

また、APIへのリクエストはベースURLを変更しておきます。これについてはstackoverflowで色々な方法が議論されています。

今回はBaseControllerというクラスを作成しこのクラスを継承したコントローラに記述されたリクエストに全て/apiがprefixにつくようにします(上記のstackoverflownの回答を参考にしています)。

src/main/java/com/example/payroll/BaseController.java

package com.example.payroll;

import org.springframework.web.bind.annotation.RequestMapping;

@RequestMapping("api")
public abstract class BaseController {}

Reactでフロントエンド(SPA)を実装する

Spring BootアプリのフロントエンドにReactを組み込みます。こちらは以下の記事を参考にさせていただきました。

Spring BootアプリにCreate React Appを導入する

frontendというディレクトリでReactアプリの雛形を作成します。create-react-appを利用します。

$ npx create-react-app frontend --template typescript

package.jsonbuild:dockerpostbuildというコマンドを追加します。build:dockerは名前の通りdockerでビルドする時に使用します。

frontend/package.json

  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "build:docker": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "postbuild": "node ./postbuild.js"
  },

postbuildはローカルでのbuild実行後、以下のようなスクリプトを実行してビルド結果をSpring Boot側のディレクトリにコピーします。Spring Bootは先ほど作成したHomeController.javaでここでコピーしたReactアプリのビルド結果に含まれるindex.htmlを返却してくれます。

※ npm scriptsはprexxxxpostxxxxという名前でコマンドを定義しておくと、xxxxというコマンドの実行前後に自動で実行してくれます(参考)。

frontend/postbuild.js

const path = require('path');
const fs = require('fs-extra');

const BUILD_DIR = path.join(__dirname, './build');
const PUBLIC_DIR = path.join(__dirname, '../backend/src/main/resources/public');

fs.emptyDirSync(PUBLIC_DIR);
fs.copySync(BUILD_DIR, PUBLIC_DIR);

Spring BootアプリへのAPIリクエストなど、上記以外のReactアプリのコードは冒頭に記載したGitHubを参照ください。

CDKでデプロイする

cdkというディレクトリ作成して実装します。今回はTypeScriptを使用します。

$ mkdir cdk && cd cdk
$ cdk init -l typescript

Dockerイメージのpush

まずはSpring Boot/ReactアプリのDockerイメージを格納するECRリポジトリを作成しデプロイします。

cdk/lib/ecr-repo-stack.ts

import * as cdk from '@aws-cdk/core';
import * as ecr from '@aws-cdk/aws-ecr';

export class EcrRepoStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const region = props?.env?.region;
    const accountId = props?.env?.account;

    new ecr.Repository(this, 'ecr-repo', {
      repositoryName: 'spring-boot-react-app-repo',
    });

    new cdk.CfnOutput(this, 'ecr-repo-uri', {
      value: `${cdk.Aws.ACCOUNT_ID}.dkr.ecr.${cdk.Aws.REGION}.amazonaws.com`,
    });

    new cdk.CfnOutput(this, 'ecr-login-password', {
      value: `aws ecr get-login-password --region ${region} \
| docker login --password-stdin --username AWS \
"${accountId}.dkr.ecr.${region}.amazonaws.com"`,
    });
  }
}
$ yarn cdk deploy ecr-repo-stack

今回使用するDockerfileは以下です。マルチステージビルドでSpring BootとReactそれぞれビルドします。

Dockerfile

FROM node:14-alpine AS frontend
WORKDIR /tmp
COPY ./frontend ./frontend
WORKDIR /tmp/frontend
RUN yarn install
RUN yarn build:docker

FROM openjdk:11-jdk-slim AS builder
WORKDIR /tmp
COPY ./backend ./backend
WORKDIR /tmp/backend
COPY --from=frontend /tmp/frontend/build /tmp/app/src/main/resources/public
RUN ./gradlew build

FROM openjdk:11-jdk-slim
WORKDIR /app
COPY --from=builder /tmp/backend/build/libs/payroll-0.0.1-SNAPSHOT.jar .
ENTRYPOINT ["java", "-jar", "payroll-0.0.1-SNAPSHOT.jar"]

まずはdocker loginで対象リポジトリにpushできるようにしておきます。

$ aws ecr get-login-password --region ap-northeast-1 | docker login --password-stdin --username AWS "<account_id>.dkr.ecr.ap-northeast-1.amazonaws.com"

つづいて、ビルド・タグ付けしてpushします。

$ docker build -t spring-boot-react-app .
$ docker tag <image_id> <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/spring-boot-react-app-repo
$ docker push <account_id>.dkr.ecr.ap-northeast-1.amazonaws.com/spring-boot-react-app-repo

ECSのデプロイ

以下のCDKのコードでデプロイします。@aws-cdk/aws-ecs-patternsというハイレベルコンストラクトを利用してさくっとALBとECSをデプロイします。

cdk/lib/ecs-app-stack.ts

import * as cdk from '@aws-cdk/core';
import * as ec2 from '@aws-cdk/aws-ec2';
import * as ecs from '@aws-cdk/aws-ecs';
import * as ecsPatterns from '@aws-cdk/aws-ecs-patterns';
import * as ecr from '@aws-cdk/aws-ecr';

export class EcsAppStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // パブリックサブネット
    const vpc = new ec2.Vpc(this, 'app-vpc', {
      maxAzs: 2,
      cidr: '10.1.0.0/16',
      subnetConfiguration: [
        { subnetType: ec2.SubnetType.PUBLIC, name: 'public', cidrMask: 24 },
      ],
    });

    const cluster = new ecs.Cluster(this, 'app-cluster', {
      vpc: vpc,
      capacity: {
        instanceType: new ec2.InstanceType('t2.small'),
        minCapacity: 2,
      },
    });

    // 今回は諸事情により起動タイプEC2
    const appTaskDef = new ecs.TaskDefinition(this, 'app-task-def', {
      compatibility: ecs.Compatibility.EC2,
    });

    const repo = ecr.Repository.fromRepositoryName(
      this,
      'ecr-repo',
      'spring-boot-react-app-repo',
    );

    // イメージをECRから取得する
    appTaskDef
      .addContainer('app-container', {
        image: ecs.ContainerImage.fromEcrRepository(repo),
        cpu: 256,
        memoryLimitMiB: 256,
        dockerLabels: { app: 'spring-boot-react-app' },
        logging: ecs.LogDrivers.awsLogs({ streamPrefix: 'App' }),
      })
      .addPortMappings({ containerPort: 8080 });

    // ハイレベルコンストラクト
    const appService = new ecsPatterns.ApplicationLoadBalancedEc2Service(
      this,
      'app-service-with-alb',
      {
        cluster: cluster,
        serviceName: 'spring-boot-react-app',
        desiredCount: 2,
        taskDefinition: appTaskDef,
      },
    );

    appService.service.addPlacementStrategies(
      ecs.PlacementStrategy.spreadAcross(
        ecs.BuiltInAttributes.AVAILABILITY_ZONE,
      ),
    );
  }
}
$ yarn cdk deploy ecs-app-stack

デプロイが完了すると、URLが表示されるのでアクセスしてみます。

Employeesにアクセスすると、Spring Bootで実装したバックエンドのAPIにGETリクエストを送り、ユーザー一覧を取得して表示します。

なお、SPAなので画面遷移はブラウザで完結しています(/settingsへの画面遷移はサーバへのリクエストが発生しない)。

おわりに

バックエンドはSpring Boot、フロントエンドはReactという構成でECSにアプリケーションをデプロイしてみました。Thymeleaf等のテンプレートエンジンではなく、自分が使い慣れたReactでフロントエンドを実装できるので個人的にはお気に入りです。Spring Boot自体まだ使い慣れていないため、この構成でいろいろ試してみようと思います。