AWS CDK と AWS App Runner でお手軽に個人サービスのバックエンド API を運用しているお話

いや〜。年の瀬ですね!今年って11月ってありました?っていうくらい一気に年末ですね。

ということで、 これは CDK Advent Calendar 2021 の 20 日目の記事になります!

re:Invent 前にですが、AWS App Runner がアプリケーションの構築とデプロイのための AWS CDK のサポートを開始 ということで、 CDK から App Runner が簡単に扱えるようになりました。ちょうど個人的にちょっとしたツールを作っており、 API サーバ側をどのようにしようかな?と考えていたところでした。

ECS 使うのもいいんだけど、個人サービスなのに結構費用かかっちゃうし、ちょっとオーバースペックだよな〜。EKS はもっとだし〜。とか、 Lambda で構成するのは良いけど、ちょっとクセが強い(そうでもない)から、ふつーにコンテナで作ったものデプロイするのがめんどくさいな〜。とか、 GCP の Cloud Run がちょうどいいんだけど、 DynamoDB 使いたいから GCP から DynamoDB 使うのもめんどくさいな〜。とかとか考えていた矢先だったので、これは App Runner を使うべきでしょ!っていうことで、 CDK + App Runner で作ることにしてみました。

そんなサービスとは、 Reviewppe  という Chrome 拡張機能です。
(サービスの内容は、ページの最後に置いておきます)

そんなわけで、このツールのバックエンド API を CDK + App Runner で作っております。まだ自分のまわりの人以外ほぼ使っていないツールだとしても!

どんな感じで作っているのか?

全体としてはざっくり、App Runner と ECR そして DynamoDB をベースに作成しています。もちろん、 S3 や CloudWatch などの基本的な部分は利用していますが、 VPC も利用しないとてもシンプルなアーキテクチャです。

主要な部分だけでいうと、

// Container Repository
const ecr = new ECRStack(app, `${pjPrefix}-ECR`, {
  repositoryName: "reviewppe-api",
  alarmTopic: monitorAlarm.alarmTopic,
  env: getProcEnv(),
});

// DynamoDB
const dynamo = new DynamoDBStack(app, `${pjPrefix}-DynamoDB`, {
  env: getProcEnv(),
});

// App Runner
const apprunner = new AppRunnerStack(app, `${pjPrefix}-AppRunner`, {
  ecrRepository: ecr.repository,
  env: getProcEnv(),
});

このような形でスタックを分けています。 alarmTopic は前もって準備しておきます。 getProcEnv() はアカウントとリージョンを表すものを返す仕組みです。

ECR の部分は、

import * as cdk from "@aws-cdk/core";
import * as ecr from "@aws-cdk/aws-ecr";
import * as eventtarget from "@aws-cdk/aws-events-targets";
import * as sns from "@aws-cdk/aws-sns";

export interface ECRStackProps extends cdk.StackProps {
  repositoryName: string;
  alarmTopic: sns.Topic;
}

export class ECRStack extends cdk.Stack {
  public readonly repository: ecr.Repository;

  constructor(scope: cdk.Construct, id: string, props: ECRStackProps) {
    super(scope, id, props);

    // Create a repository
    this.repository = new ecr.Repository(this, props.repositoryName, {
      imageScanOnPush: true,
    });
    const target = new eventtarget.SnsTopic(props.alarmTopic);

    this.repository.onImageScanCompleted("ImageScanComplete").addTarget(target);
  }
}

このような感じで単純にリポジトリを作成しているだけです。リポジトリ名は動的に作成すると後に面倒なので、固定で作成しています。

DynamoDB を準備する部分は、

import * as cdk from "@aws-cdk/core";
import * as dynamodb from "@aws-cdk/aws-dynamodb";

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

    const reviewstable = new dynamodb.Table(this, "Reviews", {
      tableName: "Reviews",
      partitionKey: {
        name: "Reviewer",
        type: dynamodb.AttributeType.STRING,
      },
      sortKey: {
        name: "Datetime",
        type: dynamodb.AttributeType.NUMBER,
      },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      timeToLiveAttribute: "ttl",
    });

    reviewstable.addGlobalSecondaryIndex({
      indexName: "gsi-reviewee",
      partitionKey: {
        name: "Reviewee",
        type: dynamodb.AttributeType.STRING,
      },
      sortKey: {
        name: "Datetime",
        type: dynamodb.AttributeType.NUMBER,
      },
    });

    const その他テーブル = new dynamodb.Table(this, "etc...", {
      同じ感じ
    });
  }
}

こんな感じにしています。なるべく個人のインフラ費用をかけないために、全テーブルに TTL を設定しています。 CDK (IaC) の時点でテーブル構造まで定義するべきかどうかは悩んだポイントです。インフラorアーキテクチャ側の責務として考えるのか、アプリケーション側の責務として考えるのかで CDK で実装するのではなく、アプリケーションの初期化時や、デプロイ時に DB Migration で項目定義するなども考えましたが、やはり個人サービスですし、さらに DynamoDB なのでインフラ・アプリの境界を考えてしまうとうまくいかない (アクセスパターンにより項目や設定も決めるべき) ので、今回は境界を考えずに CDK の中で項目まで定義することにしました。

このあたりまでは、まあふつー、っていう感じかと思います。

今回新たに CDK に対応した App Runner の部分ですが、

import * as cdk from "@aws-cdk/core";
import * as ecr from "@aws-cdk/aws-ecr";
import * as iam from "@aws-cdk/aws-iam";
import * as apprunner from "@aws-cdk/aws-apprunner";

export interface AppRunnerStackProps extends cdk.StackProps {
  ecrRepository: ecr.Repository;
}

export class AppRunnerStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props: AppRunnerStackProps) {
    super(scope, id, props);

    // Roles
    const instanceRole = new iam.Role(this, "AppRunnerInstanceRole", {
      assumedBy: new iam.ServicePrincipal("tasks.apprunner.amazonaws.com"),
      // あといい感じに実行時のポリシーを設定
    });

    const accessRole = new iam.Role(this, "AppRunnerAccessRole", {
      assumedBy: new iam.ServicePrincipal("build.apprunner.amazonaws.com"),
      // ここもいい感じにビルド時のポリシーを設定
    });

    new apprunner.Service(this, "Service", {
      source: apprunner.Source.fromEcr({
        imageConfiguration: { port: 8080 },
        repository: props.ecrRepository,
        tag: "latest",
      }),
      instanceRole: instanceRole,
      accessRole: accessRole,
    });
  }
}

このようにシンプルに作っています。直前で作成した ECR と結びつけを行って、適切な権限を持ったロールを結びつけているだけです。 Auto Scaling や Health Check などなどは CDK の L2 Construct にお任せです!いまのところ変更は必要なさそうです。
(当たり前ですが、ちゃんと動作するコンテナを ECR に置くまでは App Runner は動作しません)

今回、 API は Go で実装しているため Managed Runtime は使いません。 ローカルでビルドしたコンテナを ECR にプッシュし、手動でデプロイを行います。個人で作っているサービスのため、 CI/CD (特にCD) はオーバースペックなので用意していません。特に App Runner を使うと git commit / push と同じ感じでコンテナイメージのビルドとプッシュを行うだけなのでお手軽です。(逆に複雑なことは出来ないとも言います)。さらに、App Runner を使うと、コンテナベースのアプリケーションでも VPC も ELB も意識することがないのでとても快適です!個人でのサービス開発にはとても向いていると思います。

ちなみに Dockerfile はこのようなふつーの感じのマルチステージビルドです

FROM golang:buster as gobuilder
WORKDIR /app
COPY . /app
RUN go mod tidy
RUN go build -ldflags="-w -s" -o /go/bin/app
RUN go generate
RUN go get github.com/cosmtrek/air
CMD ["air"]

FROM gcr.io/distroless/base
COPY --from=gobuilder /go/bin/app /
COPY .env /
COPY static /static
CMD ["/app"]

このようなシンプルな CDK の構成 + いつもと同じ作り方のコンテナアプリケーションで、簡単にプロダクションレディーの Web アプリケーション(API)が作成できてしまいました!もちろん、複数コンテナ(サイドカーとか)を使うような場合はこのような感じには行きませんが、ちょっとしたサービスを作るにはピッタリの組み合わせだなと思いました!
使うサービスやフレームワークまた技術の選定は、事業の規模やステージに応じて選定するべきですよね。そして、ステージが進んでいくに連れてアーキテクチャも変更を繰り返していくべきかと思います。今回の CDK + App Runner は、簡単さ、シンプルさ、スピード感、ふつーさ、かつ本番に耐えられる非機能要件もある程度備えており、事業の序盤にはとても適している組み合わせだなと思いました!

こんな感じでステージング環境と本番環境を同じ感じにサクッと CDK で作れました。とてもシンプルで便利だったのでおすすめです!

今回作った個人的サービス Reviewppe とは?

公開されている Web ページに対してコメントやレビューをし、作成者(の Twitter ユーザ)に対してその内容を送ることができるサービスです!

動作イメージは下の動画の感じになります


Web ページを製作中に、仲間からレビューを頼まれたとき、みなさんはどのようにレビューをしていますか?
・Google Document に画面キャプチャーを貼って、コメントを書く?
・Slack などで、直接思ったことをつらつらと送る?
・GitHub などで、直接プルリクエストを送る?
お互いに、GitHub などをバリバリ使える Web エンジニアであれば、それを直接使うのが一番効率がよいかもしれません。しかし、Web サービスの作成はエンジニアだけでは完結できないことが多々あるかと思います。また、いま Web 製作の勉強中の人も一緒に作業していることもあるかと思います。そんなときに、 Web ページを直接レビューして、その結果を簡単に確認できると便利だと思い、この拡張機能は生まれました。
もしそのような用途がある方はぜひ使ってみて下さい!

こちらからは以上です。