top of page
検索
執筆者の写真make

ClientVPNのコスト最適化

始めに

 

以前、「 VerifiedAccessはclientVPNの代わりとなりうるか? 」という記事の中で、コストを抑える方法を軽く紹介していましたが、今回は具体的にどうやって実現したかを紹介したいと思います。


料金体系

 

コスト最適化の話になりますので、料金体系に軽く触れておきます。AWS公式料金ページ にある通り、以下の二要素による従量課金制となっています。

  • AWS Client VPN エンドポイントの時間料金=$0.15/h

  • AWS Client VPN 接続の時間料金=$0.05/h

1つ目のエンドポイントの時間料金は関連付けられているサブネットそれぞれにかかってきます。これは接続利用有無とは関係なく、関連付けしている限りずっと費用発生します。

2つ目の接続の時間料金は実際にVPN接続を使用した時間で発生します。


構成イメージ

 

ClientVPNを経由してオンプレや各AWSリソースへのセキュアなアクセス経路を構築しています。今回お伝えするコスト最適化施策は以下の赤枠部分になります。



①Lambdaでサブネット関連付けをコントロール

②スプリットトンネルをあえて有効化しない。



①Lambdaでサブネット関連付けをコントロール



主に日中業務で外部から接続するときのためにClientVPNを使用しているユースケースであるため、夜間はほぼ使用することが有りません。料金体系で触れた1つ目の課金が常時発生するのは無駄であるため、LambdaをEventBridgeでスケジュール起動して不要な時間帯は関連付け解除を行います。


CloudFormationのサンプルは以下。

AWSTemplateFormatVersion: '2010-09-09'
Description: This CloudFormation template to create clientvpn.

Parameters: 
    EnvironmentType: 
      Description: The environment type
      Type: String
      Default: DEV
      AllowedValues: 
        - DEV
        - PRD
      ConstraintDescription: must be a DEV or PRD
    AvailabilityZoneType: 
      Description: The environment type
      Type: String
      Default: Single
      AllowedValues: 
        - Single
        - Multi
      ConstraintDescription: must be a Single or Multi
    ClientVpnEndpoint: 
      Description: ClientVPN endpoint
      Type: String

Mappings: 
  EnvironmentTypeLower: 
    DEV: 
      Value: "dev"
    PRD:  
      Value: "prd"
  SystemName: 
    DEV: 
      Value: "clientvpnsampledev"
    PRD:  
      Value: "clientvpnsampleprd"
  SubnetId1: 
    DEV:  
      Value: "subnet-xxxxxxxxxxxxxxxxx"
    PRD:  
      Value: "subnet-xxxxxxxxxxxxxxxxx"
  SubnetId2: 
    DEV:  
      Value: "subnet-yyyyyyyyyyyyyyyyyy"
    PRD:  
      Value: "subnet-yyyyyyyyyyyyyyyyyy"

  TargetNetworkCidr:
    DEV:
      Access1: "xx.xx.xx.xx/xx"
      Access2: "yy.yy.yy.yy/yy"
    PRD:
      Access1: "xx.xx.xx.xx/xx"
      Access2: "yy.yy.yy.yy/yy"

  # 8:30-19:00(JST)
  # 起動に時間かかるため少し早めに起動
  ScheduleExpressionDisassciateClientVPN:
    DEV:  
      Value: "cron(0 10 * * ? *)"
    PRD:  
      Value: "cron(0 10 * * ? *)"
  ScheduleExpressionAssciateClientVPN:
    DEV:  
      Value: "cron(30 23 * * ? *)"
    PRD:  
      Value: "cron(30 23 * * ? *)"

Conditions:
  CreatePRDResources: !Equals [!Ref EnvironmentType, PRD]
  CreateMultiResources: !Equals [!Ref AvailabilityZoneType, Multi]
  CreatePRDMultiResources: !And
    - !Equals [!Ref EnvironmentType, PRD]
    - !Equals [!Ref AvailabilityZoneType, Multi]

Resources:

## IAM Role

  RoleLambdaClientVPNAssociate:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 
        - ${SystemName}-lambda-clientvpnassociate-${EnvironmentTypeLower}
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
          EnvironmentTypeLower: !FindInMap [EnvironmentTypeLower, !Ref EnvironmentType, Value]
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - "lambda.amazonaws.com"
            Action:
              - "sts:AssumeRole"
      Path: /
      Policies: 
        - PolicyName: "LambdaClientVPNAssociate"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              Effect: Allow
              Action:
                - "ec2:AssociateClientVpnTargetNetwork"
                - "ec2:DisassociateClientVpnTargetNetwork"
                - "ec2:DescribeClientVpnTargetNetworks"
                - "ec2:CreateClientVpnRoute"
                - "logs:CreateLogStream"
                - "logs:CreateLogGroup"
                - "logs:PutLogEvents"
              Resource: "*"

  RoleEvent:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 
        - ${SystemName}-event-clientvpnassociate-${EnvironmentTypeLower}
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
          EnvironmentTypeLower: !FindInMap [EnvironmentTypeLower, !Ref EnvironmentType, Value]
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - events.amazonaws.com
            Action:
              - "sts:AssumeRole"
      Path: /
      Policies: 
        - PolicyName: "EventClientVPNAssociate"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              Effect: Allow
              Action:
                - "lambda:InvokeFunction"
              Resource: "*"

## Lambda

  FunctionDisassociateClientVPN:
    Type: AWS::Lambda::Function
    Properties: 
      FunctionName: !Sub 
        - ${SystemName}-disassociate-clientvpn
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
      Runtime: "python3.9"
      Handler: "index.lambda_handler"
      Timeout: 60
      Code: 
        ZipFile: |
          import boto3, os
          
          def lambda_handler(event,context):
              ec2 = boto3.client('ec2')
              ClientVpnTargetNetworks = ec2.describe_client_vpn_target_networks(
                  ClientVpnEndpointId = os.environ['ClientVpnEndpointId']
              )['ClientVpnTargetNetworks']
          
              AssociationIds = [ClientVpnTargetNetwork.get('AssociationId') for ClientVpnTargetNetwork in ClientVpnTargetNetworks]
          
              for AssociationId in AssociationIds:
                  response = ec2.disassociate_client_vpn_target_network(
                      ClientVpnEndpointId = os.environ['ClientVpnEndpointId'],
                      AssociationId       = AssociationId
                  )
                  print(response)
      Environment:
        Variables:
          ClientVpnEndpointId: !Ref ClientVpnEndpoint
      Role: !GetAtt RoleLambdaClientVPNAssociate.Arn
      Tags: 
        - Key: "Environment"
          Value: !Ref EnvironmentType

  PermissionDisassociateClientVPN: 
    Type: AWS::Lambda::Permission
    Properties: 
      FunctionName: !Ref "FunctionDisassociateClientVPN"
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt RuleDisassociateClientVPN.Arn

  FunctionAssociateClientVPN:
    Type: AWS::Lambda::Function
    Properties: 
      FunctionName: !Sub 
        - ${SystemName}-associate-clientvpn
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
      Runtime: "python3.9"
      Handler: "index.lambda_handler"
      Timeout: 60
      Code: 
        ZipFile: |
          import boto3, os, botocore
          
          def lambda_handler(event, context):
              ec2 = boto3.client('ec2')
          
              SubnetIds = [x.strip() for x in str(os.environ['SubnetIds']).split(',')]
              DestinationCidrList = [x.strip() for x in str(os.environ['DestinationCidrList']).split(',')]
              
              for SubnetId in SubnetIds:
                try:
                  response = ec2.associate_client_vpn_target_network(
                      ClientVpnEndpointId = os.environ['ClientVpnEndpointId'],
                      SubnetId            = SubnetId
                  )
                  print(response)
                except botocore.exceptions.ClientError as error:
                  if error.response['Error']['Code'] == 'InvalidClientVpnDuplicateAssociation':
                     pass
                  else:
                    raise error

                for Cidr in DestinationCidrList:
                  try:
                    response = ec2.create_client_vpn_route(
                        ClientVpnEndpointId = os.environ['ClientVpnEndpointId'],
                        DestinationCidrBlock = Cidr,
                        TargetVpcSubnetId   = SubnetId
                    )
                    print(response)
                  except botocore.exceptions.ClientError as error:
                    if error.response['Error']['Code'] == 'InvalidClientVpnDuplicateRoute':
                      pass
                    else:
                      raise error
                      
      Environment:
        Variables:
          ClientVpnEndpointId: !Ref ClientVpnEndpoint
          SubnetIds: !If
            - CreateMultiResources
            - !Sub
              - ${SubnetId1},${SubnetId2}
              - SubnetId1: !FindInMap [SubnetId1, !Ref EnvironmentType, Value]
                SubnetId2: !FindInMap [SubnetId2, !Ref EnvironmentType, Value]
            - !FindInMap [SubnetId1, !Ref EnvironmentType, Value]
          DestinationCidrList: !If
            - CreatePRDResources
            - !Sub
              - ${Access1},${Access2}
              - Access1: !FindInMap [TargetNetworkCidr, !Ref EnvironmentType, Access1]
                Access2: !FindInMap [TargetNetworkCidr, !Ref EnvironmentType, Access2]
            - !Sub
              - ${Access1},${Access2}
              - Access1: !FindInMap [TargetNetworkCidr, !Ref EnvironmentType, Access1]
                Access2: !FindInMap [TargetNetworkCidr, !Ref EnvironmentType, Access2]
      Role: !GetAtt RoleLambdaClientVPNAssociate.Arn
      Tags: 
        - Key: "Environment"
          Value: !Ref EnvironmentType

  PermissionAssociateClientVPN: 
    Type: AWS::Lambda::Permission
    Properties: 
      FunctionName: !Ref "FunctionAssociateClientVPN"
      Action: "lambda:InvokeFunction"
      Principal: "events.amazonaws.com"
      SourceArn: !GetAtt RuleAssociateClientVPN.Arn


## EventBridge

  RuleDisassociateClientVPN:
    Type: AWS::Events::Rule
    Properties: 
      Name: !Sub 
        - ${SystemName}-disassociate-clientvpn
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
      RoleArn: !GetAtt RoleEvent.Arn
      ScheduleExpression: !FindInMap [ScheduleExpressionDisassciateClientVPN, !Ref EnvironmentType, Value]
      State: "ENABLED"
      Targets: 
        - Arn: !GetAtt FunctionDisassociateClientVPN.Arn
          Id: "lambda-disassociate-clientvpn"

  RuleAssociateClientVPN:
    Type: AWS::Events::Rule
    Properties: 
      Name: !Sub 
        - ${SystemName}-associate-clientvpn
        - SystemName: !FindInMap [SystemName, !Ref EnvironmentType, Value]
      RoleArn: !GetAtt RoleEvent.Arn
      ScheduleExpression: !FindInMap [ScheduleExpressionAssciateClientVPN, !Ref EnvironmentType, Value]
      State: "ENABLED"
      Targets: 
        - Arn: !GetAtt FunctionAssociateClientVPN.Arn
          Id: "lambda-associate-clientvpn"
 

②スプリットトンネルをあえて有効化しない。



AWS ClientVPNは接続中はすべての通信をトンネリングします。(=ClientVPN経由で通信)

この場合、閉塞網につなぎつつインターネットの情報にもアクセスするには、VPNで繋がった先(関連付けしたSubnet)からNATGateway→InternetGatewayに抜けるようにルーティングをしておくことになります。(緑矢印の経路)

しかしこの方法では全ての通信がVPN経由になるため、すべてAWSを経由してしまい通信コストがかさむことになります。

そこでスプリットトンネルのオプション設定が有効な手立てとなります。スプリットトンネルを有効化すると、閉塞網にはVPNを介してアクセスしつつ、インターネットへのルートはVPNを介さずに出ることが出来ます。(赤矢印の経路)

無駄にAWSを経由することもなく、利便性も高い構成が実現できます。

しかし、一方で利便性が高すぎるため、常時繋いでこまめに切断しない人が続出することが予見されます。

ClientVPNのセッションタイムアウト時間は8、10、12、24時間の中から設定できますが、最短でも8時間連続で接続できてしまうので、多くの人が利用する場合はこれもチリが積もれば山となります。

そこで、劇薬プランがあります。あえてスプリットトンネルは無効化し、AWS内のNATGateway→InternetGatewayを経由して抜けるルートも塞いでおくことで利便性を犠牲にしつつ、VPN接続中はインターネットアクセス不可にしてしまうというプランです。

スプリットトンネルは「SplitTunnel」プロパティで指定できます。セッションタイムアウト時間は「SessionTimeoutHours」プロパティで指定できます。

## ClientVPN

  ClientVpnEndpoint:
    Type: AWS::EC2::ClientVpnEndpoint
    Properties: 
             :
      SplitTunnel: false
      SessionTimeoutHours: 8
             :

ClientVPN経由でインターネットに抜ける経路の設定は

  • NATGatewayの設置

  • InternetGatewayの設置

  • clientVPNと関連付けしているサブネットに紐づくルートテーブルで「0.0.0.0/0」をNATGatewayにルーティング

  • clientVPNルートテーブルでも送信先「0.0.0.0/0」を追加

  • DNSを有効化し、Amazon Provided DNS(VPCの第4オクテットに2を指定したもの)を指定するか企業内のDNSサーバーを指定

といったことが必要になりますが、今回は塞ぐ話なので詳細は割愛します。


まとめ

 

AWS Client VPNのように立てているだけで利用に関係なく料金が発生するサービスでは、面倒ではありますが、今回紹介したようにこまめに利用を停止する施策も合わせて実施してコスト最適化を図りましょう。

閲覧数:96回0件のコメント

最新記事

すべて表示

Quick Sightの分析のクロスアカウントコピー

はじめに 今回のブログではQuick Sightで作成した分析をクロスアカウントコピーするための方法を紹介します。 開発環境で作成した分析を本番環境にコピーしたい時などにこの方法が使えるのではないかと思います。 2024/07/12追記...

Comments


bottom of page