💸
[Terraform] ClientVPN自動紐づけ+解除をEventBridge Scheduler+Lambdaで実装するモジュール
Terraform管理しているAWS Client VPNのアソシエーションをEventBridge Scheduler+Lambdaを使って自動的に紐づけ及び解除する機能を追加するTerraformのModuleのコード例です。
VPN利用者の業務時間外ではアソシエーションを解除しておくようスケジュール設定することでClient VPNの高い時間課金を削減可能です。
概要
- AWS Schedulerを使用してJSTでvpn endpointのアタッチ、デタッチそれぞれスケジュールを設定でき、平日の業務時間に合わせた自動化が可能。
- CloudWatch Logsでの実行ログの閲覧が可能。
全体構成
Terraform Code
ディレクトリ構成
├── client_vpn_auto_association
│ ├── iam.tf
│ ├── lambda_functions
│ │ ├── attach.py
│ │ └── detach.py
│ ├── main.tf
│ └── variables.tf
モジュール使用例
# Client VPN Auto Association
module "client_vpn_auto_association" {
source = "../../modules/client_vpn_auto_association"
# User input variable
service = var.service
environment = var.environment
# Module variable
client_vpn_endpoint = module.client_vpn.endpoint
network_association = module.client_vpn.network_association
# Schedule configuration (JST)
attach_schedule_expression = "cron(0 9 ? * MON-FRI *)" # JST 9:00 (平日)
detach_schedule_expression = "cron(0 18 ? * MON-FRI *)" # JST 18:00 (平日)
}
変数名 | 型 | 説明 |
---|---|---|
service |
string | サービス名 |
environment |
string | 環境名 (e.g., stg, prod) |
client_vpn_endpoint |
object | Client VPNエンドポイントオブジェクト |
network_association |
object | Client VPNネットワークアソシエーションオブジェクト |
attach_schedule_expression |
string | アタッチのスケジュール式 (JST, e.g., 'cron(0 9 ? * MON-FRI *)') |
detach_schedule_expression |
string | デタッチのスケジュール式 (JST, e.g., 'cron(0 18 ? * MON-FRI *)') |
variables.tf
# 基本設定
variable "service" {
description = "サービス名"
type = string
}
variable "environment" {
description = "環境名 (e.g., stg, prod)"
type = string
}
# Client VPN 設定
variable "client_vpn_endpoint" {
description = "Client VPN エンドポイントオブジェクト"
}
variable "network_association" {
description = "Client VPN ネットワークアソシエーションオブジェクト"
}
# スケジュール設定 (JST)
variable "attach_schedule_expression" {
description = "アタッチのスケジュール式 (JST, e.g., 'cron(0 9 ? * MON-FRI *)')"
type = string
}
variable "detach_schedule_expression" {
description = "デタッチのスケジュール式 (JST, e.g., 'cron(0 18 ? * MON-FRI *)')"
type = string
}
main.tf
# Client VPN Auto Association Module
# Client VPN のターゲットネットワーク(サブネット)を
# スケジュールに基づいて自動的にアタッチ/デタッチする
# Lambda 関数用の ZIP ファイルを作成 - アタッチ用
data "archive_file" "lambda_attach_zip" {
type = "zip"
source_file = "${path.module}/lambda_functions/attach.py"
output_path = "${path.module}/lambda_attach.zip"
}
# Lambda 関数用の ZIP ファイルを作成 - デタッチ用
data "archive_file" "lambda_detach_zip" {
type = "zip"
source_file = "${path.module}/lambda_functions/detach.py"
output_path = "${path.module}/lambda_detach.zip"
}
# CloudWatch Logs グループ
resource "aws_cloudwatch_log_group" "this" {
name = "/aws/lambda/${var.service}-${var.environment}-client-vpn-auto-association"
retention_in_days = 7
}
# Lambda 関数 - アタッチ用
resource "aws_lambda_function" "attach" {
filename = data.archive_file.lambda_attach_zip.output_path
function_name = "${var.service}-${var.environment}-cvpn-attach"
role = aws_iam_role.lambda.arn
handler = "attach.lambda_handler"
source_code_hash = data.archive_file.lambda_attach_zip.output_base64sha256
runtime = "python3.13"
timeout = 60
memory_size = 128
environment {
variables = {
CLIENT_VPN_ENDPOINT_ID = var.client_vpn_endpoint.id
SUBNET_ID = var.network_association.subnet_id
}
}
logging_config {
log_format = "JSON"
log_group = aws_cloudwatch_log_group.this.name
}
depends_on = [
aws_iam_role_policy_attachment.lambda_basic,
aws_iam_role_policy_attachment.lambda_cvpn,
aws_cloudwatch_log_group.this
]
}
# Lambda 関数 - デタッチ用
resource "aws_lambda_function" "detach" {
filename = data.archive_file.lambda_detach_zip.output_path
function_name = "${var.service}-${var.environment}-cvpn-detach"
role = aws_iam_role.lambda.arn
handler = "detach.lambda_handler"
source_code_hash = data.archive_file.lambda_detach_zip.output_base64sha256
runtime = "python3.13"
timeout = 60
memory_size = 128
environment {
variables = {
CLIENT_VPN_ENDPOINT_ID = var.client_vpn_endpoint.id
SUBNET_ID = var.network_association.subnet_id
}
}
logging_config {
log_format = "JSON"
log_group = aws_cloudwatch_log_group.this.name
}
depends_on = [
aws_iam_role_policy_attachment.lambda_basic,
aws_iam_role_policy_attachment.lambda_cvpn,
aws_cloudwatch_log_group.this
]
}
# Scheduler - アタッチ用
resource "aws_scheduler_schedule" "attach" {
name = "${var.service}-${var.environment}-cvpn-attach-schedule"
flexible_time_window {
mode = "OFF"
}
schedule_expression = var.attach_schedule_expression
schedule_expression_timezone = "Asia/Tokyo"
target {
arn = aws_lambda_function.attach.arn
role_arn = aws_iam_role.scheduler.arn
}
}
# Scheduler - デタッチ用
resource "aws_scheduler_schedule" "detach" {
name = "${var.service}-${var.environment}-cvpn-detach-schedule"
flexible_time_window {
mode = "OFF"
}
schedule_expression = var.detach_schedule_expression
schedule_expression_timezone = "Asia/Tokyo"
target {
arn = aws_lambda_function.detach.arn
role_arn = aws_iam_role.scheduler.arn
}
}
リソース名 | タイプ | 説明 |
---|---|---|
aws_lambda_function.attach |
Lambda関数 | アタッチ用Lambda関数 |
aws_lambda_function.detach |
Lambda関数 | デタッチ用Lambda関数 |
aws_scheduler_schedule.attach |
Scheduler | アタッチ用スケジューラー |
aws_scheduler_schedule.detach |
Scheduler | デタッチ用スケジューラー |
aws_cloudwatch_log_group.this |
CloudWatch Logs | ロググループ |
variableで受け取ったclient vpn endpoint及びnetwork_associationからboto3のattach api、detach apiを呼び出すのに必要な情報を環境変数としてLambda関数に渡しています。
iam.tf
# Data sources for ARN construction
data "aws_region" "current" {}
data "aws_caller_identity" "current" {}
# IAM Role for Lambda execution
data "aws_iam_policy_document" "lambda_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "lambda" {
name = "${var.service}-${var.environment}-cvpn-auto-association-lambda"
assume_role_policy = data.aws_iam_policy_document.lambda_assume_role.json
}
# CloudWatch Logs への書き込み権限
data "aws_iam_policy_document" "lambda_logging" {
statement {
effect = "Allow"
actions = [
"logs:CreateLogStream",
"logs:PutLogEvents"
]
resources = [
"${aws_cloudwatch_log_group.this.arn}:*"
]
}
}
resource "aws_iam_policy" "lambda_logging" {
name = "${var.service}-${var.environment}-cvpn-auto-association-logging"
path = "/"
description = "IAM policy for logging from Client VPN auto association Lambda"
policy = data.aws_iam_policy_document.lambda_logging.json
}
resource "aws_iam_role_policy_attachment" "lambda_basic" {
role = aws_iam_role.lambda.name
policy_arn = aws_iam_policy.lambda_logging.arn
}
# Client VPN の操作権限
data "aws_iam_policy_document" "lambda_cvpn" {
# Associate/Disassociate 操作は特定の Client VPN Endpoint に限定
statement {
effect = "Allow"
actions = [
"ec2:AssociateClientVpnTargetNetwork",
"ec2:DisassociateClientVpnTargetNetwork"
]
resources = [
var.client_vpn_endpoint.arn,
"arn:aws:ec2:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:subnet/${var.network_association.subnet_id}"
]
}
# Describe 操作は読み取り専用のため広範囲を許可
statement {
effect = "Allow"
actions = [
"ec2:DescribeClientVpnTargetNetworks",
"ec2:DescribeClientVpnEndpoints"
]
resources = ["*"]
}
}
resource "aws_iam_policy" "lambda_cvpn" {
name = "${var.service}-${var.environment}-cvpn-auto-association-cvpn"
path = "/"
description = "IAM policy for Client VPN operations from auto association Lambda"
policy = data.aws_iam_policy_document.lambda_cvpn.json
}
resource "aws_iam_role_policy_attachment" "lambda_cvpn" {
role = aws_iam_role.lambda.name
policy_arn = aws_iam_policy.lambda_cvpn.arn
}
# IAM Role for Scheduler
data "aws_iam_policy_document" "scheduler_assume_role" {
statement {
effect = "Allow"
principals {
type = "Service"
identifiers = ["scheduler.amazonaws.com"]
}
actions = ["sts:AssumeRole"]
}
}
resource "aws_iam_role" "scheduler" {
name = "${var.service}-${var.environment}-cvpn-auto-association-scheduler"
assume_role_policy = data.aws_iam_policy_document.scheduler_assume_role.json
}
# Scheduler から Lambda を呼び出す権限
data "aws_iam_policy_document" "scheduler_invoke_lambda" {
statement {
effect = "Allow"
actions = ["lambda:InvokeFunction"]
resources = [
aws_lambda_function.attach.arn,
aws_lambda_function.detach.arn
]
}
}
resource "aws_iam_policy" "scheduler_invoke_lambda" {
name = "${var.service}-${var.environment}-cvpn-auto-association-scheduler-invoke"
path = "/"
description = "IAM policy for Scheduler to invoke Lambda functions"
policy = data.aws_iam_policy_document.scheduler_invoke_lambda.json
}
resource "aws_iam_role_policy_attachment" "scheduler_invoke_lambda" {
role = aws_iam_role.scheduler.name
policy_arn = aws_iam_policy.scheduler_invoke_lambda.arn
}
リソース名 | タイプ | 説明 |
---|---|---|
aws_iam_role.lambda |
IAMロール | Lambda実行用IAMロール |
aws_iam_role.scheduler |
IAMロール | Scheduler用IAMロール |
aws_iam_policy.lambda_logging |
IAMポリシー | CloudWatch Logs書き込み権限 |
aws_iam_policy.lambda_cvpn |
IAMポリシー | Client VPN操作権限 |
aws_iam_policy.scheduler_invoke_lambda |
IAMポリシー | Lambda呼び出し権限 |
可読性のため、iam関連リソースのみこちらのファイルに分離しています。
lambda_functions/attach.py
ネットワークアソシエーションの作成をするLambda関数のコードです。
import json
import os
import boto3
import logging
# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# EC2 クライアントの初期化
ec2_client = boto3.client('ec2')
def lambda_handler(event, context):
"""
Client VPN のターゲットネットワーク(サブネット)を
スケジュールに基づいてアタッチする Lambda 関数
環境変数:
CLIENT_VPN_ENDPOINT_ID: Client VPN エンドポイント ID
SUBNET_ID: アタッチするサブネット ID
"""
# 環境変数から設定を取得
client_vpn_endpoint_id = os.environ.get('CLIENT_VPN_ENDPOINT_ID')
subnet_id = os.environ.get('SUBNET_ID')
logger.info(f"Client VPN Endpoint: {client_vpn_endpoint_id}, Subnet: {subnet_id}")
try:
# サブネットをアタッチ
response = attach_subnet(client_vpn_endpoint_id, subnet_id)
return {
'statusCode': 200,
'body': json.dumps(f'Successfully started attaching subnet {subnet_id} to {client_vpn_endpoint_id}')
}
except Exception as e:
logger.error(f"エラーが発生しました: {str(e)}")
return {
'statusCode': 500,
'body': json.dumps(f'Error: {str(e)}')
}
def attach_subnet(client_vpn_endpoint_id, subnet_id):
"""
Client VPN エンドポイントにサブネットをアタッチする
Args:
client_vpn_endpoint_id: Client VPN エンドポイント ID
subnet_id: アタッチするサブネット ID
Returns:
API レスポンス
"""
# 既に関連付けられているか確認
associations = ec2_client.describe_client_vpn_target_networks(
ClientVpnEndpointId=client_vpn_endpoint_id
)
# 同じサブネットが既にアタッチされているか確認
for association in associations.get('ClientVpnTargetNetworks', []):
if association['TargetNetworkId'] == subnet_id:
status = association['Status']['Code']
if status in ['associating', 'associated']:
logger.info(f"サブネット {subnet_id} は既にアタッチされています (status: {status})")
return {'AlreadyAttached': True}
# サブネットをアタッチ
logger.info(f"サブネット {subnet_id} をアタッチします...")
response = ec2_client.associate_client_vpn_target_network(
ClientVpnEndpointId=client_vpn_endpoint_id,
SubnetId=subnet_id
)
association_id = response['AssociationId']
status = response['Status']['Code']
logger.info(f"アタッチを開始しました。Association ID: {association_id}, Status: {status}")
return response
Terraformのvariablesを必須化しているため、ここでは環境変数の存在チェックは行っていません。
lambda_functions/detach.py
ネットワークアソシエーションの解除をするLambda関数のコードです。
import json
import os
import boto3
import logging
# ロガーの設定
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# EC2 クライアントの初期化
ec2_client = boto3.client('ec2')
def lambda_handler(event, context):
"""
Client VPN のターゲットネットワーク(サブネット)を
スケジュールに基づいてデタッチする Lambda 関数
環境変数:
CLIENT_VPN_ENDPOINT_ID: Client VPN エンドポイント ID
SUBNET_ID: デタッチするサブネット ID
"""
# 環境変数から設定を取得
client_vpn_endpoint_id = os.environ.get('CLIENT_VPN_ENDPOINT_ID')
subnet_id = os.environ.get('SUBNET_ID')
logger.info(f"Client VPN Endpoint: {client_vpn_endpoint_id}, Subnet: {subnet_id}")
try:
# サブネットをデタッチ
response = detach_subnet(client_vpn_endpoint_id, subnet_id)
return {
'statusCode': 200,
'body': json.dumps(f'Successfully started detaching subnet {subnet_id} from {client_vpn_endpoint_id}')
}
except Exception as e:
logger.error(f"エラーが発生しました: {str(e)}")
return {
'statusCode': 500,
'body': json.dumps(f'Error: {str(e)}')
}
def detach_subnet(client_vpn_endpoint_id, subnet_id):
"""
Client VPN エンドポイントからサブネットをデタッチする
Args:
client_vpn_endpoint_id: Client VPN エンドポイント ID
subnet_id: デタッチするサブネット ID
Returns:
API レスポンス
"""
# 現在の関連付けを取得
associations = ec2_client.describe_client_vpn_target_networks(
ClientVpnEndpointId=client_vpn_endpoint_id
)
# 対象のサブネットの Association ID を見つける
association_id = None
for association in associations.get('ClientVpnTargetNetworks', []):
if association['TargetNetworkId'] == subnet_id:
association_id = association['AssociationId']
status = association['Status']['Code']
logger.info(f"サブネット {subnet_id} の Association ID: {association_id}, Status: {status}")
break
if not association_id:
logger.warning(f"サブネット {subnet_id} はアタッチされていません")
return {'NotAttached': True}
# サブネットをデタッチ
logger.info(f"サブネット {subnet_id} をデタッチします (Association ID: {association_id})...")
response = ec2_client.disassociate_client_vpn_target_network(
ClientVpnEndpointId=client_vpn_endpoint_id,
AssociationId=association_id
)
status = response['Status']['Code']
logger.info(f"デタッチを開始しました。Status: {status}")
return response
こちらも同様にTerraformのvariablesを必須化しているため、ここでは環境変数の存在チェックは行っていません。
参考
Discussion