🐡

指定時刻でAuroraのインスタンスサイズをスケールアップする

2024/03/18に公開

英語版はこちらにでご覧ください:

Auroraの書き込みパフォーマンス

下のグラフは、私たちの自動スケーリングがかけられたAuroraクラスタの負荷図です。

各負荷試験後に、インスタンスのサイズを調整し、どれぐらいスロットリングするのかを検証しました。

image

db.t3.medium:スロットリングが激しい
db.r6g.large: 適度なスロットリング
db.r6g.xlarge: スロットリングなし

要するに、Auroraのオートスケーリングは、書き込み負荷には耐えられません。

注意点:スロットリングが発生してもアプリ側への転送レートは異常はないかもしれません。それはt3類などインスタンスがバースト機能を使った可能性があります。バースト機能を使うとCPUクレジットを消費するのでランニングコストが高くなります。

課題: 定期的な書き込み負荷

Auroraクラスタをスケールアウトしたい時は、リードレプリカを追加します。スケールアップしたい時はインスタンスサイズを大きくします。

AuroraのAuto-scalingスケールアウトに対応していますが、スケールアップには非対応です。なぜなら、リードレプリカは、読み取りの負荷を分散できますが、書き込みのワークロードには無力です。

image

image

ライターエンドポイント(マスター)は一つしかないので、書き込みのスループットを改善するにはインスタンスを大きいなものに置き換えます。しかし、インスタンスのサイズを変更する際、ダウンタイムが生じます。

この記事では、ダウンタイムなしでEventBridgeを用いて定期的にAuroraライターインスタンスをスケールアップする方法を紹介します。性能検証にはk6を用いて負荷試験を行います。

データベース分割について

複数のデータベースを立ち上げ、シャーディングする手法もありますが、構成の複雑さが上がりますし、コストもさらにかかります。

AuroraのマルチマスターとAurora Serverless V2について

AuroraのマルチマスターはNoSQLのDBのような機能ではなく、書き込みのスループットを改善できません。「書き込み可用性を継続させる」機能です。とはいえ、AuroraのマルチマスターはMySQL5.6限定なので、廃棄されたものです。

Aurora Serverless v2では、書き込みのスループットを自動的に上げる機能があり、v1の遊休時間問題もなく魅力的なサービスですが、値段が普通のAuroraの数倍であり、今回はコストを控える解決方法を探りたいです。

image

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にそいうい機能が備われていないことを知った時はがっかりしました。

指定した時間でスケールアップ

image

  1. ピーク負荷の直前にスケジュールでLambdaを実行させる
  2. Lambdaでdb.r6g.xlargeインスタンスをAuroraクラスタに追加させる
  3. インスタンスの追加を検知したRDSイベントが別のLambdaを呼び出す
  4. 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

指定した時間でスケールダウン

image

  • ピーク負荷の終了後、スケジュールで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万のリクエストを送信し、テストの途中でフェールオーバーしました。すべてのリクエストも無事に受け取りました。

image

Grafanaで負荷試験を可視化しました。k6 on EKSという負荷試験プラットフォームに関して、こちらの記事をご覧ください。

2. レプリケーションの遅延時間が長くなる

AWSは、クラスタの中でインスタンスのサイズを同一にするのがお勧めです。なぜなら、小さいインスタンスが、大きいものの書き込みワークロードを追いつけなくなり、レプリケーションの遅延が増え、やむなくリスタートさせるはめになりますから。

今回の仕様では、単一AZでdb.t3.mediumインスタンス一枚とdb.r6g.xlargeインスタンス二枚です。負荷試験を実行させながらAuroraReplicaLagを監視していました。最高値は85msでした。

リスタートさせる遅延が通常60秒のようなので、マルチAZやサイズ差が激しい場合でしたら、遅延時間をご留意ください。

3. データ量

同僚は、データ量が増えたら、インスタンスの起動時間が遅くなる恐れを抱えていました。

通常のRDSと違い、Auroraの構造には、データがインスタンスに依存することではなく、インスタンスと分離されているデータ層に収まり、AZ単位で同期されています。

image

キャッシュがインスタンスに残っている可能性はありますが、インスタンスを追加するにはデータを同期する必要はありません。理論上、インスタンスを起動する時間はデータスターのサイズにより変わるものではないはずです。

これを検証するため、データベースをデータを打ち込み、DBは300MBから128GBまでのサイズになりましたが、インスタンスの起動時間は常に8分ぐらいでした。

追記:オートスケールアップについて

書き込み負荷が予測できない場合は、EventBridgeルールの代わり、CloudWatchアラームを使う方式もありますが、インスタンスの追加とフェルオーバーは十分以上かかりますので、負荷をすぐ対応できる方法ではないです。最初から大きいインスタンスを使うか、Serverless V2を使うか、DynamoDBやRedisのサービスに移植するか、他の手法も検討するべきです。

文献

Discussion