ECSとRDSをやめて、AWSコストを9割削減しました

2025/01/22に公開
8

はじめに

こんにちは。BEENOSのがれっとです。

AWS上にアプリケーションを構築する際、一般的なのはECS + RDSという組み合わせです。私も社内システムをそのような形で構築しました。

しかし、使わないときにもインスタンスが動き続けてしまうため、大量のトラフィックを捌かないアプリケーションにおいてはコストが見合わないものとなってしまいます。

そこで、ECS + RDSという構成からLambda + EFSの構成に社内システムを移行して、コスト削減した話を紹介します。

前提

以下の構成のアプリケーションを移行しました。

AWS 構成図

移行前

移行前のAWS構成図

移行後

移行後のAWS構成図

リレーショナルデータベースを用いることが必須のアプリケーションを構築する際、AWSでは通常RDSやAuroraを用いることが一般的です。
しかしこれは、性能にシビアでないアプリケーションを構築する際に、オーバースペックになることが多々あります。
EFS上にSQLiteを展開することにより、費用を抑えつつ、リレーショナルデータベースを諦めない構成にできます。

また、VPC LambdaはそのままだとVPC外にアクセスできません。そのため、LambdaにEIPをアタッチして、VPC外へのアクセスを可能にしました。
(残念ながら、EIPの保持に月3.6ドルほどかかります)

PostgreSQL から SQLite への移行

設定の変更

Prismaの設定を書き換えます。基本はSQLite から PostgreSQL への移行の逆を行います。

  1. db/schema.prisma の書き換え
datasource db {
- provider = "postgres"
+ provider = "sqlite"
  url      = env("DATABASE_URL")
}

generator client {
  provider      = "prisma-client-js"
- binaryTargets = ["native", "debian-openssl-1.1.x"]
}
  1. 環境変数の書き換え
- DATABASE_URL=postgres://$USER:$PASSWORD@$CONTAINER_NAME:$PORT/$DB_NAME
+ DATABASE_URL=file:./db.sqlite  # ローカル開発環境の場合
  1. db/migrations 以下のマイグレーションファイルを全削除
  2. blitz prisma migrate dev コマンドの実行

アプリケーションの変更

テーブルの中に、PostgreSQLにしかない配列型を使用している箇所が存在したため、スキーマとアプリケーションを変更しました。サンプルコードを例示します。

schema.prismaの変更

移行前はParentテーブルにchildrenを文字列の配列として保持していました。
移行後はChildテーブルとChildrenOrderテーブルを追加し、データの正規化と順序のカラム化を行いました。

model Parent {
  id              Int       @id @default(autoincrement())
  name            String    @unique
- children        String[]
+ childrenOrder   ChildrenOrder[]
}

+ model ChildrenOrder {
+  parent     Parent  @relation(fields: [parentId], references: [id])
+  parentId   Int
+  child      Child   @relation(fields: [childId], references: [id])
+  childId    Int
+  sort_order Int
+  @@unique([parentId, sort_order])
+ }

+ model Child {
+  id             Int     @id @default(autoincrement())
+  name           String  @unique
+  childrenOrder  ChildrenOrder[]
+ }

クエリの変更

上記箇所を取得・挿入しているクエリ部分も変更する必要があります。
配列型を利用していたのはマスタデータでしたので、Select文とSeederの変更が必要でした。

Select 文
export default resolver.pipe(
  resolver.authorize(),
  resolver.zod(getChildren),
  async ({ id }): Promise<string[]> => {
    if (!id) {
      return [];
    }
-   const childrenOrder = await db.parent.findUnique({
+   const childrenOrder = await db.childrenOrder.findMany({
      where: {
-       id: id,
+       parentId: id,
      },
      select: {
-       children: true,
+       child: {
+         select: {
+           name: true,
+         },
+       },
      },
+     orderBy: {
+       sort_order: "asc",
+     },
    });
    if (!ChildrenOrders) return [];
-   return childrenOrder.children;
+   return childrenOrder.map(
+     (childrenOrder) => childrenOrder.child.name
+   );
  }
);

Seeder
+ await db.child.createMany({
+  data: [
+    { name: "Child1" },
+    { name: "Child2" },
+    { name: "Child3" },
+  ],
+ })


await db.parent.create({
    data: {
      name: "Parent Data 1",
-     children: [
-         "Child1",
-         "Child2",
-         "Child3",
-     ],
+     childrenOrder: {
+       create: [
+         {
+           child: {
+              connect: { name: "Child1" },
+           },
+           sort_order: 1,
+         },
+         {
+           child: {
+             connect: { name: "Child2" },
+           },
+           sort_order: 2,
+         },
+         {
+           child: {
+             connect: { name: "Child3" },
+           },
+           sort_order: 3,
+         },
        ],
      },
    },
  });

データ移行

アプリケーションの特性上、データ自体はSeederで投入されるマスターデータがほとんどでした。(12テーブル中9テーブル)

Seederの修正も上記の配列対応以外に行うことはなく、blitz prisma db seedを実行することでデータの移行が完了しました。

残りは手動でクエリを流し込み対応しました。

ECS から Lambda への移行

Dockerfile の書き換え

ECSとLambdaでは動かす環境が異なります。ですが、aws-lambda-adapterを用いることにより、Dockerfileの書き換えだけで対応が完了します。素晴らしいですね。

FROM node:20-slim as base
WORKDIR /opt/app
COPY . .

RUN apt-get update -y \
  && apt-get install -y openssl \
  && npm install \
  && npm install -g blitz

RUN blitz prisma generate \
  && blitz build

FROM node:20-slim as prod
+ COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.0 /lambda-adapter /opt/extensions/lambda-adapter

ENV PORT=3000
RUN apt-get update \
  && apt-get install -y openssl \
  && npm install -g blitz

COPY public .next/standalone
COPY --from=base /opt/app/package.json ./
COPY --from=base /opt/app/node_modules ./node_modules
COPY --from=base /opt/app/public ./public
COPY --from=base /opt/app/.next ./.next

CMD ["blitz", "start"]

デプロイ

以前はマネジメントコンソール(ブラウザ)より手動で作成していました。これを期に、SAMによるIaC化を行いました。

すべてを載せると膨大になりますので、重要な部分を抜粋して記します。

Lambda の部分

特筆すべきは、EFSのマウントについてです。
FileSystemConfigs でEFSとそれのマウントパスを指定します。また、EFSのマウントパスに応じて DATABASE_URL にSQLiteファイルの場所を指定する必要があります。

MyAppLambda:
  Type: AWS::Serverless::Function
  Properties:
    PackageType: Image
    FunctionName: !Sub my-app-lambda-${Stage}
    Environment:
      Variables:
        DATABASE_URL: "file:/mnt/efs/db.sqlite" # EFSのマウント箇所に合わせる
    Events: # Lambdaでアプリを動かすときのおまじない
      RootEvent: # `/` に対するすべてのメソッドを許可
        Type: Api
        Properties:
          RestApiId: !Ref MyAppApiGateway
          Path: "/"
          Method: ANY
      ProxyEvent: # `/` 以外に対するすべてのメソッドを許可
        Type: Api
        Properties:
          RestApiId: !Ref MyAppApiGateway
          Path: "/{proxy+}"
          Method: ANY
    Role: !GetAtt MyAppLambdaExecutionRole.Arn # 後述のExecutionRoleを指定
    VpcConfig:
      SecurityGroupIds:
        - !Ref MyAppLambdaSG
      SubnetIds:
        - !Ref MyAppPublicSubnet # Public subnetを指定
    FileSystemConfigs:
      - Arn: !GetAtt MyAppEfsAccessPoint.Arn
        LocalMountPath: /mnt/efs # EFSをマウントするパス
  Metadata: # Dockerファイルを指定
    Dockerfile: Dockerfile
    DockerContext: .
    DockerTag: latest

S3にアクセスするため、Public subnetに配置する必要があります。ご注意ください。

Execution Role の部分

ExecutionRoleでは、ENI、EFSおよびS3に対する権限を付与します。

MyAppLambdaExecutionRole:
  Type: "AWS::IAM::Role"
  Properties:
    Description: "my-app's Lambda execution role"
    AssumeRolePolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: "Allow"
          Action: "sts:AssumeRole"
          Principal:
            Service: lambda.amazonaws.com
MyAppLambdaExecutionPolicy:
  Type: AWS::IAM::Policy
  Properties:
    PolicyName: "my-app-vpc-lambda-execution-policy"
    PolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: "Allow"
          Action:
            - "ec2:CreateNetworkInterface" # ENIに対する権限
            - "ec2:DescribeNetworkInterfaces"
            - "ec2:DeleteNetworkInterface"
            - "ec2:DetachNetworkInterface"
            - "elasticfilesystem:ClientMount" # EFSに対する権限
            - "elasticfilesystem:ClientWrite"
            - "elasticfilesystem:DescribeMountTargets"
          Resource: "*"
        - Effect: "Allow"
          Action:
            - "s3:GetObject" # S3に対する権限
            - "s3:ListBucket"
          Resource:
            - !Sub "arn:aws:s3:::${BucketName}/*"
            - !Sub "arn:aws:s3:::${BucketName}"
    Roles:
      - !Ref MyAppLambdaExecutionRole

ID の出力

EIPをアタッチするため、LambdaのあるSubnetおよびSecurity groupのIDを出力します。

Resources:
  MyAppPublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref MyAppVPC
      CidrBlock: "10.0.0.0/25"
      Tags:
        - Key: Name
          Value: !Sub my-app-lambda-public-${Stage}
  MyAppLambdaSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Allow all traffic"
      VpcId: !Ref MyAppVPC
      SecurityGroupIngress:
        - CidrIp: "0.0.0.0/0"
          IpProtocol: "-1"
  MyAppElasticIp:
    Type: AWS::EC2::EIP
Outputs:
  SubnetId:
    Description: "MyApp Lambda's Public subnet Id"
    Value: !GetAtt MyAppPublicSubnet.SubnetId
  SecurityGroupId:
    Description: "MyApp Lambda's Security group Id"
    Value: !GetAtt MyAppLambdaSG.GroupId
  ElasticIPId:
    Description: "MyApp Lambda's EIP Allocation Id"
    Value: !GetAtt MyAppElasticIp.AllocationId

EIP のアタッチ

ENIにEIPをアタッチするためのシェルスクリプトを書きます。


SecurityGroupId=`aws cloudformation describe-stacks --stack-name $STACK_NAME \
  --query "Stacks[0].Outputs[?OutputKey=='SecurityGroupId'].OutputValue" \
  --output text`

SubnetId=`aws cloudformation describe-stacks --stack-name $STACK_NAME \
  --query "Stacks[0].Outputs[?OutputKey=='SubnetId'].OutputValue" \
  --output text`

nicId=`aws ec2 describe-network-interfaces \
  --filters "Name=subnet-id,Values="$SubnetId \
            "Name=interface-type,Values=lambda" \
            "Name=group-id,Values="$SecurityGroupId \
  --query 'NetworkInterfaces[].NetworkInterfaceId' \
  --output text`

allocationId=`aws cloudformation describe-stacks --stack-name $STACK_NAME \
  --query "Stacks[0].Outputs[?OutputKey=='ElasticIPId'].OutputValue" \
  --output text`

if ! aws ec2 associate-address --allocation-id $allocationId --network-interface-id ${nicId}; then
  echo "Failed to associate Elastic IP address with the network interface."
  exit 1
fi

コスト比較

移行前は、ELB 18ドル + RDS 21ドル + ECS 11ドル = 50ドル/月かかっていました。

移行後は、EIP 3.6ドル + EFS 0.4ドル = 4ドル/月まで圧縮できました。

移行によって、AWSのコストを9割近く削減できます 🎉🎉🎉

他クラウドサービスとの比較

筆者がCloudflareを大好きなので、主にCloudflareとの比較をします。

動作するアプリケーション

Cloudflare Workersでは主にJavaScriptで作られたアプリケーションが動作します。LambdaではDockerコンテナが動作します。また、Cloudflare Workersではファイルサイズ上限が3MBで、Lambdaは10GB(コンテナを利用する場合)です。(2025年1月現在)

そのため、Lambdaのほうが言語・ライブラリの選択肢が多くなります。とくに、PrismaとCloudflare Workersの相性は悪いことが知られています。Prismaを用いているアプリケーションをデプロイする場合はLambdaのほうが適しています。

コスト

料金の観点だと、Cloudflare Workers + D1であればほとんど無料の範囲で収まると考えられます。

また構築難度も、EFSを自前で用意するよりD1というフルマネージドサービスに乗っかってしまうほうが易しいでしょう。

コストの観点では、Cloudflare Workers + D1に軍配が上がります。

AWS他サービスとの連携

アプリケーションよりAWSで管理している別のリソースを用いる場合、他クラウドサービスを用いるよりAWSを用いたほうが便利です。

例えば、ドメイン管理が挙げられます。
弊社ではドメインをRoute 53にて管理することが多く、このアプリケーションもRoute 53で管理しているドメインのサブドメインを作成して運用しています。

今回はPrismaを使用していたり、ドメインをAWSで管理していたりする都合上AWS上に構築しました。次に同じようなシステムを構築する際はORMにDrizzleを用いて、デプロイ先にCloudflare Workersを使ってみたいです。

まとめ

ECSとRDSをやめてLambdaとEFSの構成にすることにより、大幅なコストカットを実現できました。

みなさんも個人開発などで試してみてはいかがでしょうか。

Wanted!

BEENOSグループでは一緒に働いて頂けるエンジニアを強く求めております!
少し気になった方は、社内の様子や大事にしていることなどをThe BEENOSにて発信しておりますので、是非ご覧ください。

とても気になった方はこちらでも求人を公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな…?」と思った方はオープンポジションとしてご応募頂けると大変嬉しく思います 🙌
世界で戦えるサービスを創っていきたい方、是非是非ご連絡ください!よろしくお願いいたします!!

世界で戦えるサービスを創っていく

BEENOS Tech Blog

Discussion

korokorotechkorokorotech

ユーザー何名くらいの規模のサービスでしょうか。大規模アプリでSQLiteという選択肢は現実的なのでしょうか?

がれっとがれっと

korokorotechさん、コメントありがとうございます。

こちらの社内システムはMAU10人程度のごく小規模のもので、同時に複数人がアクセスすることが基本的にはないという前提で構築しております。(もし集中して不具合が出たとしても、少し時間をおいて接続することが許容されるほどシステム要件的にはゆるいものです。)

性能調査を厳密に行っているわけではありませんが、同時に書き込みが発生する環境など、一般的なSQLiteの弱みが出るような環境では性能的に厳しいと考えています。

たいきたいき

Cloudflare Workers無料版のファイルサイズ上限ですが2024年11月27日頃に1MBから3MBに変更されました。
https://developers.cloudflare.com/workers/platform/limits/

がれっとがれっと

たいきさん、ご指摘ありがとうございます。記事を修正させていただきました。🙇

markdown
- Cloudflare Workersでは主にJavaScriptで作られたアプリケーションが動作します。LambdaではDockerコンテナが動作します。また、Cloudflare Workersではファイルサイズ上限が1MiBで、Lambdaは250MBです。
+ Cloudflare Workersでは主にJavaScriptで作られたアプリケーションが動作します。LambdaではDockerコンテナが動作します。また、Cloudflare Workersではファイルサイズ上限が3MBで、Lambdaは250MBです。(2025年1月現在)
SarisiaSarisia

こんにちは、大変実用的な記事ありがとうございます!

Lambda のアプリケーションサイズ上限ですが、 zip なら250MBなのですが、 Docker イメージなら10GBです!

https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/gettingstarted-limits.html#function-configuration-deployment-and-execution

デプロイパッケージ (.zip ファイルアーカイブ) のサイズ
250 MB レイヤーやカスタムランタイムなど、デプロイパッケージの内容の最大サイズ。(解凍済み)

コンテナイメージのコードパッケージサイズ
10 GB (非圧縮のイメージの最大サイズ、すべてのレイヤーを含む)

がれっとがれっと

Sarisiaさん、ご指摘ありがとうございます。勉強になります。
記事を修正させていただきました。🙇

markdown
- Cloudflare Workersでは主にJavaScriptで作られたアプリケーションが動作します。LambdaではDockerコンテナが動作します。また、Cloudflare Workersではファイルサイズ上限が3MBで、Lambdaは250MBです。(2025年1月現在)
+ Cloudflare Workersでは主にJavaScriptで作られたアプリケーションが動作します。LambdaではDockerコンテナが動作します。また、Cloudflare Workersではファイルサイズ上限が3MBで、Lambdaは10GB(コンテナを利用する場合)です。(2025年1月現在)
Takaaki FuruseTakaaki Furuse

SQliteが社内向けとはいえウェブサービスで動いてる、というのを見てちょっと驚きました。
ユーザーが限られる社内向けであればコスト削減はなおのこと嬉しいですね。

LaPhLaPh

最近はCloudflare D1をProductionで動かすのも珍しくないですし、libSQL(Turso)なんかもよく見かけるので、割とエッジで使う想定だと普通になってきてる気がしますね。