使うときだけ EC2 インスタンスを立ち上げたい

# はじめに

本番環境ならいざしらず、開発用の環境であったり、踏み台サーバーについては必要な時だけ立ち上げることで費用を抑えたいというのは当然のことでしょう。AWS にはリザーブドインスタンスや節約プランを使うことで費用を抑える仕組みもありますが、一般的な使い方では３割程度の割引率にしかならないため、平日日中だけしか利用しない場合には、随時起動/停止を行う方が安上がりです。

このエントリでは、私が考えた「使うときだけ EC2 インスタンスを起動する方法」を説明します。

[2020/4/28 追記] セッション数を SSH のプロセス数から取得するよう変更しました。これにより、VS Code から接続するとセッションが切れないという問題も解消しました。

# 方針

ここ最近、Linux であろうが Windows であろうが SSH 経由で接続することができるようになってきました。[^1]

[^1]: Windows 2019 からは OpenSSH が OS 機能として用意されています。SSH サーバーをインストールしておけば、リモートデスクトップ接続も SSH のポートフォワーディング経由で利用することができます。

そこで「SSH で接続すると起動して、SSH を切断すると停止する」ような仕組みを考えます。昨年から Session Manager を使った SSH トンネリングが利用できるようになったので、これを使い次のような仕掛けを行うことにしました。

1. （クライアント環境）SSH の ProxyCommand で、トンネリングのついでにインスタンスの起動スクリプトを実行する
2. （サーバー環境）EC2 インスタンスの中で Session Manager の接続状況を cron で監視して、接続がないときは停止する

以降では具体的なスクリプトを紹介します。なお、私は Windows ユーザーなので、クライアント環境のスクリプトはバッチファイルとなっています。クライアント環境として MacOS や Linux をお使いの方は、シェルスクリプトに読み替えて頂ければ幸いです。

# 事前準備

クライアント環境ではあらかじめ次のことを行っておきます。
- OpenSSH クライアントのインストール
- AWS CLI のインストールとプロファイル設定
- Session Manager Plugin のインストール

サーバー環境でもあらかじめ次のことを行っておきます。
- AWS CLI のリージョン設定
- SSM エージェントのインストール[^ssm_config]
- EC2 インスタンスの IAM ロールに対する権限の付与

権限についてはSSM エージェント用の AmazonSSMManagedInstanceCore ~~だけでなく、セッション数の確認用に ssm:DescribeSessions~~ を追加する必要があります。[^ssm_iam_policy]

[^ssm_config]: SSMAgent を利用するには、AWS のサービス API にアクセスが必要なため、インターネットに接続可能か、該当サービスへのプライベートリンクが設定されている必要があります。

[^ssm_iam_policy]: SSH の接続数をカウントすることで ssm:DescribeSessions の付与は不要になりました。

# クライアント環境への設定

まず、クライアント環境の設定です。Session Manager を使って EC2 インスタンスに SSH で接続する場合には ProxyCommand を使います。例えば、%HOME%/.ssh/config に次のようなショートカットが設定してあるとします。

```text:%HOME%/.ssh/config
Host aws_bastion
    HostName i-12345
    User ec2-user
    Port 22
    IdentityFile ~/.aws/ec2.pem
    ProxyCommand C:/Program Files/Amazon/AWSCLIV2/aws.exe ssm start-session --target %h --document-name AWS-StartSSHSession --parameters portNumber=%p
```

この ProxyCommand を独自のスクリプトに置き換え、セッション開始の前に EC2 インスタンスを起動するようにします。ここでは、C:\ssm_connect.bat というスクリプトに記述することにし、.ssh/config を書き換えます。

```text:%HOME%/.ssh/config
Host aws_bastion
    HostName i-12345
    User ec2-user
    Port 22
    IdentityFile ~/.aws/ec2.pem
    ProxyCommand C:\\Windows\\System32\\cmd.exe /c C:/ssm_connect.bat %h %p default
```

次に ProxyCommand から呼び出される ssm_connect.bat を用意します。長いですが、単に EC2 インスタンスが止まっていたら起動させて接続できるまで待つというだけのスクリプトです。

```bat:ssm_connect.bat
@echo off
setlocal

set AWS_HOME=C:\Progra~1\Amazon\AWSCLIV2
set INSTANCE_ID=%1
set PORT=%2
set PROFILE=%3

if "%PROFILE%"=="" (
	set PROFILE=default
)

set INSTANCE_STATE=
for /f "usebackq" %%R in (`%AWS_HOME%\aws.exe ec2 describe-instance-status --include-all-instances --instance-ids "%INSTANCE_ID%" --query "InstanceStatuses[*].InstanceState.Name" --output text`) do set INSTANCE_STATE=%%R
if "%INSTANCE_STATE%"=="pending" (
	rem no handle
) else if "%INSTANCE_STATE%"=="running" (
	rem no handle
) else if "%INSTANCE_STATE%"=="rebooting" (
	rem no handle
) else if "%INSTANCE_STATE%"=="stopping" (
	echo Waiting the instance stopped... 1>&2
	aws ec2 wait instance-stopped --instance-ids "%INSTANCE_ID%" --profile "%PROFILE%" > nul
	if not errorlevel 0 (
		echo Failed to invoke ec2:wait instance-stopped: %INSTANCE_ID% 1>&2
		exit /b 1
	)
	aws ec2 start-instances --instance-ids "%INSTANCE_ID%" --profile "%PROFILE%" > nul
	if not errorlevel 0 (
		echo Failed to invoke ec2:start-instances: %INSTANCE_ID% 1>&2
		exit /b 1
	)
) else if "%INSTANCE_STATE%"=="stopped" (
	aws ec2 start-instances --instance-ids "%INSTANCE_ID%" --profile "%PROFILE%" > nul
	if not errorlevel 0 (
		echo Failed to invoke ec2:start-instances: %INSTANCE_ID% 1>&2
		exit /b 1
	)
) else if "%INSTANCE_STATE%"=="shutting-down" (
	echo Instance is shutting-down: %INSTANCE_ID% 1>&2
	exit /b 1
) else if "%INSTANCE_STATE%"=="terminated" (
	echo Instance is terminated: %INSTANCE_ID% 1>&2
	exit /b 1
) else (
	echo Failed to invoke ec2:describe-instance-status: %INSTANCE_ID% 1>&2
	exit /b 1
)

echo Waiting the instance status ok... 1>&2
aws ec2 wait instance-status-ok --include-all-instances --instance-ids "%INSTANCE_ID%" --profile "%PROFILE%" > nul
if not errorlevel 0 (
	echo Failed to invoke ec2:wait instance-status-ok: %INSTANCE_ID% 1>&2
	exit /b 1
)

%AWS_HOME%\aws.exe ssm start-session --target "%INSTANCE_ID%" --document-name AWS-StartSSHSession --parameters "portNumber=%PORT%
```

ここまで来れば、あとは「ssh aws_bastion」と打ち込んで、接続するだけです。

停止中の場合、 EC2 インスタンスの起動には1～2分程度かかりますが気長に待ちましょう。~~Hibernation が可能なインスタンスであれば、もう少し早く起動できるかもしれません。（未確認です）~~ Hibernation を使っても起動速度は変わらなかったため、停止方法を shutdown に変更しました。

VSCode の Remote SSH から使う場合には接続タイムアウト時間を 3 分程度に延長するとよいでしょう。

# サーバー環境への設定

定期的に Session Manager のセッション数を確認し、セッションがない場合には自分のインスタンスを停止するスクリプトを作ります。ここでは ~/bin/stop_instance.sh にスクリプトを配置することにします。[^path]

[^path]: ディストリビューションによってコマンドのパスが違う場合がありますので、環境に応じて修正してください。

```sh:~/bin/stop_instance.sh
#/bin/bash -e

UPTIME=`/usr/bin/uptime -s`
B5TIME=`date +"%Y-%m-%d %T" --date "-5 minutes"`
if [ "$UPTIME" \> "$B5TIME" ]; then
    echo "Skip the instance stop for initializing."
    exit 0
fi

SESSION_COUNT=`ps -A x | grep " sshd:" | grep -v -e grep -v -e /usr/sbin/sshd | wc -l`
if [ $SESSION_COUNT -gt 0 ]; then
    echo "Skip the instance stop because sessions exist."
    exit 0
fi

echo "Stopping the instance."
sudo /sbin/shutdown -h now > /dev/null 2>&1
```

あとは、このスクリプトを cron から呼び出します。5分ごとに確認する場合、 crontab -e で次の行を追加すれば完了です。

```
*/5 * * * * ~/bin/stop_instance.sh | logger -t stop_instance
```

これで接続がないときには自動的に停止するようになります。