【Fargate】localからMysqlClientでRDSに繋げたい？SessionManagerのリモートホストポートフォワードがあるよ【SSM】

# 背景
開発期間中にPJメンバーから、DBに投入しているSeedデータではとあるテストを実施する際にカバー出来ない部分があって、ローカルからMysqlClientで値を変えながら実施したい旨の話がありました。

該当PJではFargate+Auroraで構成されており、また常駐的な踏み台は用意せず、必要な時だけそれ用のmiscコンテナを起動させる方針を取っています。

そんな中、丁度今年の5月下旬にタイトルにある機能がセッションマネージャーにリリースされたこともあり、テンポラリーなmiscコンテナをリモートホストポートフォワードの中継役として実施したところ、既に幾つか記事として見かけますが、便利だと言うことで周辺知識を改めて追いながら書いてます。

# 概要
## ポートフォワードのイメージ
そもそもポートフォワードとはなんぞやという話ですが、下図のように例えば自端末の特定ポート(例:13306)への通信に対して、別のホストやアドレスの特定ポート(例:3306)に自動で転送してくれる仕組みを指します。

![スクリーンショット 2022-07-30 18.19.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/06cfc701-3cd1-56e8-8421-6483f7aee727.png)

## セッションマネージャーのリリース

>Session Manager のポートフォワーディングは、クライアントマシンと Systems Manager が管理するインスタンス間の通信をトンネルするために使用されます。本日より、Systems Manager はクライアントマシンからリモートホストのポートへのフォワーディングコネクションをサポートします。リモートのポートフォワーディングにより、データベースやウェブサーバーなどのリモートホストのアプリケーションポートに、これらのサーバーを外部ネットワークにさらすことなく安全に接続するための「ジャンプホスト」として管理対象インスタンスを使用可能になりました。

https://aws.amazon.com/jp/about-aws/whats-new/2022/05/aws-systems-manager-support-port-forwarding-remote-hosts-using-session-manager/

リリース文にある通り、セッションマネージャーはそもそもポートフォワード機能を持ってまして、今回追加された機能はリモートホストに対してもポートフォワード出来るという内容になっています。

何が違うのかというと

## 従来までの機能(ローカルホストポートフォワード)
これまでは例えばEC2にssm-agentとmysqlをインストールしていた場合、ssm-agentを用いてPrivateSubnet内のinstanceにセッションを張って、ポートフォワードによりEC2内のMysqlにアクセス出来るといったものでした。

![スクリーンショット 2022-07-30 14.14.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/6e7e7ceb-66b8-8c82-5b9b-2335c2f99996.png)

以下のコマンドをローカルで実行する事でローカルポート13306にアクセスするとEC2内の3306ポートに転送されます。
```bash
aws ssm start-session \
    --target i-exsampleinstance \
    --document-name AWS-StartPortForwardingSession \
    --parameters '{"portNumber":["3306"], "localPortNumber":["13306"]}'
```

セッションが確立されたらmysqlへのログインが可能になります。
```bash
mysql -u username -p -h 127.0.0.1 -P 13306
```

## リモートホストポートフォワード
従来の例ですとローカルホスト内のポートに対してアクセスを行うものでしたが、新しい機能では呼んで字の如く、リモートホストのポートに対してアクセス出来るようになりました。ですので、ssm-agentをインストールしたインスタンスだけでなく、PrivateSubnet内のRDSやElastiCacheにローカル端末からのアクセスがセッションマネージャーで可能ということです。踏み台にログインというのも当然不要になります。

![スクリーンショット 2022-07-30 14.21.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/853df041-a225-fc27-bac2-884ad6f9665c.png)


以下のコマンドをローカルで実行する事でローカルポート13306にアクセスするとEC2を経由してAuroraの3306ポートに転送されます。※先程とコマンドが若干違うので注意。
```bash
aws ssm start-session \
    --target i-exsampleinstance \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters '{"host":["xxx.xxx.cluster-xxx.ap-northeast-1.rds.amazonaws.com"],"portNumber":["3306"], "localPortNumber":["１3306"]}'
```

セッションが確立されたらmysqlへのログインが可能になります。
```bash
mysql -u username -p -h 127.0.0.1 -P 13306
```

## sshを用いたRDSへのポートフォワード
一応セッションマネージャーの別の機能で、sshを用いてPrivateSubnetのRDSにアクセスすることは、これまでも可能でした。※この場合、認証鍵の管理などが必要という点に注意が必要です。

![スクリーンショット 2022-07-30 14.48.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/a8e9f269-e5a3-512e-1ca6-9c7a4925afc9.png)

```bash
ssh -i xxxx.pem ec2-user@i-exsampleinstance -L 13306:xxx.xxx.cluster-xxx.ap-northeast-1.rds.amazonaws.com:3306
```

# やってみよう
## 前提
セッションマネージャーによるリモートホストポートフォワードを実現する為には下記条件があるのでそれぞれ満たす必要があります。

- [セッションマネージャーそのものの前提条件](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-prerequisites.html)
    - os、ssm-agent、SystemsManagerエンドポイントへの経路（NatGateway or VPCEndpoint）、aws-cli、iam権限、アドバンストインスタンス層有効化（Fargate）など
- ssm-agentのバージョンが3.1.1374.0以降

## 構成とフロー
![スクリーンショット 2022-08-01 16.07.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/7c4ea792-d8cc-9acb-ef6e-4a3559faf8ed.png)

1. SystemsManagerのインスタンス層をスタンダードからアドバンストに変更
1. ssm-agent、aws-cli、scriptを入れたコンテナイメージを用意
1. ECRにPush
1. コンテナ起動/ポートフォワード用scriptを実行
1. コンテナが起動
1. コンテナ内のscriptが実行
1. SystemsManagerにコンテナを管理ノードとして登録
1. ノードIDをParameterStoreに登録
1. ssm-agentを起動
1. コンテナ起動/ポートフォワード用scriptにてノードIDを取得
1. 取得したノードIDを用いてセッションの確立
1. ポートフォワード実行

## インスタンス層をアドバンストに変更
今回はコンソール上から実施。
- SystemsManagerのナビゲーションペインよりフリートマネージャーを選択
-  「アカウント管理」よりインスタンス枠の設定を押下

![スクリーンショット_2022-07-31_13_38_23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/11e08109-8976-fecc-d018-6dbf83268029.png)

- 「アカウント設定の変更」を押下

![スクリーンショット_2022-07-31_13_42_16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/15c830f1-0016-4e3d-179c-89a349b17825.png)

- 「アカウントとリージョン内のすべて〜」にチェックを入れて「設定の変更」を押下
※アドバンスドインスタンスは以下のルールで従量課金が発生するので注意

>アドバンスドオンプレミスインスタンスごとに時間あたり 0.00695 USD
無料利用枠なし

![スクリーンショット_2022-07-31_13_42_27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/d0e1219f-7591-d2cb-d177-89745f258154.png)

コンソールの表示が変わっていれば設定完了です。
![スクリーンショット_2022-07-31_13_50_21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/2cd97b0e-78d5-acfe-5c18-b6b339b284fb.png)

## コンテナイメージの作成
ファイル構成
```
docker
├── scripts
│   └── setting.sh
└── ssm
    └── Dockerfile
```

```dockerfile.
FROM public.ecr.aws/ubuntu/ubuntu:20.04

WORKDIR /

RUN \
    --mount=type=cache,target=/var/lib/apt/lists \
    --mount=type=cache,target=/var/cache/apt/archives \
    apt-get update \
    && apt-get -y install \
    curl \
    jq \
    unzip

RUN curl https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip -o awscliv2.zip \
    && unzip awscliv2.zip \
    && ./aws/install \
    && rm -rf awscliv2.zip ./aws

RUN curl https://s3.ap-northeast-1.amazonaws.com/amazon-ssm-ap-northeast-1/latest/debian_amd64/amazon-ssm-agent.deb -o /tmp/amazon-ssm-agent.deb \
    && dpkg -i /tmp/amazon-ssm-agent.deb \
    && mv /etc/amazon/ssm/amazon-ssm-agent.json.template /etc/amazon/ssm/amazon-ssm-agent.json \
    && rm /tmp/amazon-ssm-agent.deb

COPY ./docker/scripts/setting.sh /setting.sh

RUN chmod 755 /setting.sh

CMD ["bash", "/setting.sh"]
```

※Scriptのコメントにも記載していますが、該当イメージを指定したタスク定義の環境変数にはENVを指定します。
※また、以下の権限を持たせたIAMRoleを用意します。
- SSMServiceRole
    - 「AmazonSSMManagedInstanceCore」のマネージドポリシー
    - 「ssm:DeregisterManagedInstance」の許可

```setting.sh
#!/bin/bash
set -e

# 事前にIAMRole(SSMServiceRole)を用意する必要あり
# タスク定義の環境変数にENVを指定してください。
SSM_SERVICE_ROLE_NAME="SSMServiceRole"

SSM_PARAMETER_NAME="/${ENV}/portforward/ssminstanceid"
AWS_REGION="ap-northeast-1"
REGISTRATION_FILE="/var/lib/amazon/ssm/registration"

cleanup() {
    # コンテナ終了時、マネージドインスタンス登録を解除
	echo "Deregister a managed instance..."
    aws ssm deregister-managed-instance --instance-id "$(cat "${REGISTRATION_FILE}" | jq -r .ManagedInstanceID)" || true
    exit 0
}

# エラー対処
amazon-ssm-agent stop
rm -rf /var/lib/amazon/ssm/ipc

# マネージドインスタンスのアクティベーションを作成
ACTIVATION_PARAMETERS=$(aws ssm create-activation \
	--description "Activation Code for Fargate Bastion" \
	--default-instance-name bastion \
	--iam-role ${SSM_SERVICE_ROLE_NAME} \
	--registration-limit 1 \
	--tags Key=Type,Value=Bastion \
	--region ${AWS_REGION})

# アクティベーションIDとアクティベーションコードの変数化
SSM_ACTIVATION_ID=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationId)
SSM_ACTIVATION_CODE=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationCode)

# アクティベーションIDとアクティベーションコードを使用してマネージインスタンスの登録
result=$(amazon-ssm-agent -register -code "${SSM_ACTIVATION_CODE}" -id "${SSM_ACTIVATION_ID}" -region ${AWS_REGION})

trap "cleanup" EXIT ERR

echo "${result}"

# マネージドインスタンスIDを取得
MANAGED_INSTANCE_ID=$(echo ${result} | grep -Eo "instance-id: .*$" | grep -Eo "[^ ]*$")
echo "Managed instance-id: ${MANAGED_INSTANCE_ID}"

# ParameterStoreにマネージドインスタンスIDを登録
# 該当のParameterStoreが存在しなくても自動的に作成してくれます。
aws ssm put-parameter --name "${SSM_PARAMETER_NAME}" --value "${MANAGED_INSTANCE_ID}" --type String --overwrite

# マネージドインスタンスのアクティベーションの削除
aws ssm delete-activation --activation-id ${SSM_ACTIVATION_ID}

# ssm-agentの起動
amazon-ssm-agent
```

## ECRにイメージをPush
```bash
# build
docker build -t portforward ./docker/ssm

# タグ
docker tag portforward:latest xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/portforward:latest

# ecrにログイン
aws ecr get-login-password --region ap-northeast-1 ーーprofile <awscli profile名> | docker login --username AWS --password-stdin xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com

# push
docker push xxxxxxxxxxxx.dkr.ecr.ap-northeast-1.amazonaws.com/portforward:latest
```

## Script実行

※ECS周り(Cluster、Service、タスク定義など)やAuroraなどは既にあるものとして話を進めますが、TaskRoleは以下の権限を付与します。

- SessionManager用ポリシー
    - "ssm:DeleteActivation"の許可
    - "ssm:RemoveTagsFromResource"の許可
    - "ssm:AddTagsToResource"の許可
    - "ssm:CreateActivation"の許可
    - "ssm:DeregisterManagedInstance"の許可
- ParameterStore用ポリシー
    - "ssm:PutParameter"の許可
    - "ssm:GetParameter*"の許可
    - "ssm:DescribeParameters"の許可
- SessionManager-PassRole
    - "iam:PassRole"の許可
        - iam:PassedToService: "ssm.amazonaws.com"の条件付与
```TaskRole.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "ssm:DescribeParameters",
                "ssm:GetParameter*",
                "ssm:PutParameter"
            ],
            "Resource": "*",
            "Effect": "Allow",
            "Sid": "AccesstoParameterStore"
        },
        {
            "Action": [
                "ssm:DeleteActivation",
                "ssm:RemoveTagsFromResource",
                "ssm:AddTagsToResource",
                "ssm:CreateActivation",
                "ssm:DeregisterManagedInstance"
            ],
            "Resource": "*",
            "Effect": "Allow",
            "Sid": "SessionManager"
        },
        {
            "Condition": {
                "StringEquals": {
                    "iam:PassedToService": "ssm.amazonaws.com"
                }
            },
            "Action": "iam:PassRole",
            "Resource": "*",
            "Effect": "Allow",
            "Sid": "SessionManagerPassRole"
        }
    ]
}
```

ローカルから下記Scriptを実行します。
これにより、以下も併せて実行されます。
- コンテナが起動
- コンテナ内のsetting.shが実行
- SystemsManagerにコンテナを管理ノードとして登録
- ノードIDをParameterStoreに登録
- ssm-agentを起動
- コンテナ起動/ポートフォワード用scriptにてノードIDを取得
- 取得したノードIDを用いてセッションの確立

```sh
./portforward.sh <awscli-profile>
```

※ECSやAuroraなど関連リソースの命名は[xxx-stg-xxxx]のように環境値を入れてそれぞれ立てている前提で書いてます。

```portforward.sh
#!/bin/bash

## 第1引数にAWScliのprofileを指定
PROFILE=$1

cd $(dirname $(readlink $0 || echo $0))

CUR_DIR=$(pwd)

echo "対象環境を数字から選択してください"
select ENV in "prod" "stg" "dev"
do
    echo "${ENV}が選択されました。"
    ENV=${ENV}
    break
done

if [ -z $PROFILE ]; then
    echo "aws cli プロファイル名を数字から選択してください"
    PROFILES=$(aws configure list-profiles)
    select PROFILE in $PROFILES
    do
        echo "${PROFILE}が選択されました。"
        PROFILE=${PROFILE}
        break
    done
fi

##--------------
## コンテナ起動処理
##--------------
echo "Clusterを数字から選択してください"
CLUSTERS=$(aws ecs list-clusters --profile $PROFILE --output text | sed -e 's/.*cluster\///' | grep $ENV)
select CLUSTER in $CLUSTERS
do
    echo "${CLUSTER}が選択されました。"
    CLUSTER=${CLUSTER}
    break
done

echo "Serviceを数字から選択してください"
SERVICES=$(aws ecs list-services --cluster ${CLUSTER} --profile $PROFILE --output text | sed -e 's/.*'$CLUSTER'\///')
select SERVICE in $SERVICES
do
    echo "${SERVICE}が選択されました。"
    SERVICE=${SERVICE}
    break
done

SUBNETS=$(aws ecs describe-services \
        --cluster ${CLUSTER} \
        --services ${SERVICE} \
        --profile $PROFILE \
        --query 'services[].deployments[].networkConfiguration[].awsvpcConfiguration[].subnets[]')
SECURITY_GROUPS=$(aws ecs describe-services \
                --cluster ${CLUSTER} \
                --services ${SERVICE} \
                --profile $PROFILE \
                --query 'services[].deployments[].networkConfiguration[].awsvpcConfiguration[].securityGroups[]')
TASK_DEFINITION=$(aws ecs describe-services \
                --cluster ${CLUSTER} \
                --services ${SERVICE} \
                --profile $PROFILE \
                --query 'services[].taskDefinition[]' \
                --output text | sed -e 's/.*task-definition\///') 
NETWORK_CONFIGURATION="awsvpcConfiguration={subnets=${SUBNETS},securityGroups=${SECURITY_GROUPS},assignPublicIp=DISABLED}"

RUN_TASK=$(aws ecs run-task \
        --cluster ${CLUSTER} \
        --task-definition ${TASK_DEFINITION} \
        --network-configuration="${NETWORK_CONFIGURATION}" \
        --enable-execute-command \
        --platform-version 'LATEST' \
        --launch-type=FARGATE \
        --profile $PROFILE \
        --query 'tasks[].containers[].taskArn' \
        --output text)

EXEC_WAITING_SEC=20
echo "コンテナを起動しています。 ${EXEC_WAITING_SEC} 秒お待ち下さい。";
sleep ${EXEC_WAITING_SEC};

##-----------------
## ポートフォワードセッション処理
##-----------------
echo "接続するDatabaseを数字から選択してください"
TARGET_HOSTS=$(aws rds describe-db-clusters \
            --profile $PROFILE \
            --query 'DBClusters[?contains(Endpoint, `'$ENV'`)].[Endpoint]' \
            --output text)
select TARGET_HOST in $TARGET_HOSTS
do
    echo "${TARGET_HOST}が選択されました。"
    TARGET_HOST=${TARGET_HOST}
    break
done

LOCAL_DB_PORT="13306"
TARGET_DB_PORT="3306"
REGION="ap-northeast-1"
APP_NAME="Bastion"
SSM_PARAMETER_NAME="/${ENV}/portforward/ssminstanceid"

TUNNEL_LOG_DIR_PATH="${CUR_DIR}/tunnel_logs"
TUNNEL_LOG_FILE_PATH="${TUNNEL_LOG_DIR_PATH}/$(date +%Y_%m%d_%H%M%S).log"

TARGET=$(aws ssm get-parameters \
	--name "${SSM_PARAMETER_NAME}" \
    --profile $PROFILE \
    --query 'Parameters[].Value' \
    --output text)

PARAMETERS="{\"host\":[\"${TARGET_HOST}\"],\"portNumber\":[\"${TARGET_DB_PORT}\"], \"localPortNumber\":[\"${LOCAL_DB_PORT}\"]}"

mkdir -p ${TUNNEL_LOG_DIR_PATH}
touch ${TUNNEL_LOG_FILE_PATH}

aws ssm start-session \
	--target ${TARGET} \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters "${PARAMETERS}" \
	--region ${REGION} \
	--profile $PROFILE > ${TUNNEL_LOG_FILE_PATH} &

# defaultでは20分。SystemsManagerのSessionManager設定で変更できます。
echo "セッションを開始しました。"
echo "20分経つとセッション接続が切れます。"

# 1日経ったログは消す
find ${TUNNEL_LOG_DIR_PATH} -type f -mtime +1 | xargs rm

##-----------------
## 終了処理
##-----------------
TASK_ARN=$RUN_TASK
echo "作業が終わったらコンテナを削除してください。"
function try_exec {
  while true; do
    echo -n "$* [y/n]: "
    read YN
    case $YN in
      [Yy]*)
        return 0
        ;;  
      [Nn]*)
        return 1
        ;;
      *)
        echo "yまたはnを入力してください"
        ;;
    esac
  done
}

if try_exec "コンテナを削除しますか？"; then
  STOP_CMD=$(aws ecs stop-task --cluster $CLUSTER --task $TASK_ARN --profile $PROFILE)
  echo "コンテナ削除を実行しました。Scriptを終了します。"
else
    echo "コンテナを削除せずScriptを終了します。"
    echo "コンテナの削除を忘れずに実施してください。"
fi
```

Scriptを実行してコンテナが起動するとフリートマネージャー上ではノードが登録されます。
![スクリーンショット_2022-07-31_17_46_49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/cdb4bbdf-75e8-69d8-c9f5-cc8a6fa0e1b1.png)

また、ParameterStore上には上記のノードIDが値として保管されます。
![スクリーンショット_2022-07-31_17_52_21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/18f4ab1b-4e0a-f478-ddde-d22f4bb0342a.png)

# MysqlClientで接続
お好みのClientを使ってlocalhost:13306にアクセスするとリモートホストポートフォワードでAuroraにアクセスすることが出来ます。

Mysql Workbenchで試してみましょう。
接続情報を入力して、「Test Connection」を押下します。

![スクリーンショット_2022-07-31_22_41_29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/7511c122-ebf7-f8b2-728e-ace5eed533e4.png)

「Successfully made the MySQL connection」が表示されたので問題なく成功していますね。
![スクリーンショット_2022-07-31_22_47_25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/c36b9ff3-639e-6c66-2386-2ceaaa0f1130.png)

作業が完了したらターミナルの案内に従って「Y」を入力してエンターを押下し、コンテナを削除してください。
![スクリーンショット 2022-07-31 22.53.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/b71959c6-f23e-c159-e0b9-580cad398b47.png)

コンテナが削除されると、フリートマネージャーに登録されていたノードも併せて解除されます。
![スクリーンショット 2022-07-31 22.55.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/354022/ed7925b5-d224-5e0f-0725-e53eccb7de5a.png)

# 纏め
Clientをインストールした踏み台を用意せずにローカルのClientからRDSにアクセス出来る様になったので、どうしてもという時非常に便利です！また、セッションマネージャーによるアクセスはIAM内で条件付け(IP、MFA必須、ユーザなど)を行うことが可能になるのでよりセキュアに運用できるのも嬉しいポイントですね。

# 参考
- Scriptなど参考にさせて頂きました

https://go-to-k.hatenablog.com/entry/ecs-fargate-ssm-remote-port-forward
- 権限を絞りたい場合

https://dev.classmethod.jp/articles/aws-ssm-remote-host-port-forward-deny/