ECSとRDSをやめて、AWSコストを9割削減しました
はじめに
こんにちは。BEENOSのがれっとです。
AWS上にアプリケーションを構築する際、一般的なのはECS + RDSという組み合わせです。私も社内システムをそのような形で構築しました。
しかし、使わないときにもインスタンスが動き続けてしまうため、大量のトラフィックを捌かないアプリケーションにおいてはコストが見合わないものとなってしまいます。
そこで、ECS + RDSという構成からLambda + EFSの構成に社内システムを移行して、コスト削減した話を紹介します。
前提
以下の構成のアプリケーションを移行しました。
- Blitz.js
-
PostgreSQL
- テーブル数は12 (
_prisma_migrations
テーブルを含めて13)
- テーブル数は12 (
AWS 構成図
移行前
移行後
リレーショナルデータベースを用いることが必須のアプリケーションを構築する際、AWSでは通常RDSやAuroraを用いることが一般的です。
しかしこれは、性能にシビアでないアプリケーションを構築する際に、オーバースペックになることが多々あります。
EFS上にSQLiteを展開することにより、費用を抑えつつ、リレーショナルデータベースを諦めない構成にできます。
また、VPC LambdaはそのままだとVPC外にアクセスできません。そのため、LambdaにEIPをアタッチして、VPC外へのアクセスを可能にしました。
(残念ながら、EIPの保持に月3.6ドルほどかかります)
PostgreSQL から SQLite への移行
設定の変更
Prismaの設定を書き換えます。基本はSQLite から PostgreSQL への移行の逆を行います。
-
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"]
}
- 環境変数の書き換え
- DATABASE_URL=postgres://$USER:$PASSWORD@$CONTAINER_NAME:$PORT/$DB_NAME
+ DATABASE_URL=file:./db.sqlite # ローカル開発環境の場合
-
db/migrations
以下のマイグレーションファイルを全削除 -
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 /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にて発信しておりますので、是非ご覧ください。
とても気になった方はこちらでも求人を公開しておりますので、お気軽にご応募ください!
「自分に該当する職種がないな…?」と思った方はオープンポジションとしてご応募頂けると大変嬉しく思います 🙌
世界で戦えるサービスを創っていきたい方、是非是非ご連絡ください!よろしくお願いいたします!!
Discussion
ユーザー何名くらいの規模のサービスでしょうか。大規模アプリでSQLiteという選択肢は現実的なのでしょうか?
korokorotechさん、コメントありがとうございます。
こちらの社内システムはMAU10人程度のごく小規模のもので、同時に複数人がアクセスすることが基本的にはないという前提で構築しております。(もし集中して不具合が出たとしても、少し時間をおいて接続することが許容されるほどシステム要件的にはゆるいものです。)
性能調査を厳密に行っているわけではありませんが、同時に書き込みが発生する環境など、一般的なSQLiteの弱みが出るような環境では性能的に厳しいと考えています。
Cloudflare Workers無料版のファイルサイズ上限ですが2024年11月27日頃に1MBから3MBに変更されました。
たいきさん、ご指摘ありがとうございます。記事を修正させていただきました。🙇
こんにちは、大変実用的な記事ありがとうございます!
Lambda のアプリケーションサイズ上限ですが、 zip なら250MBなのですが、 Docker イメージなら10GBです!
Sarisiaさん、ご指摘ありがとうございます。勉強になります。
記事を修正させていただきました。🙇
SQliteが社内向けとはいえウェブサービスで動いてる、というのを見てちょっと驚きました。
ユーザーが限られる社内向けであればコスト削減はなおのこと嬉しいですね。
最近はCloudflare D1をProductionで動かすのも珍しくないですし、libSQL(Turso)なんかもよく見かけるので、割とエッジで使う想定だと普通になってきてる気がしますね。