図書管理アプリを支えたAWS環境を解説

はじめに

こんにちは。エンジニアのすぎやまです。

​​みなさん、この記事、読んでいただけましたか?

2024年の新入社員研修で、図書管理アプリをリリースしたイベントについて書いていますので、ぜひお読みください。

この記事ですこーし触れられていますが、新卒エンジニアをサポートした先輩の一人が僕です!

今日は図書管理アプリ用に、m-akira0924くんと一緒に構築した AWS CDKを利用したインフラ環境について説明します。

m-akira0924くんは、こんな記事も書いています。

Next.jsでのレンダリングを理解してSSRを効果的に活用する

AWSについては、弊社が運営するメディアサイト in-Pocket でも記事を載せています。AWSを優しい言葉でわかりやすく説明していますので、こちらもご覧ください。

知ってて損はないAWSのこと。

ここでは、エンジニア向けに技術的な視点から説明していきます。

作成した環境

作成した環境は以下の構成図のようになります。

フロントエンドはNext.js、バックエンドはLaravelでAPIを開発しています。
開発してもらったコードをマージした際にgithub actionsによりdocker imageをbuildしてECRにpushするようにしました。ECRにあるイメージが更新されるとAppRunnerが自動でデプロイされます。

AppRunnerに関してはこちらで別途記事を書きました。
AppRunnerでexpressのデプロイ

アイコンなどの画像はS3に、本やユーザーのデータなどはRDSに保存するようにしています。
またLambdaとSESを利用して本の貸し出し期限が過ぎている人にメール通知を送信するようにしました。

Route53にてフロントとバックのAppRunnerの異なるドメインを統一させています。こちらはコンソール上で行いました。

コード

ECR

リソースクラスを以下のように用意しておき、スタックで呼び出します。

export class Ecr extends Repository {
  constructor(
    scope: Construct,
    id: string,
    props: Props
  ) {
    super(scope, id);

    new Repository(this, props.repositoryName, {
      repositoryName: props.repositoryName,
      removalPolicy: RemovalPolicy.RETAIN,
    });
  }
}
export class ECRStack extends Stack {
  readonly frontendRepository: Repository;
  readonly backendRepository: Repository;

  constructor(scope: Construct, id: string, props?: Props) {
    super(scope, id, props);

    this.frontendRepository = new Ecr(this, 'front', {
      repositoryName: `${props?.appName}-front-repository`
    })

    this.backendRepository = new Ecr(this, 'front', {
      repositoryName: `${props?.appName}-backend-repository`
    })
  }
}

AppRunner

AppRunnerは以下のようなクラスにてAppRunnerの作成のほかECRリポジトリ、VPCコネクタ、ロールとの紐付けを行っています。S3やRDSとの接続に必要なロールはアクセスキーとシークレットアクセスキーをバックエンド側に環境変数として渡してしまっていたのでここで設定すべきでした。

AppRunnerリソース

export class AppRunner extends cdk.Resource {
  readonly instanceRole: Role;
  readonly service: apprunner.Service;
  readonly vpcConnector?: apprunner.VpcConnector;

  constructor(
    scope: Construct,
    id: string,
    {
      tagOrDigest = "latest",
      serviceName,
      repositoryName,
      vpc,
      cpu,
      memory,
      autoDeploymentsEnabled = true,
      imageConfiguration = {
        port: 80,
      },
      ...props
    }: Props
  ) {
    super(scope, id, props);

    const instanceRole = new Role(this, "apprunnerInstanceRole", {
assumedBy: new ServicePrincipal("tasks.apprunner.amazonaws.com"),
    });

    const repository = Repository.fromRepositoryName(
      this,
      repositoryName,
      repositoryName
    );

    const vpcConnector = vpc
      ? new apprunner.VpcConnector(this, "VpcConnector", {
          vpc,
        })
      : undefined;

    const service = new apprunner.Service(this, "apprunnerService", {
      serviceName,
      cpu: apprunner.Cpu.of(cpu ?? "1 vCPU"),
      memory: apprunner.Memory.of(memory ?? "2 GB"),
      source: apprunner.Source.fromEcr({
        repository,
        tagOrDigest,
        imageConfiguration,
      }),
      accessRole: new Role(this, "AppRunnerAccessRole", {
        assumedBy: new ServicePrincipal("build.apprunner.amazonaws.com"),
        managedPolicies: [
          ManagedPolicy.fromAwsManagedPolicyName(
            "service-role/AWSAppRunnerServicePolicyForECRAccess"
          ),
        ],
      }),
      autoDeploymentsEnabled,
      vpcConnector,
      instanceRole,
    });

    this.instanceRole = instanceRole;
    this.service = service;
    this.vpcConnector = vpcConnector;
  }
}

  application stackでAppRunnerのクラスを呼び出し、propsで必要な情報を渡します。AppRunnerの他にVPCとRDSのリソースを呼び出し作成もしています。
またstandalone モードの場合はフロントエンドの環境変数にHOSTNAME=0.0.0.0の追加が必要になります。

またバックエンドのAppRunnerはRDSと接続するためVPCコネクタを使用します。AppRunnerリソースで作成したコネクタをallowDefaultPortFromでデフォルトポートのアクセスを許可します。

application stack

import { Stack, StackProps } from "aws-cdk-lib";
import { Secret as AppRunnerSecret } from "@aws-cdk/aws-apprunner-alpha";
import { StringParameter } from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";
import { AppRunner } from "./resources/apprunner";
import { IpAddresses, SubnetType, Vpc } from "aws-cdk-lib/aws-ec2";
import { Database } from "./resources/database";

type Props = {
  appName: string;
  envName: string;
  frontendRepositoryName: string;
  backendRepositoryName: string;
} & StackProps;

const frontEnvironmentSecrets: string[] = ["HOSTNAME"];
const backEnvironmentSecrets: string[] = [
  "AWS_ACCESS_KEY_ID",
  "AWS_SECRET_ACCESS_KEY",
  "DB_DATABASE",
  "DB_USERNAME",
  "DB_PASSWORD",
];

export class AppRunnerStack extends Stack {
  constructor(
    scope: Construct,
    id: string,
    {
      appName,
      envName,
      frontendRepositoryName,
      backendRepositoryName,
      ...props
    }: Props
  ) {
    super(scope, id, props);

    const vpc = new Vpc(this, "libVpc", {
      vpcName: "libapp-vpc",
      ipAddresses: IpAddresses.cidr("10.0.0.0/24"),
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          subnetType: SubnetType.PUBLIC,
          name: "Public",
          cidrMask: 28,
        },
        {
          subnetType: SubnetType.PRIVATE_WITH_EGRESS,
          name: "Private",
          cidrMask: 28,
        },
      ],
    });

    const database = new Database(this, "Database", {
      vpc,
    });

    const front = new AppRunner(this, "frontAppRunner", {
      serviceName: `${appName}-front`,
      repositoryName: frontendRepositoryName,
      vpc,
      cpu: "0.25 vCPU",
      memory: "0.5 GB",
      imageConfiguration: {
        port: 3000,
        environment: {},
        environmentSecrets: frontEnvironmentSecrets.reduce(
          (acc, cur) => ({
            ...acc,
            [cur]: AppRunnerSecret.fromSsmParameter(
              StringParameter.fromStringParameterName(
                this,
                `/${envName}/front/${cur}`,
                `/${envName}/front/${cur}`
              )
            ),
          }),
          {}
        ),
      },
    });

    const ApiContainer = new AppRunner(this, "BackendAppRunner", {
      serviceName: `${appName}-backend`,
      repositoryName: backendRepositoryName,
      vpc,
      cpu: "0.25 vCPU",
      memory: "0.5 GB",
      imageConfiguration: {
        port: 80,
        environmentVariables: {
          DB_CONNECTION: "pgsql",
          DB_HOST: database.cluster.clusterEndpoint.hostname,
        },
        environmentSecrets: backEnvironmentSecrets.reduce(
          (acc, cur) => ({
            ...acc,
            [cur]: AppRunnerSecret.fromSsmParameter(
              StringParameter.fromStringParameterName(
                this,
                `/${envName}/back/${cur}`,
                `/${envName}/back/${cur}`
              )
            ),
          }),
          {}
        ),
      },
    });

    if (ApiContainer.vpcConnector?.connections) {
      database.connections.allowDefaultPortFrom(
        ApiContainer.vpcConnector.connections
      );
    }
  }
}

環境変数はパラメータストアに用意してあり、environment stackを作成してそちらで定義しておきました。

export class EnvironmentStack extends Stack {
  constructor(scope: App, id: string, props?: StackProps) {
    super(scope, id, props);
    
    new ssm.StringParameter(this, "parameter1", {
      parameterName: "/prod/front/HOSTNAME",
      stringValue: `${process.env.HOSTNAME}`,
    });
    new ssm.StringParameter(this, "parameter2", {
      parameterName: "/prod/back/AWS_ACCESS_KEY_ID",
      stringValue: `${process.env.AWS_ACCESS_KEY_ID}`,
    });

S3

パブリックアクセスを拒否し、IAMのARNにreadwrite権限を渡したS3バケットを作成しました。

export class S3 extends Resource {
  constructor(
    scope: Construct,
    id: string,
    { appName, ...props }: Props
  ) {
    super(scope, id);

    const bucket = new s3.Bucket(this, `${appName}-Bucket`, {
      publicReadAccess: true,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false
      }),
      // S3のデフォルトはRETAIN
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    const iamArn = process.env.IAM_ARN

    if (!iamArn) return
    const user = iam.User.fromUserArn(this, 'User', iamArn)
    bucket.grantReadWrite(user);
  }
}

RDS

AuroraのPostgresでDBクラスターを作成しました。合わせてEC2でBastionホストを作成しi3のipアドレス経由でRDSにアクセスできるようにしています。

このリソースは先ほど記述したapplication stackで呼び出します。

RDSリソース

export class Database extends Resource {
  readonly connections: Connections;
  readonly cluster: DatabaseCluster;
  constructor(
    scope: Construct,
    id: string,
    {
      defaultDatabaseName,
      vpc,
      clusterIdentifier,
      instanceIdentifierBase,
      ...props
    }: Props
  ) {
    super(scope, id, props);

    const cluster = new DatabaseCluster(this, "database", {
      vpc,
      clusterIdentifier: "test-cluster",
      defaultDatabaseName: "testauroradb01",
      engine: DatabaseClusterEngine.auroraPostgres({
        version: AuroraPostgresEngineVersion.VER_15_2,
      }),
      writer: ClusterInstance.serverlessV2("Writer", {
        instanceIdentifier: "test-writer-instance01",
      }),
      serverlessV2MaxCapacity: 1,
      serverlessV2MinCapacity: 0.5,
    });

    const bastion = new BastionHostLinux(this, "Bastion", {
      vpc,
      subnetSelection: { subnetType: SubnetType.PUBLIC },
      instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO),
    });

    ["i3のip/32"].forEach(
      (cidr) => {
        bastion.instance.connections.allowFrom(Peer.ipv4(cidr), Port.tcp(22));
        bastion.instance.connections.allowFrom(Peer.ipv4(cidr), Port.tcp(9999));
      }
    );

    cluster.connections.allowDefaultPortFrom(bastion);

    const databaseService = new Asset(this, "DatabaseService", {
      path: path.join(__dirname, "/../../systemd/database.service"),
    });
    const databaseSocket = new Asset(this, "DatabaseSocket", {
      path: path.join(__dirname, "/../../systemd/database.socket"),
    });
    databaseService.grantRead(bastion);
    databaseSocket.grantRead(bastion);

    bastion.instance.userData.addS3DownloadCommand({
      bucket: databaseService.bucket,
      bucketKey: databaseService.s3ObjectKey,
      localFile: "/etc/systemd/system/database.service",
    });
    bastion.instance.userData.addS3DownloadCommand({
      bucket: databaseSocket.bucket,
      bucketKey: databaseSocket.s3ObjectKey,
      localFile: "/etc/systemd/system/database.socket",
    });
    bastion.instance.userData.addCommands(
      `sed -i -e 's/@db-host/${cluster.clusterEndpoint.hostname}/g' /etc/systemd/system/database.service`,
      `sed -i -e 's/@db-port/${cluster.clusterEndpoint.port}/g' /etc/systemd/system/database.service`,
      "systemctl enable --now database.socket"
    );

    this.cluster = cluster;
    this.connections = cluster.connections;
  }
}

Lambda, SES

LambdaとSESでメール通知を送る方法については、別記事にて紹介しています。

▼こちらもあわせてご覧ください。

AWS初心者でもできる!LambdaとSESで自動メール通知をマスターしよう

デプロイ

bin/cdk.tsでstackを呼び出し、cdk deploy --all で全てのスタックをデプロイさせます。

const repo = new ECRStack(
    app,
    "EcrStack",
    {
        appName,
        env,
      }
  );
    
const infraStack = new InfraStack(app, 'ApplicationInfra', {
  appName,
  env,
  envName,
})
const envStack = new EnvironmentStack(app, "EnvironmentStack", { bucketName: infraStack.bucketName });

new AppRunnerStack(app, "ApplicationStack", {
  appName,
  env,
  envName,
  frontendRepositoryName: repo.frontendRepository.repositoryName,
  backendRepositoryName: repo.backendRepository.repositoryName
}).addDependency(envStack)

感想

開発が始まってから権限まわりと環境変数が難しかったです…

通常業務をこなしながら、新卒研修の講師もやり、実装のヘルプにも入ったので新卒研修期間の後半はタイトなスケジュールでしたが、困難な状況であればあるほど成長できますね。(実感…)

さいごに

アイスリーデザインは、2023年にAWSセレクトティアサービスパートナー認定されました。

僕自身、AWSの資格はいくつか取得しているので、今後も実務で活かしていきたいです。

アイスリーデザインでは、AWSを活用したアプリケーション開発やインフラ構築・運用において、お客様のニーズに合わせた高品質なサービスを提供し、ビジネスの成長を支援していきます。

システムやプロダクトの開発をご検討中の方はぜひこちらからご相談ください。

開発パートナーをお探しですか?お気軽にご相談ください!