始めに
以前、「 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のように立てているだけで利用に関係なく料金が発生するサービスでは、面倒ではありますが、今回紹介したようにこまめに利用を停止する施策も合わせて実施してコスト最適化を図りましょう。
Comments