こんにちは。
エンジニアの遠藤です。

久々に Lambda (serverless framework)の記事となります。
今回は、とある小さめの案件で、 Lambda からアクセスする Database に SQLite を採用してみたので、構成などをまとめておきたいと思います。

プロジェクトの構成

プロジェクトの構成は以下のような内容です。

efs_image

静的な Webページは S3 に配置しておき、 CloudFront 経由で配布します。
API Gateway + Lambda を使って中身のコンテンツを取得しています。

また、表示するデータは何かしらのDBに格納しておき、画面からの更新は行いません。
データの更新は、 EventBridge をトリガーに毎分実行されます。
Lambda が定期的に外部の API を呼び出して集計を行った結果で Update します。

Read は同時に複数アクセスされる可能性があり、 Write が複数箇所から同時に実行されることはない、という状況です。

データの件数は、最新の値のみ保持していればよく、アクセスするユーザ数や、ログなどに応じて増えることはありません。
常に数千件の集計結果が保持されているという状態になります。
マスターテーブルのようなイメージが近いかもしれません。

Database の選定

さて、 Database の選択肢ですが、一般的な用途で言えば、 RDS, Aurora あたりが採用されることが多いです。
また、設計さえしっかりできていれば、 DynamoDB を使う選択肢もあります。

今回の用途で言えば、特に複雑な設計ではないので、どの Database を採用することも可能です。
ただし、 RDS や DynamoDB は、ややお高めなことが引っかかっていました。

そこで、もうちょっとお手軽に使えるデータベースないかなと調べていたところ、 EFS が Lambda からも利用できるよ、ということで、 Lambda + EFS + SQLite の構成でどの程度の性能が出せるのか調べてみました。
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/services-efs.html

性能を測定する

というわけで、今回は実際に似たような案件で、過去に採用した Aurora RDS との比較をしてみたいと思います。
それぞれ 10,000 件ずつインサートした状態のテーブルに対して、全件セレクトと、アップデートをしてみて、その実行速度を計測してみます。

1回だと誤差が出そうなので、 100回ずつリクエストを行い、最大、最小、平均値を比較してみます。

それぞれ 100 回ずつ select 性能を測定し、最大、最小、平均を見てみます。

DB 構成

Aurora MySQL

  AuroraParameterGroup:
    Type: AWS::RDS::DBClusterParameterGroup
    Properties:
      Description: Cluster parameter group for aurora-mysql5.7
      Family: aurora-mysql5.7
      Parameters:
        character_set_client: utf8mb4
        character_set_server: utf8mb4
  AuroraCluster:
    Type: AWS::RDS::DBCluster
    Properties:
      DBClusterIdentifier: rds-sample
      DatabaseName: mydatabase
      Engine: aurora-mysql
      DBClusterParameterGroupName:
        Ref: AuroraParameterGroup
      DBSubnetGroupName:
        Ref: AuroraSubnetGroup
      EnableHttpEndpoint: true
      EngineMode: serverless
      ScalingConfiguration:
        AutoPause: true
        MaxCapacity: 1
        MinCapacity: 1
        SecondsUntilAutoPause: 600
      StorageEncrypted: true
      VpcSecurityGroupIds:
        - Fn::GetAtt:
            - AuroraSecurityGroupDefinition
            - GroupId

テーブル構成

Aurora MySQL

Column Name Data Type Constraints
user_id BIGINT NOT NULL
organization_id VARCHAR(64) CHARACTER SET utf8 NOT NULL, COLLATE utf8_general_ci
user_name VARCHAR(64) CHARACTER SET utf8 NOT NULL, COLLATE utf8_general_ci
status INT NOT NULL

Primary Key: user_id

SQLite

Column Name Data Type Constraints
user_id INTEGER PRIMARY KEY, NOT NULL
organization_id TEXT NOT NULL
user_name TEXT NOT NULL
status INTEGER NOT NULL

select

クエリはこんな感じ。

select * from sample_master limit 10000;

Aurora MySQL

最大時間: 2.911370515823364
最小時間: 1.679802656173706
平均時間: 2.2446921920776366

SQLite

最大時間: 1.0608539581298828
最小時間: 0.9547784328460693
平均時間: 1.0193749523162843

これは、想定外で、想像以上に SQLite が早かったです。

update

クエリはこんな感じで、これを 100 件更新します。

update sample_master set user_name="hoge" where user_id=1;

Aurora MySQL

最大時間: 4.00672483444214
最小時間: 2.2890918254852295
平均時間: 3.147315459251404

SQLite

最大時間: 0.1183171272277832
最小時間: 0.07905888557434082
平均時間: 0.09160815477371216

結果

というわけで、個人的に想像していた以上に SQLite が早く、想像以上に Aurora MySQL が遅いという結果になりました。

今回は Aurora MySQL への接続に、 boto3 の rds-data クライアント を使いました。
やはり、リクエストの都度ネットワーク処理が挟まるため、 select 1回とかであれば、そこまで差は大きな差は出ませんが、何度も update をリクエストするようなコードを書いてしまうと、差が顕著になるなと感じました。

一方 SQLite はストレージをマウントしているというだけあって、想像以上に処理が早かったです。

もちろん、 SQLite は同時書き込みに難があったり、クラスター化やバックアップ、復元などの面で、デメリットはあります。
EFS への同時接続数にも制約があります。
また、 DynamoDB のように VPC を作成しなくてもサッと使えるわけではないので、構築にはやや手間が必要です。
ただし、その辺りのデメリットを考慮しても、しっかり用途さえ見極めれば、費用面でも性能面でも、十分に選択肢の一つとしてよいパフォーマンスではないかと感じました。

CloudFormation, Serverless の定義

今回も環境構築には serverless framework を使用しました。

最後に、参考までに今回の検証で使用した VPC, EFS の CloudFormaion の記載と、 serverless.yml の内容を添付しておきます。

VPC

Resources:
  VPCDefinition:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true
      InstanceTenancy: default
  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId:
        Ref: VPCDefinition
      InternetGatewayId:
        Ref: InternetGateway
  VPCPublicSubnet1Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.0.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 0
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: true
  VPCPublicSubnetSharedRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId:
        Ref: VPCDefinition
  VPCPublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCPublicSubnetSharedRouteTable
      SubnetId:
        Ref: VPCPublicSubnet1Subnet
  VPCPublicSubnetDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: VPCPublicSubnetSharedRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId:
        Ref: InternetGateway
    DependsOn:
      - GatewayAttachment
  VPCEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
  NATGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId:
        Fn::GetAtt:
          - VPCEIP
          - AllocationId
      SubnetId:
        Ref: VPCpublicSubnet1Subnet
  VPCPublicSubnet2Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.1.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 1
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: true
  VPCPublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCPublicSubnetSharedRouteTable
      SubnetId:
        Ref: VPCPublicSubnet2Subnet
  VPCLambdaSubnetDefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId:
        Ref: VPCLambdaSubnetSharedRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId:
        Ref: NATGateway
  VPCLambdaSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCLambdaSubnetSharedRouteTable
      SubnetId:
        Ref: VPCLambdaSubnet1Subnet
  VPCLambdaSubnet1Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.2.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 0
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: false
  VPCLambdaSubnetSharedRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId:
        Ref: VPCDefinition
  VPCLambdaSubnet2Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.3.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 1
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: false
  VPCLambdaSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCLambdaSubnetSharedRouteTable
      SubnetId:
        Ref: VPCLambdaSubnet2Subnet
  VPCPrivateSubnet1Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.4.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 0
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: false
  VPCPrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCLambdaSubnetSharedRouteTable
      SubnetId:
        Ref: VPCPrivateSubnet1Subnet
  VPCPrivateSubnet2Subnet:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: 10.0.5.0/24
      VpcId:
        Ref: VPCDefinition
      AvailabilityZone:
        Fn::Select:
          - 1
          - Fn::GetAZs: ""
      MapPublicIpOnLaunch: false
  VPCPrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId:
        Ref: VPCLambdaSubnetSharedRouteTable
      SubnetId:
        Ref: VPCPrivateSubnet2Subnet
  LambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: LambdaSecurityGroup
      GroupName: ${self:service}-${self:provider.stage}-lambda-sg
      SecurityGroupEgress:
        - CidrIp: 0.0.0.0/0
          Description: Allow all outbound traffic by default
          IpProtocol: "-1"
      VpcId:
        Ref: VPCDefinition
  LambdaSecurityGroupFromLambdaSecurityGroup:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      IpProtocol: "-1"
      GroupId:
        Fn::GetAtt:
          - LambdaSecurityGroup
          - GroupId
      SourceSecurityGroupId:
        Fn::GetAtt:
          - LambdaSecurityGroup
          - GroupId
  EFSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EFS Allowed Ports
      VpcId:
        Ref: VPCDefinition
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 2049
          ToPort: 2049
          SourceSecurityGroupId:
            Fn::GetAtt:
              - LambdaSecurityGroup
              - GroupId
          Description: from Lambda
    DependsOn:
      - LambdaSecurityGroup

EFS

Resources:
  EFSFileSystem:
    Type: AWS::EFS::FileSystem
    Properties:
      FileSystemTags:
        - Key: Name
          Value: FileSystem
      BackupPolicy:
        Status: ENABLED
      Encrypted: true
      LifecyclePolicies:
        - TransitionToIA: AFTER_30_DAYS
      PerformanceMode: generalPurpose
    DependsOn: VPCDefinition
  EFSMountTarget:
    Type: AWS::EFS::MountTarget
    Properties:
      FileSystemId: !Ref EFSFileSystem
      SecurityGroups:
        - !Ref EFSSecurityGroup
      SubnetId: !Ref VPCPrivateSubnet1Subnet
    DependsOn: EFSFileSystem

  EFSAccessPoint:
    Type: AWS::EFS::AccessPoint
    Properties:
      FileSystemId: !Ref EFSFileSystem
      PosixUser:
        Uid: 1001
        Gid: 1001
      RootDirectory:
        Path: ${self:provider.environment.EFS_ROOT_DIR}
        CreationInfo:
          OwnerGid: 1001
          OwnerUid: 1001
          Permissions: 750
      AccessPointTags:
        - Key: Name
          Value: AccessPoint
    DependsOn: EFSFileSystem

Serverless.yml

service: sls-python-sample
frameworkVersion: '3'

provider:
  name: aws
  runtime: python3.9
  stage: ${opt:stage, self:custom.defaultStage}
  environment: ${file(./env/${self:provider.stage}.yml)}
  iamRoleStatements: ${file(provider/iam.yml)}
  vpc:
    subnetIds:
      - !Ref VPCPrivateSubnet1Subnet
    securityGroupIds:
      - !Ref LambdaSecurityGroup

resources:
  - ${file(resources/vpc.yml)}
  - ${file(resources/efs.yml)}

plugins:
  - serverless-python-requirements
  - serverless-prune-plugin

custom:
  defaultStage: local
  pythonRequirements:
    pythonBin: python
  prune:
    automatic: true
    number: 1

functions:
  hello:
    handler: handler.hello
    timeout: 900
    handler: handler.create_table_rds
    fileSystemConfig:
      localMountPath: ${self:provider.environment.EFS_MOUNT_DIR}
      arn: !GetAtt ["EFSAccessPoint", "Arn"]
    events:
      - http:
          path: /show_records
          method: get