👻

AWS Client VPNの起動停止ツールを実装してコスト削減した話

に公開

はじめに

こんにちは、DELTAの馬場です。
少し前に業務で利用するClient VPNの料金を削減するためのツールを導入したので、備忘がてら記事にします。

まず、今回のツールを作成するに至った背景を説明します。自社では月次でAWSの料金を確認し、なるべく必要最低限になるようにしています。
その中でClientVPNの料金が少し高く、実際の利用量と比較しても割高だったため、どうにかできないか?と思って調べ始めたのが作成のきっかけでした。

そもそものClient VPNの課金形態

料金表にもある通り、クライアント接続時とsubnetに関連づけられている状態のときに料金が発生します。

つまり、 不要なタイミングではsubnetへの関連付けを外してしまえば 料金が請求されません。

そのため、起動停止ツールとは言いつつも実体としてはClient VPNに対するsubnetの付外しツールを作成していきます。

実装

前提

Client VPNは既存のものがあり、ターゲットネットワークやセキュリティグループは設定済みで、任意のルールとして 0.0.0.0 でインターネットに出られるようにするルールのみが追加されているとします。

CloudFormationで実装

AWSTemplateFormatVersion: '2010-09-09'
Description: Automate the association and disassociation of AWS Client VPN
  subnets using Lambda and EventBridge.

Parameters:
  ClientVpnEndpointId:
    Description: The ID of the Client VPN endpoint.
    Type: String
  SubnetId:
    Description: The ID of the subnet to associate or disassociate with the Client VPN.
    Type: String
  LambdaExecutionRoleName:
    Description: Name for the IAM role that Lambda will use for execution.
    Type: String
  AssociateCronExpression:
    Description: Cron expression to associate the subnet (e.g., cron(0 8 * * ? *)). "UTC" time.
    Type: String
    Default: cron(0 8 * * ? *) # ????????8:00
  DisassociateCronExpression:
    Description: Cron expression to disassociate the subnet (e.g., cron(0 18 * * ? *))."UTC" time.
    Type: String
    Default: cron(0 18 * * ? *) # ????????18:00

Resources:
  # IAM Role for Lambda execution
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref LambdaExecutionRoleName
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: LambdaClientVpnPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - ec2:AssociateClientVpnTargetNetwork
                  - ec2:DisassociateClientVpnTargetNetwork
                  - ec2:DescribeClientVpnTargetNetworks
                  - ec2:CreateClientVpnRoute
                Resource: '*'

  VpnSubnetLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: ClientVpnSubnetManager
      Handler: index.lambda_handler
      Runtime: python3.9
      Role: !GetAtt LambdaExecutionRole.Arn
      MemorySize: 128
      Timeout: 60
      #using cheaper arch
      Architectures:
        - arm64
      Environment:
        Variables:
          CLIENT_VPN_ENDPOINT_ID: !Ref ClientVpnEndpointId
          SUBNET_ID: !Ref SubnetId
      Code:
        ZipFile: |
          import boto3
          import os
          ec2_client = boto3.client('ec2')
          CLIENT_VPN_ENDPOINT_ID = os.environ['CLIENT_VPN_ENDPOINT_ID']
          SUBNET_ID = os.environ['SUBNET_ID']
          def associate_subnet():
              associate_response = ec2_client.associate_client_vpn_target_network(
                  ClientVpnEndpointId=CLIENT_VPN_ENDPOINT_ID,
                  SubnetId=SUBNET_ID
              )
              route_response = ec2_client.create_client_vpn_route(
                  ClientVpnEndpointId=CLIENT_VPN_ENDPOINT_ID,
                  DestinationCidrBlock="0.0.0.0/0",
                  TargetVpcSubnetId=SUBNET_ID
              )
              return {
                  "AssociateResponse": associate_response,
                  "RouteResponse": route_response,
              }
          def disassociate_subnet():
              associations = ec2_client.describe_client_vpn_target_networks(
                  ClientVpnEndpointId=CLIENT_VPN_ENDPOINT_ID
              )
              for association in associations['ClientVpnTargetNetworks']:
                  if association['TargetNetworkId'] == SUBNET_ID:
                      association_id = association['AssociationId']
                      break
              response = ec2_client.disassociate_client_vpn_target_network(
                  ClientVpnEndpointId=CLIENT_VPN_ENDPOINT_ID,
                  AssociationId=association_id
              )
              return response
          def lambda_handler(event, context):
              action = event.get('action')
              if action == 'associate':
                  return associate_subnet()
              elif action == 'disassociate':
                  return disassociate_subnet()
              else:
                  raise ValueError("Invalid action. Use 'associate' or 'disassociate'.")

  AssociateSubnetRule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: !Ref AssociateCronExpression
      Targets:
        - Id: AssociateSubnetLambda
          Arn: !GetAtt VpnSubnetLambda.Arn
          Input: '{"action": "associate"}'
    DependsOn: VpnSubnetLambda

  DisassociateSubnetRule:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: !Ref DisassociateCronExpression
      Targets:
        - Id: DisassociateSubnetLambda
          Arn: !GetAtt VpnSubnetLambda.Arn
          Input: '{"action": "disassociate"}'
    DependsOn: VpnSubnetLambda

  LambdaInvokePermissionAssociate:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt VpnSubnetLambda.Arn
      Principal: events.amazonaws.com
      SourceArn: !GetAtt AssociateSubnetRule.Arn

  LambdaInvokePermissionDisassociate:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt VpnSubnetLambda.Arn
      Principal: events.amazonaws.com
      SourceArn: !GetAtt DisassociateSubnetRule.Arn

Outputs:
  LambdaFunctionName:
    Description: The name of the Lambda function managing VPN subnets.
    Value: !Ref VpnSubnetLambda
  AssociateEventRule:
    Description: EventBridge rule for associating the subnet.
    Value: !Ref AssociateSubnetRule
  DisassociateEventRule:
    Description: EventBridge rule for disassociating the subnet.
    Value: !Ref DisassociateSubnetRule

解説

ざっくり仕様開設

  • EventBridgeのcron式を使用して起動停止のスケジュール設定を行います
  • 1つのLambda関数で起動停止両方の挙動を制御し、payloadで渡される文字列で挙動を分岐させます
  • ARMアーキテクチャを採用してLambda関数のコスト効率を最適化しています
  • サブネット再紐付け時に、設定されていたセキュリティグループが自動で適用されます

パラメータ

パラメータ名 説明 タイプ デフォルト
ClientVpnEndpointId AWS Client VPN エンドポイントのIDを指定します。 String なし
SubnetId VPNに紐付けるサブネットのIDを指定します。 String なし
LambdaExecutionRoleName Lambda関数実行時のIAMロール名を指定します。 String なし
AssociateCronExpression サブネット紐付けをスケジュールするcron式。例: cron(0 8 * * ? *) String cron(0 8 * * ? *)
DisassociateCronExpression サブネット紐付け解除をスケジュールするcron式。例: cron(0 18 * * ? *) String cron(0 18 * * ? *)

まとめ

既存のClient VPNがあり、記事のCloudFormationのテンプレートで提供しているツールの前提を満たしていれば、ツールを使ってClient VPNの起動停止を達成できコスト削減につなげることが可能となります。

ちょっと宣伝

Google workspace SAML × AWS Client VPN の設定方法についての記事 も記載しているので、お困りの方に届けば幸いです。

DELTAテックブログ

Discussion