【With Step-by-Step Images】Minimize AWS Client VPN Subnet Association Costs

Minimize AWS Client VPN Subnet Association Costs
  • AWS Client VPN incurs costs even when subnets are associated but unused, which leads to unnecessary expenses if 24/7 operation is not required.
  • Our company has streamlined operations by automatically disassociating subnets via CloudWatch alarms or associating them on demand using ALB.
  • In this article, we will introduce a cost-saving example with an actual configuration that is easy to implement!

Background

To promote remote work, we provide Amazon WorkSpaces to employees and distribute iPads to enable them to work from anywhere. When working remotely, employees connect to Amazon WorkSpaces via iPads.

However, an issue arose where the arrow keys would not function properly while working on Amazon WorkSpaces using iPads. Although this issue remains unresolved, we have temporarily implemented an alternative structure using AWS Client VPN.
For details on this structure, refer to the following article:

Currently, subnets are always associated with the AWS Client VPN endpoint, incurring continuous costs.
Since AWS Client VPN is unnecessary for connections from devices other than iPads, a solution was needed to reduce costs by associating subnets only when required.

Solution: Implementing Subnet Associations

We decided to have employees associate subnets themselves whenever AWS Client VPN is needed.
However, not all employees are technically adept enough to use the management console or AWS CLI commands for association.
To address this, we developed a system where employees can perform the association easily via a browser.

Here’s how it works:
Employees access ALB through a browser. The ALB’s target is a Lambda function, which executes the subnet association for the AWS Client VPN endpoint.
To ensure only employees can perform this action, Amazon Cognito authentication is configured for ALB access.
This method eliminates complex procedures, requiring only the distribution of a domain name to employees.

The configuration is as follows:

This configuration was already introduced in another article. For detailed explanations, please refer to the article below:

Solution: Subnet Disassociation

To handle subnet disassociation, we implemented an automated mechanism.
Specifically, the Lambda function executed during Solution: Subnet Association is monitored using a CloudWatch alarm.
If no client connections to the Client VPN endpoint are detected for a specified period after the alarm triggers, another Lambda function is triggered to disassociate the subnet.

Although it's not a complex configuration, the conceptual image is as follows:

Steps for Administrators

For this section, we assume that the Lambda function for subnet association and the Client VPN endpoint have already been created.

Creating the Lambda Function

Create a Lambda function to disassociate subnets from the Client VPN endpoint.

  1. Execute the following AWS CLI command to create an IAM role for the Lambda function:
# 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
  1. Create a Lambda function using the following URL:
    https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/create/function
  2. Use the following settings for the function:
    • Function name: AWSClientVPN-DisassociatingSubnets
    • Runtime: Python 3.13
    • Execution role: LambdaRole-ClientVPN
Image
  1. Update the timeout duration to 1 minute using the following URL:
    https://us-east-1.console.aws.amazon.com/lambda/home?region=us-east-1#/functions/AWSClientVPN-DisassociatingSubnets/edit/basic-settings?tab=configure
    • Timeout: 1 min
Image
  1. Add the code for the Lambda function.
    Once the code is entered, it must be deployed by clicking Deploy or using the shortcut Ctrl+Shift+U.
    Most of the code was generated using ChatGPT, and the following is the code:
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)
            })
        }

Creating the CloudWatch Alarm

  1. The CloudWatch alarm can be set up with the required metrics using the URL below. This URL allows you to create a CloudWatch alarm that triggers the Lambda function:
    https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-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*3aus-east-1*3a<Your AWS account ID>*3afunction*3aAWSClientVPN-DisassociatingSubnets)~InsufficientDataActions~(~)~OKActions~(~))~AlarmRecommendation~false~MultiTimeSeries~false)
    • Replace the placeholders in the URL with your region and resource IDs as needed:
      • <Your Client VPN endpoint ID> → The ID of your Client VPN endpoint
      • <Your AWS account ID> → Your AWS account ID
    • Description: This CloudWatch alarm triggers the disassociation Lambda function if the number of active connections to the Client VPN endpoint remains at 0 for approximately 30 minutes after the association Lambda function is triggered.
    • Metrics Monitored: Total number of executions of the associated Lambda function and number of connections to the Client VPN endpoint
    • Alarm Conditions: If the monitored metrics fall below 1 for six data points within 30 minutes, the alarm triggers.
    • Alarm Source: The alarm can also be created using the following AWS CLI command:
AWS CLI Commnad
ACCOUNT_ID="<Your AWS account  ID>"

aws cloudwatch put-metric-alarm \
    --alarm-name 'AWSClientVPN-DisassociatingSubnets' \
    --actions-enabled \
    --alarm-actions 'arn:aws:lambda:us-east-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"}}]'
Image

Adding a Resource-Based Policy to the Lambda Function

  1. Execute the following AWS CLI commands to add a resource-based policy to the Lambda function, allowing it to be triggered by the CloudWatch alarm.
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

By using this setup, the Lambda function is triggered when there are no connections for a certain period, automatically disassociating the subnet from the AWS Client VPN endpoint.

By combining Solution: Subnet Association and Solution: Subnet Disassociation, subnet associations are minimized, leading to further cost optimization.

We hope this example proves helpful to others facing similar use cases.

Copied title and URL