指定時刻でAuroraのインスタンスサイズをスケールアップする
英語版はこちらにでご覧ください:
Auroraの書き込みパフォーマンス
下のグラフは、私たちの自動スケーリングがかけられたAuroraクラスタの負荷図です。
各負荷試験後に、インスタンスのサイズを調整し、どれぐらいスロットリングするのかを検証しました。
db.t3.medium:スロットリングが激しい
db.r6g.large: 適度なスロットリング
db.r6g.xlarge: スロットリングなし
要するに、Auroraのオートスケーリングは、書き込み負荷には耐えられません。
注意点:スロットリングが発生してもアプリ側への転送レートは異常はないかもしれません。それはt3類などインスタンスがバースト機能を使った可能性があります。バースト機能を使うとCPUクレジットを消費するのでランニングコストが高くなります。
課題: 定期的な書き込み負荷
Auroraクラスタをスケールアウトしたい時は、リードレプリカを追加します。スケールアップしたい時はインスタンスサイズを大きくします。
AuroraのAuto-scalingはスケールアウトに対応していますが、スケールアップには非対応です。なぜなら、リードレプリカは、読み取りの負荷を分散できますが、書き込みのワークロードには無力です。
ライターエンドポイント(マスター)は一つしかないので、書き込みのスループットを改善するにはインスタンスを大きいなものに置き換えます。しかし、インスタンスのサイズを変更する際、ダウンタイムが生じます。
この記事では、ダウンタイムなしでEventBridgeを用いて定期的にAuroraライターインスタンスをスケールアップする方法を紹介します。性能検証にはk6を用いて負荷試験を行います。
データベース分割について
複数のデータベースを立ち上げ、シャーディングする手法もありますが、構成の複雑さが上がりますし、コストもさらにかかります。
AuroraのマルチマスターとAurora Serverless V2について
AuroraのマルチマスターはNoSQLのDBのような機能ではなく、書き込みのスループットを改善できません。「書き込み可用性を継続させる」機能です。とはいえ、AuroraのマルチマスターはMySQL5.6限定なので、廃棄されたものです。
Aurora Serverless v2では、書き込みのスループットを自動的に上げる機能があり、v1の遊休時間問題もなく魅力的なサービスですが、値段が普通のAuroraの数倍であり、今回はコストを控える解決方法を探りたいです。
From: AWS Aurora Serverless V2 — What’s new?
Each GB of Serverless V2 RAM is twice the price of V1 and more than 3 times the price of provisioned Aurora capacity (Sam Gibbons)
DynamoDBやRedis(クラスターモード有効)などのAWSサービスは自動的に書き込みスループットに対応しているので、Auroraにそいうい機能が備われていないことを知った時はがっかりしました。
指定した時間でスケールアップ
- ピーク負荷の直前にスケジュールでLambdaを実行させる
- Lambdaでdb.r6g.xlargeインスタンスをAuroraクラスタに追加させる
- インスタンスの追加を検知したRDSイベントが別のLambdaを呼び出す
- Lambdaでクラスタをdb.r6g.xlargeインスタンスにフェイルオーバーさせる
Auroraクラスタにインスタンスを追加(スケールアップ)
Lambdaでdb.r6g.xlargeインスタンスをクラスタに追加させる
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
exports.handler = async (event) => {
try {
const params = {
DBClusterIdentifier: process.env.DBClusterIdentifier,
DBInstanceIdentifier: process.env.DBInstanceIdentifier, // rds with higher write
Engine: 'aurora-postgresql',
DBInstanceClass: process.env.DBInstanceClass,
EngineVersion: '14.6',
PubliclyAccessible: false,
AvailabilityZone: process.env.AvailabilityZone,
MultiAZ: false,
EnablePerformanceInsights: true,
MonitoringInterval: 60,
MonitoringRoleArn: process.env.MonitoringRoleArn
};
const data = await rds.createDBInstance(params).promise();
console.log('Aurora instance created successfully:', data.DBInstance);
return data.DBInstance;
} catch (err) {
console.error('Error creating Aurora instance:', err);
throw err;
}
};
CloudFormation リソース
Lambda関数の定義
CreateRDSInstanceFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !GetAtt AuroraScaleUpLambdaExecutionRole.Arn
Runtime: nodejs16.x
Timeout: 60
Code:
ZipFile: |
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
// ...
Environment:
Variables:
DBClusterIdentifier:
Ref: RDSCluster
DBInstanceIdentifier: myapp-postgres-instance-3
DBInstanceClass: db.r6g.xlarge
AvailabilityZone: ap-northeast-1a
MonitoringRoleArn: !Join
- ""
- - "arn:aws:iam::"
- !Ref AWS::AccountId
- ":role/rds-monitoring-role"
ピークの直前、関数を呼び出すスケジュールのルール
ScheduledCreateRDSInstance:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: "cron(40 12 ? * MON-FRI *)"
State: ENABLED
Targets:
- Arn: !GetAtt CreateRDSInstanceFunction.Arn
Id: CreateRDSInstanceFunction
追加したインスタンスにフェルオーバー(スケールアップ)
Lambdaで追加したインスタンスにフェルオーバーさせる
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
const logger = require('console');
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const waitUntilRDSAvailable = async (dbInstanceIdentifier, maxAttempts = 90, pollingInterval = 10000) => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const response = await rds.describeDBInstances({ DBInstanceIdentifier: dbInstanceIdentifier }).promise();
const dbInstance = response.DBInstances[0];
const status = dbInstance.DBInstanceStatus;
if (status === 'available') {
console.log("RDS instance is available!");
return;
} else {
console.log(`RDS instance is not yet available (Status: ${status}). Retrying...`);
await wait(pollingInterval);
}
} catch (error) {
console.error("Error describing RDS instance:", error);
throw error; // Propagate the error to the caller
}
}
console.error("Timeout: RDS instance did not become available within the specified time.");
throw new Error("Timeout: RDS instance did not become available within the specified time.");
};
exports.handler = async (event, context) => {
logger.log("Received event: ", JSON.stringify(event));
const detail = event.detail;
if (detail && detail.SourceType === "DB_INSTANCE" && detail.EventCategories.includes("creation") && detail.SourceIdentifier === process.env.DBInstanceIdentifier) {
logger.log(`Received an instance creation event for ${process.env.DBInstanceIdentifier}`);
const params = {
DBClusterIdentifier: process.env.ClusterIdentifier,
TargetDBInstanceIdentifier: process.env.DBInstanceIdentifier
};
try {
await waitUntilRDSAvailable(process.env.DBInstanceIdentifier, 90, 10000); // 15 minutes
try {
await rds.failoverDBCluster(params).promise();
console.log('Failover completed successfully');
} catch (error) {
console.error('Error during failover:', error);
throw error;
}
} catch (error) {
return { statusCode: 500, body: "Error waiting for RDS instance to become available: " + error.message };
}
} else {
logger.log(`Received an event, but it is not an instance creation event for ${process.env.DBInstanceIdentifier}`);
}
};
RDSインスタンスを起動したところ、インスタンスはすぐ利用できません。利用できるようになるまで定期的にポーリングし、ステータスを確認します。通常には、利用可能の状態になるまで2分から5分かかりますが、安全策としてLambda上限の15分に設定しました。
(RDSインスタンスを起動したところ、フェイルオーバーするとエラーが出ちゃいます。)
可用性のイベントも使ってみましたが、シャットダウンとリスタート限定みたいでした。直近ストップしたインスタンスではないと発動しないようです。
CloudFormation リソース
Lambda関数の定義
RDSScaleUpFailoverFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !GetAtt AuroraScaleUpLambdaExecutionRole.Arn
Runtime: nodejs16.x
Timeout: 900 # 15 minutes; wait for RDS instance to be available
Code:
ZipFile: |
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
// ...
Environment:
Variables:
ClusterIdentifier:
Ref: RDSCluster
DBInstanceIdentifier: myapp-postgres-instance-3
インスタンス追加を検知し、Lambdaを実行させるEventBridgeルール
RDSCreateDBInstanceEventRule:
Type: "AWS::Events::Rule"
Properties:
Description: After DB instance created, invoke Lambda to failover RDS cluster
EventPattern:
source:
- aws.rds
detail-type:
- RDS DB Instance Event
detail:
SourceIdentifier:
- myapp-postgres-instance-3
EventCategories:
- creation
State: ENABLED
Targets:
- Arn: !GetAtt RDSScaleUpFailoverFunction.Arn
Id: RDSScaleUpFailoverTarget
指定した時間でスケールダウン
- ピーク負荷の終了後、スケジュールでLambdaを実行させる
- Lambdaでクラスタを小さいインスタンスにフェイルオーバーさせる
- フェイルオーバーを検視たRDSイベントがLambdaを呼び出す
- Lambdaでスケールアップのため追加したインスタンスを削除させる
小さいインスタンスにフェルオーバー(スケールダウン)
フェイルオーバーを実行させるLambda関数
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
exports.handler = async () => {
const params = {
DBClusterIdentifier: process.env.ClusterIdentifier,
TargetDBInstanceIdentifier: process.env.DBInstanceIdentifier
};
try {
await rds.failoverDBCluster(params).promise();
console.log('Failover completed successfully');
} catch (error) {
console.error('Error during failover:', error);
throw error;
}
};
CloudFormation
Lambda関数の宣言
RDSScaleDownFailoverFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !GetAtt AuroraScaleUpLambdaExecutionRole.Arn
Runtime: nodejs16.x
Timeout: 60
Code:
ZipFile: |
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
// ...
Environment:
Variables:
ClusterIdentifier:
Ref: RDSCluster
DBInstanceIdentifier: myapp-postgres-instance-1
ピーク終了後、Lambdaを呼び出すスケジュールのルール
ScheduledRDSScaleDownFailover:
Type: AWS::Events::Rule
Properties:
ScheduleExpression: "cron(25 14 ? * MON-FRI *)"
State: !FindInMap [EnvToParams, !Ref EnvironmentType, RDSDBScaleUpEnabled]
Targets:
- Arn: !GetAtt RDSScaleDownFailoverFunction.Arn
Id: RDSScaleDownFailoverFunction
フェルオーバーを検知し、インスタンスを削除
スケールアップのため追加したインスタンスを削除させるLambda関数
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
const logger = require('console');
exports.handler = async (event, context) => {
logger.log("Received event: ", JSON.stringify(event));
const detail = event.detail;
// ensure failover was for target instance
if (detail && detail.SourceType === "CLUSTER" && detail.EventCategories.includes("failover") && detail.Message.includes(process.env.FailoverTargetDBInstanceIdentifier)) {
logger.log(`Received an cluster failover event to ${process.env.FailoverTargetDBInstanceIdentifier}`);
const params = {
DBInstanceIdentifier: process.env.DBInstanceToRemoveIdentifier
};
try {
await rds.deleteDBInstance(params).promise();
console.log('Old replica instance deleted successfully');
} catch (error) {
console.error('Error during cleanup:', error);
throw error;
}
} else {
logger.log(`Received an event, but it is not an cluster failover event to ${process.env.FailoverTargetDBInstanceIdentifier}`);
}
};
CloudFormation リソース
Lambda関数定義
RemoveRDSInstanceFunction:
Type: AWS::Lambda::Function
Properties:
Handler: index.handler
Role: !GetAtt AuroraScaleUpLambdaExecutionRole.Arn
Runtime: nodejs16.x
Timeout: 60
Code:
ZipFile: |
const AWS = require('aws-sdk');
const rds = new AWS.RDS();
// ...
Environment:
Variables:
DBInstanceToRemoveIdentifier: myapp-postgres-instance-3
FailoverTargetDBInstanceIdentifier: myapp-postgres-instance-1
フェイルオーバーを検知し、Lambdaを呼び出すEventBridgeのルール
RDSClusterFailoverEventRule:
Type: "AWS::Events::Rule"
Properties:
Description: After RDS cluster failover to original reader, invoke Lambda to delete extra instance
EventPattern:
source:
- aws.rds
detail-type:
- RDS DB Cluster Event
detail:
SourceIdentifier:
- Ref: RDSCluster
EventCategories:
- failover
State: ENABLED
Targets:
- Arn: !GetAtt RemoveRDSInstanceFunction.Arn
Id: RDSScaleDownFailoverTarget
CloudFormationのロールと権限
Lambda実行のロール
AuroraScaleUpLambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: LambdaExecutionPolicy
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup # allow lambda to write to CloudWatch logs
- logs:CreateLogStream
- logs:PutLogEvents
- iam:PassRole # allow lambda to pass rds-monitoring role to rds instance
- rds:DescribeDBInstances
- rds:FailoverDBCluster
- rds:CreateDBInstance
- rds:DeleteDBInstance
Resource: "*"
Lambda実行の権限をEventBridgeルールに付与
InvokeCreateRDSInstancePermission:
Type: "AWS::Lambda::Permission"
Properties:
Action: "lambda:InvokeFunction"
FunctionName: !Ref CreateRDSInstanceFunction
Principal: "events.amazonaws.com"
SourceArn: !GetAtt ScheduledCreateRDSInstance.Arn
InvokeRDSScaleUpFailoverPermission:
Type: "AWS::Lambda::Permission"
Properties:
Action: "lambda:InvokeFunction"
FunctionName: !Ref RDSScaleUpFailoverFunction
Principal: "events.amazonaws.com"
SourceArn: !GetAtt RDSCreateDBInstanceEventRule.Arn
InvokeRDSScaleDownFailoverPermission:
Type: "AWS::Lambda::Permission"
Properties:
Action: "lambda:InvokeFunction"
FunctionName: !Ref RDSScaleDownFailoverFunction
Principal: "events.amazonaws.com"
SourceArn: !GetAtt ScheduledRDSScaleDownFailover.Arn
InvokeRemoveRDSInstancePermission:
Type: "AWS::Lambda::Permission"
Properties:
Action: "lambda:InvokeFunction"
FunctionName: !Ref RemoveRDSInstanceFunction
Principal: "events.amazonaws.com"
SourceArn: !GetAtt RDSClusterFailoverEventRule.Arn
懸念事項
1. ダウンタイム
クラスタがフェールオーバーしている時、ダウンタイムがあるかどうかを最初に検証したものです。数回の書き込みの負荷試験を行なった結果、ダウンタイムはほぼゼロでした。
負荷試験では、1万のリクエストを送信し、テストの途中でフェールオーバーしました。すべてのリクエストも無事に受け取りました。
Grafanaで負荷試験を可視化しました。k6 on EKSという負荷試験プラットフォームに関して、こちらの記事をご覧ください。
2. レプリケーションの遅延時間が長くなる
AWSは、クラスタの中でインスタンスのサイズを同一にするのがお勧めです。なぜなら、小さいインスタンスが、大きいものの書き込みワークロードを追いつけなくなり、レプリケーションの遅延が増え、やむなくリスタートさせるはめになりますから。
今回の仕様では、単一AZでdb.t3.mediumインスタンス一枚とdb.r6g.xlargeインスタンス二枚です。負荷試験を実行させながらAuroraReplicaLagを監視していました。最高値は85msでした。
リスタートさせる遅延が通常60秒のようなので、マルチAZやサイズ差が激しい場合でしたら、遅延時間をご留意ください。
3. データ量
同僚は、データ量が増えたら、インスタンスの起動時間が遅くなる恐れを抱えていました。
通常のRDSと違い、Auroraの構造には、データがインスタンスに依存することではなく、インスタンスと分離されているデータ層に収まり、AZ単位で同期されています。
キャッシュがインスタンスに残っている可能性はありますが、インスタンスを追加するにはデータを同期する必要はありません。理論上、インスタンスを起動する時間はデータスターのサイズにより変わるものではないはずです。
これを検証するため、データベースをデータを打ち込み、DBは300MBから128GBまでのサイズになりましたが、インスタンスの起動時間は常に8分ぐらいでした。
追記:オートスケールアップについて
書き込み負荷が予測できない場合は、EventBridgeルールの代わり、CloudWatchアラームを使う方式もありますが、インスタンスの追加とフェルオーバーは十分以上かかりますので、負荷をすぐ対応できる方法ではないです。最初から大きいインスタンスを使うか、Serverless V2を使うか、DynamoDBやRedisのサービスに移植するか、他の手法も検討するべきです。
Discussion