この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
こんにちは、CX事業本部のうらわです。
今回はバックエンドはSpring Boot、フロントエンドはReact(SPA)のWebアプリケーションをAWS CDK(以下、CDK)でAmazon ECSデプロイしてみました。フロントエンドはS3/Cloud Frontで配信するのではなく、Spring Bootの一部としてデプロイします。
本記事ではSpring BootやReactで実装するアプリ自体のコードの説明は重要な点のみです。全てのコード例は以下のGitHubを参照ください。
作業環境
$ 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を作成します。
フロントエンドは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.json
にbuild:docker
とpostbuild
というコマンドを追加します。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はprexxxx
やpostxxxx
という名前でコマンドを定義しておくと、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自体まだ使い慣れていないため、この構成でいろいろ試してみようと思います。