AWS 環境からPlanetScale に接続した場合のレイテンシを計測してみた

今回はAWS環境からPlanetScaleに接続した場合に、どれくらいレイテンシがかかるのかをRDSと比較し検証しました
2023.05.20

西田@CX事業本部です。

今回はAWS環境 から PlanetScale に接続した場合に、どれくらいレイテンシがかかるのかをRDSと比較し検証しました

全体構成

API Gateway + Lambda の構成で、Lambda から PlanetScale に接続します

また、比較用にVPC Lambda + RDS の環境も用意しました

PlanetScale のデータベース準備

今回はレイテンシを測ることが目的なので、AWS Lambda を構築するリージョンと同じ ap-northeast-1PlanetScale のデータベースを作成しました

テーブルの作成

PlanetScale のダッシュボードから Console にアクセスして以下のDDLでテーブルを作成しました

CREATE TABLE `posts` (
    `id` INT NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `title` VARCHAR(255),
    `content` TEXT
);

データを投入

初期データを投入します

INSERT INTO posts (`title`, `content`) VALUES ('サンプル1', 'ここほれわんわん1');
INSERT INTO posts (`title`, `content`) VALUES ('サンプル2', 'ここほれわんわん2');
INSERT INTO posts (`title`, `content`) VALUES ('サンプル3', 'ここほれわんわん3');
INSERT INTO posts (`title`, `content`) VALUES ('サンプル4', 'ここほれわんわん4');

環境構築

Lambda

PlanetScale用とRDS用の両方で同じ Lambdaを使用しました。主な処理としては以下の時間を計測し CloudWatch Logs に構造化ログ(JSON)で出力しました

  1. データベースの接続にオープンするのにかかった時間
  2. クエリを実行するのにかかった時間
Node.jsのソース
import { APIGatewayEvent, APIGatewayProxyResult, Context } from "aws-lambda";
import * as mysql from "mysql2/promise";

const log = (param: Object) => {
  console.log(JSON.stringify(param));
};

let conn: mysql.Connection | undefined;

export const handler = async (
  event: APIGatewayEvent,
  context: Context
): Promise<APIGatewayProxyResult> => {
  let rows;

  try {
    let startTime = process.hrtime();

    conn = await mysql.createConnection(process.env.DATABASE_URL!);
    const connEnd = process.hrtime(startTime);

    startTime = process.hrtime();
    [rows] = await conn?.query("SELECT * FROM `posts`");
    const queryEnd = process.hrtime(startTime);

    log({
      kind: "connect",
      type: process.env.ANNOTATE,
      conn: connEnd[1],
      query: queryEnd[1],
    });
  } catch (e) {
    console.log(e);
  } finally {
    conn?.end();
  }

  return {
    statusCode: 200,
    body: JSON.stringify(rows),
  };
};

CDK

PlanetScale に接続する Lambda のCDKのソースです

CDKのソース
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export class PlanetscaleLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const databaseUrl = cdk.aws_ssm.StringParameter.valueForStringParameter(
      this,
      "planetScaleLambda-DatabaseURL"
    );

    const func = new cdk.aws_lambda_nodejs.NodejsFunction(this, "apiFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_18_X,
      entry: "lambda/handler.ts",
      environment: {
        DATABASE_URL: databaseUrl,
        ANNOTATE: "planet-mysql",
      },
      tracing: cdk.aws_lambda.Tracing.ACTIVE,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
    });

    const api = new cdk.aws_apigateway.RestApi(this, "apigw", {
      restApiName: "PlanetScale Lambda",
      description: "APIGW for PlanetScale and Lambda",
      deployOptions: {
        tracingEnabled: true,
      },
    });

    const integration = new cdk.aws_apigateway.LambdaIntegration(func, {
      requestTemplates: { "application/json": '{"statusCode": 200}' },
    });

    api.root.addMethod("GET", integration);
  }
}

VPC Lambda + RDS の CDK のコードです

  • VPCの構築
  • RDSの構築
  • Lambdaの構築
CDKのソース
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";

export class RdsLambdaStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const vpc = new cdk.aws_ec2.Vpc(this, "vpc", {
      maxAzs: 2,
      natGateways: 0,
      subnetConfiguration: [
        {
          cidrMask: 24,
          name: "Public",
          subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
        },
      ],
    });

    const lambdaSg = new cdk.aws_ec2.SecurityGroup(this, "lambdaSg", {
      vpc,
      description: "",
      allowAllOutbound: true,
    });

    const dbSg = new cdk.aws_ec2.SecurityGroup(this, "dbSg", {
      vpc,
      allowAllOutbound: true,
    });
    const bastionSg = new cdk.aws_ec2.SecurityGroup(this, "bastionSg", { vpc });

    dbSg.addIngressRule(lambdaSg, cdk.aws_ec2.Port.tcp(3306));
    dbSg.addIngressRule(bastionSg, cdk.aws_ec2.Port.tcp(3306));

    const bastion = new cdk.aws_ec2.BastionHostLinux(this, "Bastion", {
      vpc,
      instanceType: cdk.aws_ec2.InstanceType.of(
        cdk.aws_ec2.InstanceClass.T4G,
        cdk.aws_ec2.InstanceSize.NANO
      ),
      securityGroup: bastionSg,
      subnetSelection: {
        subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
      },
    });
    bastion.instance.addUserData("yum -y update", "yum install -y mysql jq");

    const dbUser = "user";
    const dbName = "test";

    const rdsCredentials = cdk.aws_rds.Credentials.fromPassword(
      dbUser,
      // WARN: 簡略化のためパスワードを文字列で指定してます
      new cdk.SecretValue("password")
    );

    const rds = new cdk.aws_rds.DatabaseInstance(this, "db", {
      vpc,
      engine: cdk.aws_rds.DatabaseInstanceEngine.mysql({
        version: cdk.aws_rds.MysqlEngineVersion.VER_8_0_31,
      }),
      credentials: rdsCredentials,
      databaseName: dbName,
      securityGroups: [dbSg],
      instanceType: cdk.aws_ec2.InstanceType.of(
        cdk.aws_ec2.InstanceClass.BURSTABLE4_GRAVITON,
        cdk.aws_ec2.InstanceSize.SMALL
      ),
      vpcSubnets: {
        subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
      },
    });

    const databaseUrl = `mysql://${dbUser}:${rdsCredentials.password?.unsafeUnwrap()}@${
      rds.dbInstanceEndpointAddress
    }/${dbName}`;

    const func = new cdk.aws_lambda_nodejs.NodejsFunction(this, "apiFn", {
      runtime: cdk.aws_lambda.Runtime.NODEJS_18_X,
      entry: "lambda/handler.ts",
      environment: {
        DATABASE_URL: databaseUrl,
        ANNOTATE: "rds-mysql",
      },
      tracing: cdk.aws_lambda.Tracing.ACTIVE,
      architecture: cdk.aws_lambda.Architecture.ARM_64,
      securityGroups: [lambdaSg],
      vpcSubnets: vpc.selectSubnets({
        subnetType: cdk.aws_ec2.SubnetType.PUBLIC,
      }),
      vpc,
      allowPublicSubnet: true,
    });

    const api = new cdk.aws_apigateway.RestApi(this, "apigw", {
      restApiName: "RDS Lambda",
      description: "APIGW for RDS and Lambda",
      deployOptions: {
        tracingEnabled: true,
      },
    });

    const integration = new cdk.aws_apigateway.LambdaIntegration(func, {
      requestTemplates: { "application/json": '{"statusCode": 200}' },
    });

    api.root.addMethod("GET", integration);
  }
}

※ 簡略化のため、パスワードを文字列で指定し、インスタンスをパブリックサブネットに置いてますが実際に運用するときはセキュリティ的な考慮が必要です

検証結果

秒間100リクエストで30秒間アクセスし、かかった平均値、最大値、95%タイルを CloudWatch Logs Insight で以下のクエリを使って集計しました

平均値

stats avg(conn) /1000000 , avg(query) / 1000000 by type
| filter kind="connect"

最大値

stats max(conn) /1000000 , max(query) / 1000000 by type
| filter kind="connect"

95%タイル

stats pct(conn, 95) /1000000 , pct(query, 95) / 1000000 by type
| filter kind="connect"

結果

以下が計測結果になります。単位は ミリ秒(msec)です

接続

DB 平均 95%タイル 最大
PlanetScale 116.0112 198.7724 939.9752
RDS 22.3542 40.1569 353.667

クエリ

DB 平均 95%タイル 最大
PlanetScale 11.5505 20.1922 186.0484
RDS 4.9825 17.5909 186.1258

接続にかかる時間は、RDSが22msecに対しPlanetScale が116msecと約4倍近くの処理時間がかかりました。接続確立後に簡単なクエリを実行する分には RDS が約5msec なのに対し、PlanetScale は約11msecとなっています。

データベースとの接続には少し時間がかかりますが、コネクションプールなどを活用し接続を維持しながら活用する分には、同じVPCのネットワーク内にあるRDSと比べ、そこまで性能劣化を感じることなく使える速度ではないでしょうか

まとめ

日頃RDSを使ってると、PlanetScaleのようなサービスを選択しようとしたときに、気になる要素の1つはレイテンシではないでしょうか。フルマネージドのRDBを使いたい、でも、レイテンシが大きくなりすぎるとサービスのレスポンスに悪影響が出そうで使いにくいと考えておられる方も少ないと思います。

今回はクエリも簡単でデータもほとんど投入されてない状態で計測しましたが、実際に使う際には、もっと複雑なクエリで、データ量も多くなると思います。そうなれば全体の処理から見て、レイテンシの影響も薄くなっていくものと思われます

この記事が誰かの役に立てば幸いです

参考

話題のPlanetScale を AWS Lambda から使ってみた