
背景
弊社ではリモートワーク推進のため、社員に Amazon WorkSpaces を配布しています。
また、外出先でも作業ができるように iPad を配布しています。
外出先から作業をする場合には、iPad で Amazon WorkSpaces に接続し、作業を行うこととしています。
ただ、iPad で Amazon WorkSpaces を作業するときに矢印キーが動作しない事象が発生しました。
現在でも解消しておらず、ひとまずは、AWS Client VPN を使用した代替構成で運用しています。
こちらについては以下の記事をご参照ください。
現在では、AWS Client VPN エンドポイントのサブネット関連付けを常にしており、常に費用が発生しています。
iPad 以外の端末からの接続では AWS Client VPN は不要であり、常に AWS Client VPN が必要となるわけではありません。
必要なときにのみ関連付けを実施することで、コストを削減する方法を模索することとなりました。
方法:関連付けの実施
サブネットの関連付けについては、AWS Client VPN が必要になったらその都度、社員自身で実施してもらう運用としました。
ただ、マネジメントコンソールや AWS CLI コマンドでの関連付け方法を周知し、実際にしてもらうのは技術的に困難な社員もいます。
そのため、ブラウザなどから簡単に関連付けを実施できる仕組みを用意しました。
具体的には、社員にはブラウザから ALB にアクセスしてもらいます。
ALB のターゲットに Lambda 関数を登録し、この Lambda 関数が AWS Client VPN エンドポイントの関連付けを実行します。
また、社員のみが実施できるように、ALB へのアクセスには Amazon Cognito の認証を設定します。
この方法であれば、複雑な手順などは必要なく、社員にはドメイン名を配布するのみで良くなります。
構成としては以下です。

実は、こちらの構成はすでに別の記事で紹介した内容となります。
詳細につきましては、以下の記事をご参照ください。
方法:関連付けの解除
関連付けの解除については自動で実施される仕組みを用意しました。
具体的には、方法:関連付けの実施 で実行される Lambda 関数を CloudWatch アラームで検知します。
検知後、Client VPN エンドポイントへ接続するクライアントが一定期間確認できない場合には、関連付けを解除する Lambda 関数をトリガーする仕組みとしました。
構成というほどではありませんが、イメージ図としては以下となります。

管理者側の手順
ここではすでに、サブネットの関連付け用の Lambda 関数と、Client VPN エンドポイントの作成はされているものとします。
Lambda 関数の作成
Client VPN エンドポイントのサブネットの関連付けを解除する Lambda 関数を作成します。
- 以下の AWS CLI コマンドを実行し、Lambda 関数用の IAM ロールを作成します。
# Create IAM role
aws iam create-role \
--role-name LambdaRole-ClientVPN \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}'
# Attach the IAM policy required for Client VPN
aws iam attach-role-policy \
--role-name LambdaRole-ClientVPN \
--policy-arn arn:aws:iam::aws:policy/AmazonEC2FullAccess
# Attach the IAM policy required for logging
aws iam attach-role-policy \
--role-name LambdaRole-ClientVPN \
--policy-arn arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
- 以下の URL から Lambda 関数を作成します。
https://ap-northeast-1.console.aws.amazon.com/lambda/home?region=ap-northeast-1#/create/function - 以下の設定で作成します。
- 関数名:AWSClientVPN-DisassociatingSubnets
- ランタイム:Python 3.13
- 実行ロール:LambdaRole-ClientVPN
画像

- 以下の URL からタイムアウトの時間を1分に変更します。
https://ap-northeast-1.console.aws.amazon.com/lambda/home?region=ap-northeast-1#/functions/AWSClientVPN-DisassociatingSubnets/edit/basic-settings?tab=configure- タイムアウト:1分
画像

- Lambda 関数のコードを記入していきます。
コード記入後、Deploy (Ctrl+Shift+U) をする必要があります。
ほとんど Chat GPT に書かせましたがコードは以下です。
import boto3
import os
import json
def lambda_handler(event, context):
# Initialize the EC2 client
ec2 = boto3.client('ec2')
# Input parameters
client_vpn_endpoint_id = '<Your Client VPN endpoint ID>'
try:
# Describe the Client VPN target network associations
associations_response = ec2.describe_client_vpn_target_networks(
ClientVpnEndpointId=client_vpn_endpoint_id
)
# Collect all Association IDs
association_ids = [association['AssociationId'] for association in associations_response['ClientVpnTargetNetworks']]
if not association_ids:
return {
'statusCode': 404,
'body': json.dumps({
'Message': 'No associations found for the given Client VPN endpoint.',
'ClientVpnEndpointId': client_vpn_endpoint_id
})
}
# Loop through and disassociate each target network
results = []
for association_id in association_ids:
try:
response = ec2.disassociate_client_vpn_target_network(
ClientVpnEndpointId=client_vpn_endpoint_id,
AssociationId=association_id
)
results.append({
'AssociationId': association_id,
'Status': response['Status']
})
except Exception as e:
results.append({
'AssociationId': association_id,
'Error': str(e)
})
# Return response details
return {
'statusCode': 200,
'body': json.dumps({
'Message': 'Processed all associations for disassociation.',
'Results': results
})
}
except Exception as e:
print(f"Error disassociating Client VPN endpoint: {str(e)}")
return {
'statusCode': 500,
'body': json.dumps({
'Message': 'Failed to process disassociations for Client VPN endpoint.',
'Error': str(e)
})
}
CloudWatch アラームの作成
- 以下の URL にて、メトリクスなどが指定された状態で CloudWatch アラームの準備が可能です。この URL から、Lambda 関数をトリガーする CloudWatch アラームを作成します。
https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#alarmsV2:create?~(Page~'Preview~AlarmType~'MetricAlarm~AlarmData~(Metrics~(~(Id~'e1~Expression~'m1*2bm2~ReturnData~true~Label~'UsingClientVPN)~(MetricStat~(Metric~(MetricName~'Invocations~Namespace~'AWS*2fLambda~Dimensions~(~(Name~'FunctionName~Value~'AWSClientVPN-AssociatingSubnets)))~Period~300~Stat~'Average)~Id~'m1~ReturnData~false)~(MetricStat~(Metric~(MetricName~'ActiveConnectionsCount~Namespace~'AWS*2fClientVPN~Dimensions~(~(Name~'Endpoint~Value~'<Your Client VPN endpoint ID>)))~Period~300~Stat~'Average)~Id~'m2~ReturnData~false))~AlarmName~'AWSClientVPN-DisassociatingSubnets~AlarmDescription~'~ActionsEnabled~true~ComparisonOperator~'LessThanThreshold~Threshold~1~DatapointsToAlarm~6~EvaluationPeriods~6~TreatMissingData~'missing~AlarmActions~(~'arn*3aaws*3alambda*3aap-northeast-1*3a<Your AWS account ID>*3afunction*3aAWSClientVPN-DisassociatingSubnets)~InsufficientDataActions~(~)~OKActions~(~))~AlarmRecommendation~false~MultiTimeSeries~false)- 適宜以下の部分をご自身のリージョンとリソース ID に置き換えてください。
- <Your Client VPN endpoint ID> → Client VPN エンドポイントの ID
- <Your AWS account ID> → AWS アカウント ID
- 説明:この CloudWatch アラームでは、関連付け用の Lambda 関数が起動された後に、Client VPN エンドポイントへの接続数が 30 分間程度 0 であった場合に、関連付け解除用の Lambda 関数がトリガーされます。
- 監視対象のメトリクス:関連付け Lambda 関数の実行回数と Client VPN エンドポイントへの接続数の合計値
- アラーム条件:監視対象のメトリクスについて、30 分内の 6 データポイントで 1 未満であること
- アラームソース:以下の AWS CLI コマンドでも作成が可能です。
- 適宜以下の部分をご自身のリージョンとリソース ID に置き換えてください。
AWS CLI コマンド
ACCOUNT_ID="<Your AWS account ID>"
aws cloudwatch put-metric-alarm \
--alarm-name 'AWSClientVPN-DisassociatingSubnets' \
--actions-enabled \
--alarm-actions 'arn:aws:lambda:ap-northeast-1:$ACCOUNT_ID:function:AWSClientVPN-DisassociatingSubnets' \
--evaluation-periods 6 \
--datapoints-to-alarm 6 \
--threshold 1 \
--comparison-operator 'LessThanThreshold' \
--treat-missing-data 'missing' \
--metrics '[{"Id":"e1","Label":"UsingClientVPN","ReturnData":true,"Expression":"m1+m2"},{"Id":"m1","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"AWS/ClientVPN","MetricName":"ActiveConnectionsCount","Dimensions":[{"Name":"Endpoint","Value":"cvpn-endpoint-01234example56789"}]},"Period":300,"Stat":"Average"}},{"Id":"m2","ReturnData":false,"MetricStat":{"Metric":{"Namespace":"AWS/Lambda","MetricName":"Invocations","Dimensions":[{"Name":"FunctionName","Value":"AWSClientVPN-AssociatingSubnets"}]},"Period":300,"Stat":"Average"}}]'
画像

Lambda 関数にリソースベースのポリシーを追加
- 以下の AWS CLI コマンドを実行し、Lambda 関数に、CloudWatch アラームからの実行を許可するリソースベースのポリシーを追加します。
LAMBDA_FUNCTION_NAME="AWSClientVPN-DisassociatingSubnets"
# Get the ARN of CloudWatch alarm
CLOUDWATCH_ALARM_ARN=$(aws cloudwatch describe-alarms \
--alarm-names "AWSClientVPN-DisassociatingSubnets" \
--query "MetricAlarms[0].AlarmArn" \
--output text)
# Add permission for the CloudWatch alarm to invoke the Lambda function
aws lambda add-permission \
--function-name $LAMBDA_FUNCTION_NAME \
--statement-id "AWS-CW_Invoke-alarm" \
--action lambda:InvokeFunction \
--principal lambda.alarms.cloudwatch.amazonaws.com \
--source-arn $CLOUDWATCH_ALARM_ARN
この運用により、一定時間接続がない時に、Lambda 関数がトリガーされ AWS Client VPN エンドポイントの関連付けが解除されます。
方法:関連付けの実施 と合わせることで、サブネットの関連付けを必要最小限にすることができ、よりコストを最適化できました。
同じユースケースを持つ方の参考となれば幸いです。