Amazon API Gateway + AWS Fargate をJavaで書こう!

マネックス証券 システム開発推進部のHです。

はじめに

今回もCDKネタです。前回はCDKを使用して、Kubernetes環境構築の記事を書きましたが、今回はAmazon API Gateway+AWS Fargateの環境をCDKで構築します。言語はいつも通りJavaです。

キーワードの説明

Amazon API GatewayもAWS FargateもAWSのサービスです。(そのまんまじゃん)

  • Amazon API Gateway
    API Gatewayは、Web APIを外部公開する際に必要な保守、モニタリング、セキュリティなどをまとめて行えるフルマネージドサービスです。
    aws.amazon.com

  • AWS Fargate
    AWS Fargateは、ECS(Amazon Elastic Container Service)やEKS(Amazon Elastic Kubernetes Service)で動作するコンテナ向けサーバーレスコンピューティングエンジンです。DockerコンテナがAWS上で動いている感じです。その中でもFargateはサーバーインスタンスの管理をしなくてよいので、いろいろ便利です。
    aws.amazon.com

構成のイメージ

AWS環境の構成としてはシンプルに作ります。

  • VPCは1つ。配下のサブネットもパブリックなものを一つ。
    ⇒プライベートにすると、作成するリソースが増えるので、ここはシンプルにパブリックにします。
  • ロードバランサーはNLB(Network Load Balancer)を使用します。
  • Fargateは1サービス、1タスク
  • API Gatewayには2つのREST APIを作成

いざやってみる

以下の感じで作ります。

  1. 動作確認用のOCIイメージ作成。Fargateでは、javaコマンドによるアプリケーションの実行ではなく、より高速なネイティブバイナリを実行するようにします。
  2. CDKでVPCの作成からAPI Gatewayの作成まで一気にやる。

事前準備

  • ビルド環境の作成
    • 今回はEC2(OSはAmazon Linux2)上でビルドします。ネイティブなOCIイメージを作成するのにそこそこCPUパワーとメモリがいるので、t3.mediumインスタンスにして、ストレージ32GBで一部をスワップ領域にしています。
    • OCIイメージを作成するのに必要なので、DockerGraalVMをインストールします。
    • ECR(Amazon Elastic Container Registry)にイメージ保存用のリポジトリを作成します。
    • AWS コマンドラインインターフェイスがインストールされていなければ、インストールします。また、「aws configure」を実行して、認証情報の設定も行っておきます。(ECRにプッシュする際に必要なため)
    • その他必要なアプリをインストールします。

OCIイメージの作成

今回アプリとしては2つのREST APIを作ります。

  • ランダムで野球の球団情報を返す。(パスは「/randomteam」引数なし)
  • 日本の全球団情報を返す。(パスは「/allteams」引数なし)

アプリはJavaで作成し、フレームワークはSpring Nativeを使用します。
Spring Nativeは、MavenやGradleでネイティブなOCIイメージを作成できます。Dockerfileも不要です。
DockerやECS(Elastic Container Service)、Fargateを起動する際は、Javaコマンドによる実行ではなくネイティブバイナリを実行するため、起動や実行がかなり高速です。

■Gradleの設定

ビルドツールとしてはGradleを使います。
下記の記載がSpring Native独特の記載になります。 詳しくはこちらに書いてあります。

  • build.gradleの抜粋
plugins {
    // ...
    id 'org.springframework.experimental.aot' version '0.9.1'
}
bootBuildImage {
    imageName = '[ECRのURI]'
    builder = 'paketobuildpacks/builder:tiny'
    environment = ['BP_NATIVE_IMAGE': 'true', 'BPE_TZ': 'Asia/Tokyo']
}
■コントローラの作成
  • 球団のDTOクラス
    球団の略称、名称、本拠地を下記のDTOクラスに入れるようにします。(lombokを使っています。)
@AllArgsConstructor
@Getter
public class Team {

    @JsonProperty("no")
    private int no;

    @JsonProperty("id")
    private String id;

    @JsonProperty("name")
    private String name;

    @JsonProperty("home")
    private String home;

    @Override
    public String toString() {
        return String.format("no: %2d, id: %s", no, id);
    }
}

これをこんな感じでリストに詰め込みます。

private static final List<Team> TEAMS = List.of(
    new Team(1, "F", "北海道日本ハムファイターズ", "札幌ドーム"),
    new Team(2, "E", "東北楽天ゴールデンイーグルス", "楽天生命パーク宮城"),
    new Team(3, "L", "埼玉西武ライオンズ", "メットライフドーム"),
    new Team(4, "G", "読売ジャイアンツ", "東京ドーム"),
    new Team(5, "S", "東京ヤクルトスワローズ", "明治神宮野球場"),
    new Team(6, "M", "千葉ロッテマリーンズ", "ZOZOマリンスタジアム"),
    new Team(7, "DB", "横浜DeNAベイスターズ", "横浜スタジアム"),
    new Team(8, "D", "中日ドラゴンズ", "バンテリンドーム ナゴヤ"),
    new Team(9, "B", "オリックス・バファローズ", "京セラドーム大阪"),
    new Team(10, "T", "阪神タイガース", "阪神甲子園球場"),
    new Team(11, "C", "広島東洋カープ", "MAZDA Zoom-Zoom スタジアム広島"),
    new Team(12, "H", "福岡ソフトバンクホークス", "福岡PayPayドーム"));
  • API(/randomteam)の設定
    ランダムに球団の情報を返します。
@GetMapping("/randomteam")
public Team getRandomTeam() {
    int n = new Random(System.currentTimeMillis()).nextInt(TEAMS.size());

    return TEAMS.get(n);
}
  • API(/allteams)の設定
    上記TEAMSに代入されている全球団の情報を返します。多少デバッグ用のロジックが入っていますが、気にしないでください。
@GetMapping("/allteams")
public List<Team> printAll() {
    LocalDateTime start = LocalDateTime.now();
    TEAMS.stream()
        .forEach(t -> System.out.println(LocalDateTime.now() + " " + t));
    LocalDateTime end = LocalDateTime.now();
    long executeTime = ChronoUnit.MICROS.between(start, end);
    System.out.println(LocalDateTime.now() + " Execute time: " + executeTime + "[microsecond]");

    return TEAMS;
}
■ビルド

ビルドは下記コマンドを実行します。私の環境だとdockerコマンドをrootユーザ以外でうまく実行できなかったので、sudoで実行しています。bootBuildImageタスクは、モジュールのコンパイル後にOCIイメージを作成します。

sudo ./gradlew bootBuildImage

完了するとこんなメッセージが表示されます。

Successfully built image '[ECRのURI]:latest'

BUILD SUCCESSFUL in 8m 8s

シンプルなプログラムなのに、作成に8分もかかりました・・・。
作成したOCIイメージを以下のコマンドでECRにプッシュします。

aws ecr get-login-password | docker login --username AWS --password-stdin https://[ECRのURI]
docker push [ECRのURI]:latest

VPC~API Gatewayの作成

ここからAWSのリソースを作成します。上の方で書いた通り、AWSリソースの作成はCDKで作成します。Gradleのプロジェクトとしては、上記とは別に作成します。
以下の単位でメソッドに分割します。

  • VPC作成
  • ロードバランサー作成
  • タスク定義作成
  • Fargate作成
  • API Gateway作成
■VPC作成

上記の通りVPC1つとパブリックサブネット1つを作成します。インターネットゲートウェイなどは自動で作成されます。

private IVpc createVpc() {
    return Vpc.Builder.create(this, "EBlogVpc")
        .cidr("10.0.0.0/16")
        .enableDnsHostnames(true)
        .enableDnsSupport(true)
        .maxAzs(1)
        .subnetConfiguration(
            Arrays.asList(
                new SubnetConfiguration.Builder() // パブリックサブネット
                    .name("Public")
                    .subnetType(SubnetType.PUBLIC)
                    .reserved(false)
                    .build()))
        .build();
}
■ロードバランサー作成

上記の通りNLBです。

private INetworkLoadBalancer createNlb(IVpc vpc) {
    String nlbName = "EBlogNlb";
    return NetworkLoadBalancer.Builder.create(this, nlbName)
        .loadBalancerName(nlbName)
        .crossZoneEnabled(true)
        .internetFacing(true)
        .vpc(vpc) // 上記で作成したVPC
        .build();
}
■タスク定義作成

タスク定義はFargateTaskDefinitionクラスを使用します。また、コンテナ定義でCloudWatchへログ出力する設定も行っています。

private FargateTaskDefinition createTaskDev() {
    String taskDefName = "EBlogTaskDef";
    FargateTaskDefinition taskDef = FargateTaskDefinition.Builder.create(this, taskDefName)
        .family(taskDefName)
        .memoryLimitMiB(2048)
        .cpu(1024)
        .build();

    // ECRからイメージ取得
    EcrImage ecrImage = ContainerImage.fromEcrRepository(
        Repository.fromRepositoryName(this, "ecr-repo",
            "eblog-202104"));

    // コンテナの追加
    ContainerDefinition containerDef = taskDef.addContainer("ct-eblog202104",
        new ContainerDefinitionOptions.Builder()
            .image(ecrImage)
            .cpu(1)
            .logging(LogDriver.awsLogs(new AwsLogDriverProps.Builder() // CloudWatchの設定
                .logGroup(LogGroup.Builder.create(this, "applicationLogs")
                    .removalPolicy(RemovalPolicy.DESTROY)
                    .retention(RetentionDays.ONE_WEEK)
                    .build())
                .streamPrefix("ecs")
                .build()))
            .build());

    // ポートマッピング
    containerDef.addPortMappings(new PortMapping.Builder()
        .containerPort(8080)
        .hostPort(8080)
        .build());

    return taskDef;

}
■Fargate作成

NLBを使用したFargateサービスの作成には、NetworkLoadBalancedFargateServiceクラスを使用します。

private NetworkLoadBalancedFargateService createFargate(IVpc vpc, FargateTaskDefinition taskDef,
    INetworkLoadBalancer nlb) {
    // Fargateクラスタ作成
    String clusterName = "EBlogFargateCluster";
    Cluster cluster = Cluster.Builder.create(this, clusterName)
        .clusterName(clusterName)
        .vpc(vpc) // 上記で作成したVPC
        .build();

    // Fargateサービス作成
    String serviceName = "EBlogFargateService";

    NetworkLoadBalancedFargateService service = NetworkLoadBalancedFargateService.Builder
        .create(this, serviceName)
        .serviceName(serviceName)
        .cluster(cluster)
        .desiredCount(1)
        .platformVersion(FargatePlatformVersion.LATEST)
        .taskDefinition(taskDef) // 上記で作成したタスク定義
        .assignPublicIp(true)
        .publicLoadBalancer(false)
        .loadBalancer(nlb) // 上記で作成したNLB
        .build();

    // セキュリティグループの設定
    // 8080ポートを許可する
    service.getService().getConnections().allowFrom(
        Peer.ipv4(cluster.getVpc().getVpcCidrBlock()), Port.tcp(8080));

    // リスナー、ターゲットグループの登録
    String listenerName = "EBlogNlbListener";
    nlb.addListener(listenerName, new BaseNetworkListenerProps.Builder()
        .defaultTargetGroups(Arrays.asList(NetworkTargetGroup.Builder.create(this, "EBlogNlbTarget")
            .targets(Arrays.asList(service.getService()))
            .port(8080)
            .protocol(Protocol.TCP)
            .targetType(TargetType.IP)
            .vpc(vpc) // 上記で作成したVPC
            .build()))
        .port(8080)
        .protocol(Protocol.TCP)
        .build());

    return service;
}
■API Gateway作成

API Gatewayは、V1(REST Api)とV2(HTTP Api)の2種類あります。
今回はREST Apiなので、V1の方を使用します。
API Gatewayの定義は、リソース(URLのパス)を作成し、メソッドを定義して上位リソースに追加していく感じです。階層構造が深かったり、APIが多いとリソースの上限(500)に達してしまうため、その場合はネストされたスタック(NestedStack)を使用してスタックを分割することで、リソースの上限に達することを回避できます。
また新規にAPIを作成する場合は、ステージ(prod)を作成してデプロイしてくれます。(既存のAPIへリソースを追加した場合は、自動でデプロイしてくれませんでした)

private void createApiGateway(INetworkLoadBalancer nlb, NetworkListener listener) {
    String apiName = "EBlogApi";
    IRestApi api = RestApi.Builder.create(this, apiName)
        .restApiName(apiName)
        .deploy(true)
        .build();

    // パス/randomteamの追加
    IResource randomteam = api.getRoot().addResource("randomteam");

    // GETメソッドの追加
    randomteam.addMethod("GET",
        Integration.Builder.create()
            .type(IntegrationType.HTTP)
            .integrationHttpMethod("GET")
            .uri("http://" + nlb.getLoadBalancerDnsName() + ":8080/randomteam")
            .options(new IntegrationOptions.Builder()
                .integrationResponses(List.of(
                    new IntegrationResponse.Builder()
                        .statusCode("200")
                        .build()))
                .build())
            .build(),
        new MethodOptions.Builder()
            .methodResponses(List.of(new MethodResponse.Builder()
                .responseModels(Map.of("application/json", Model.EMPTY_MODEL))
                .statusCode("200")
                .build()))
            .build());

    // パス/allteamsの追加
    IResource allteams = api.getRoot().addResource("allteams");

    // GETメソッドの追加
    allteams.addMethod("GET",
        Integration.Builder.create()
            .type(IntegrationType.HTTP)
            .integrationHttpMethod("GET")
            .uri("http://" + nlb.getLoadBalancerDnsName() + ":8080/allteams")
            .options(new IntegrationOptions.Builder()
                .integrationResponses(List.of(
                    new IntegrationResponse.Builder()
                        .statusCode("200")
                        .build()))
                .build())
            .build(),
        new MethodOptions.Builder()
            .methodResponses(List.of(new MethodResponse.Builder()
                .responseModels(Map.of("application/json", Model.EMPTY_MODEL))
                .statusCode("200")
                .build()))
            .build());
}
■AWSへデプロイ
  • ビルド・定義確認
    ビルドは下記コマンドで行います。
cdk synth

Cloudformationの定義が出力されます。

  • デプロイ
    AWSへのデプロイは下記コマンドで行います。
cdk deploy

AWSに反映するか聞かれるので、「y」を入力します。

■スタック

Cloudformationのスタックではリソースが作成されていく様子がイベントに出力されます。数分で作成が完了します。

■VPC
  • VPC
  • サブネット
■NLB

■タスク定義

■Fargate
  • クラスター
  • サービス
    よーく見るとターゲットグループが2つあり、どちらもコンテナポートが8080となっています。これはNLBのヘルスチェック用ターゲットグループで、80ポートの入力をコンテナの8080ポートに転送しようとしています。特にこんなこと書いてないのですが・・・。これはCDKのバグみたいで、80ポートに対するターゲットグループが自動的に作成されるみたいです。
  • タスク
  • 起動ログ(CloudWatchログイベント) 起動が超速いですね。シンプルなプログラムであればLambdaにも使えるかもしれないですね。
■API Gateway

APIが作成され、リソースやメソッドを追加し、ステージ(prod)にデプロイまでされています。

実行してみる

作成したAPIを実行してみます。実行はPostmanを使用します。
一瞬で実行できてます。起動だけではなく、実行も速いですね。

  • /randomteam

  • /allteams 全チームの情報が取得できています。(画面は一部のみ表示しています)

AWSリソースの削除

AWSリソースの削除は以下のコマンドで実行します。

cdk destroy
・
・
・
Are you sure you want to delete: EBlogStack (y/n)?

yを入力すると作成したAWSリソースを全削除します。

おわりに

CDKはAWSリソースをJavaオブジェクトで表しているので(Java以外でもできますが)、Javaプログラマの私としてはCloudformationのテンプレートを書くよりとっつきやすく、コマンド1つで一気に環境構築できるのでとても便利です。