💸

[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を必須化しているため、ここでは環境変数の存在チェックは行っていません。

参考

https://blog.serverworks.co.jp/cvpn_auto_association

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/associate_client_vpn_target_network.html

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/describe_client_vpn_target_networks.html

https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/disassociate_client_vpn_target_network.html

Discussion