節約術！？ AWS Client VPN を Lambda で定時に起動・停止してみた

# やったこと
AWS Client VPN を Lambda で定時に起動・停止するようにしました。

AWS Client VPN の料金は、下の引用の通り、Client VPN（エンドポイント）にサブネットを関連付けることで発生します。
関連付けを行ったままだと、夜間など利用しない時間帯にも料金が発生してしまうため、利用する時間帯だけサブネットの関連付けを行うようにしました。

> AWS Client VPN では、アクティブなクライアント接続の数に対して、
> および Client VPN に関連付けられているサブネットの数に対してそれぞれ 1 時間単位の料金が発生します。
>
> 引用元：[AWS Client VPN の料金](https://aws.amazon.com/jp/vpn/pricing/#AWS%20Client%20VPN%20%E3%81%AE%E6%96%99%E9%87%91)

# 前提
- サブネットの関連付けを解除すると VPN 接続ができなくなり、料金も発生しないため、「サブネットの関連付け ＝ AWS Client VPN の起動」、「サブネットの関連付け解除 ＝ AWS Client VPN の停止」と定義しています。

- AWS Client VPN の設定手順は、[こちらの記事(AWS Client VPN 設定メモ)](https://qiita.com/hmatsu47/items/ce0cc8b7f5747dbb3190)を参考にして行ったため割愛します。
（AWS CLI による設定手順がまとめられています。非常に参考になりました、ありがとうございます）

# 環境
- **クライアントPC**

 - Windows 10 バージョン 1803 (OSビルド 17134.829)
 - PowerShell バージョン 5.1.17134.765
 - AWS CLI バージョン 1.16.148
 - Python バージョン 2.7.15
 - pip バージョン 9.0.1
 - OpenVPN バージョン 2.4.7

- **AWS サービス**

 - AWS Client VPN
 - AWS IAM
 - AWS Lambda
 - AWS CloudWatch Events
 - AWS CloudWatch Logs 

# 設定手順

**手順に出てくる変数には、以下の例のように、予め値を設定してください。
AWS Client VPN 起動用/停止用で変数を使い分けるので、ご注意ください。**

```PowerShell:設定例
# 1. IAM の Lambda 実行ロールを作成する
$Region="ap-northeast-1"
$RoleName="client_vpn_admin_role"

# 2. Lambda 関数の作成
# 起動用 Lambda 関数作成時に設定
$FunctionName="start_client_vpn"
$handlerName="start_client_vpn_function.lambda_handler"

# 停止用 Lambda 関数作成時に設定
$FunctionName="stop_client_vpn"
$handlerName="stop_client_vpn_function.lambda_handler"

# 3. CloudWatch Events の作成
# 起動用 CloudWatch Events 作成時に設定
$RuleName="start_client_vpn"
$Schedule="cron(0 23 * * ? *)" # UTC 時間で設定

# 停止用 CloudWatch Eventss 作成時に設定
$Schedule="cron(0 15 * * ? *)"
$RuleName="stop_client_vpn" # UTC 時間で設定
```

## 1. IAM の Lambda 実行ロールを作成する
Lambda 関数は、IAM で作成されたロールを必要とし、ロールは関数を実行するのに必要なアクセス権限を提供します。
ここでは、ロールを作成し、Lambda 関数が「**Cloudwatch Logsへのログ出力**」と「**Client VPN エンドポイントに対するサブネット関連付け/関連付け解除**」を実行するのに必要なアクセス権限（ポリシー）をロールへアタッチします。

（予め、AWS CLI を実行するカレントディレクトリへ下に記載する role-policy.json を作成してください。）

```bash
# ロールの作成
aws iam create-role --role-name $RoleName --assume-role-policy-document file://role-policy.json

# ロールの確認
aws iam get-role --role-name $RoleName

# ポリシーのアタッチ
aws iam attach-role-policy --role-name $RoleName --policy-arn "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"

aws iam attach-role-policy --role-name $RoleName --policy-arn "arn:aws:iam::aws:policy/AmazonEC2FullAccess"

# アタッチしたポリシーの確認
aws iam list-attached-role-policies --role-name $RoleName
```

```json:role-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
       {
         "Action": "sts:AssumeRole",
         "Principal": {
           "Service": "lambda.amazonaws.com"
          },
          "Effect": "Allow",
          "Sid": ""
       }
    ]
}
```

## 2. Lambda 関数の作成
### 2.1 デプロイパッケージの作成
任意のディレクトリで、Lamda へデプロイするパッケージを作成します。
（参考：[Lambda上でAWSCLIを動かしてS3 Syncする](http://blog.serverworks.co.jp/tech/2019/04/11/lambda-aws-cli/)、[aws-cliでLambdaのScheduled Eventを作成する](https://qiita.com/chroju/items/aa65fddadbc18a2e717a)）

```bash
mkdir lambda-cli

cd lambda-cli

pip install awscli -t .
```

作成した lambda-cli ディレクトリに以下の Python スクリプト（ aws、start_client_vpn_function.py、stop_client_vpn_function.py ）を作成します。

```python:aws
import sys
import awscli.clidriver
 
 
def main():
    return awscli.clidriver.main()
 
 
if __name__ == '__main__':
    result = main()
    sys.exit(result)
```

```python:start_client_vpn_function.py
import subprocess


def lambda_handler(event, context):
    client_vpn_endpoint_id = "[あなたの Client VPN エンドポイント ID]"
    subnet_id_list = ["あなたの 関連付けるサブネット ID(カンマ区切りで複数記述可)"]
    
    for subnet_id in subnet_id_list :
        cmd = "python aws ec2 associate-client-vpn-target-network --client-vpn-endpoint-id " + client_vpn_endpoint_id + " --subnet-id " + subnet_id
        result = subprocess.run(
            cmd.split(" "),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT
            )
        print(result.stdout.decode())
```

```python:stop_client_vpn_function.py
import subprocess


def lambda_handler(event, context):
    client_vpn_endpoint_id = "[あなたの Client VPN エンドポイント ID]"
    cmd = "python aws ec2 describe-client-vpn-target-networks --client-vpn-endpoint-id " + client_vpn_endpoint_id + " --query ClientVpnTargetNetworks[].AssociationId --output text"
    
    result = subprocess.run(
        cmd.split(" "),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT
    )
    association_id_list = result.stdout.decode().strip().split()
    print("[DEBUG] association_id_list: %s" %  association_id_list)

    for association_id in association_id_list:
        cmd = "python aws ec2 disassociate-client-vpn-target-network --client-vpn-endpoint-id " + client_vpn_endpoint_id + " --association-id " + association_id

        result = subprocess.run(
        cmd.split(" "),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT
        )

        print(result.stdout.decode())
```

Python スクリプトを作成したら、lambda-cli ディレクトリをZIP圧縮します。

### 2.2 Lambda へのデプロイ

ZIPファイルを指定して、Lambda 関数を作成します。（bash で実行する場合は、改行文字を「`」→「\」に置換してください）

```bash
# ロール ARN の取得
$RoleArn=$(aws iam get-role --role-name $RoleName --query 'Role.Arn' --output text)

# Lambda 関数の作成
aws lambda create-function `
    --function-name $FunctionName `
    --runtime python3.7 `
    --role $RoleArn `
    --handler $handlerName `
    --timeout 120 `
    --zip-file fileb://lambda-cli.zip `
    --region $Region

# Lambda 関数の確認
aws lambda get-function --function-name $FunctionName

# 補足：Lambda 関数を更新
# update lambda function
aws lambda update-function-code `
    --function-name $FunctionName `
    --zip-file fileb://lambda-cli.zip `
    --publish
```

### 2.3 Lambda 関数のテスト実行
GUI でテスト実行をしてもよいですが、ここでは AWS CLI で実行します。

```bash
# Lambda 関数のテスト実行
aws lambda invoke --invocation-type Event `
    --function-name $FunctionName `
    --region $Region `
    --log-type Tail outfile.txt
```

CloudWatch Logs に次のようなログが出力されていれば、OK です。

```json:起動
{
"AssociationId": "cvpn-assoc-XXXXXXXXXXXXXXXX",
"Status": {
"Code": "associating"
}
}
```

```json:停止
{
"AssociationId": "cvpn-assoc-011618912b11f5840",
"Status": {
"Code": "disassociating"
}
}
```

# 3. CloudWatch Events の作成
Lambda 関数をスケジュール実行するため、トリガーとなる CloudWatch Events のルールを作成します。

```bash
# CloudWatch Events のルールの作成
aws events put-rule --name $RuleName --schedule-expression "$Schedule" --state ENABLED

# CloudWatch Events のルールの確認
aws events describe-rule --name $RuleName

# CloudWatch Events の ARN の取得
$EventArn=$(aws events describe-rule --name $RuleName --query Arn --output text)

# Lambda 関数へのトリガー追加
aws lambda add-permission `
--function-name $FunctionName `
--statement-id $FunctionName `
--action 'lambda:InvokeFunction' `
--principal events.amazonaws.com `
--source-arn $EventArn

# Lambda 関数のARNの取得
$FunctionArn=$(aws lambda get-function --function-name $FunctionName --query Configuration.FunctionArn --output text)

# CloudWatch Events へのターゲット追加
aws events put-targets --rule $RuleName --targets "Id=$FunctionName,Arn=$FunctionArn"

# CloudWatch Events のターゲットの確認
aws events list-targets-by-rule --rule $RuleName
```

以上で設定は完了です。お疲れ様でした。

# 最後に

Client VPN を利用すると、踏み台サーバなどを経由せずに直接 VPC 内のリソースにアクセスできるので運用が非常に楽です。

踏み台サーバ + EIP/NAT Gateway 構成とのコスト比較をすると確かに高いですが、利用しない時間帯はサブネットの関連付け解除をする運用で大分コストを抑えられると思います。

最後までお読みいただきまして、ありがとうございました。