👻
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 の設定方法についての記事 も記載しているので、お困りの方に届けば幸いです。
Discussion