AWS Copilotを使用したサーバーレスな踏み台サーバーの構築

# 概要
AWSを使用しているとVPC内のリソースにアクセスするため踏み台サーバーを構築する機会があるかと思います。
EC2を踏み台サーバーとする選択肢もあると思いますが、今回は`Fargate`を使用して踏み台サーバーを構築し、ローカルからポートフォワードしてプライベートサブネット内に配置されたDBへ接続するまでを行います。
`Fargate`のデプロイには[`AWS Copilot`](https://aws.github.io/copilot-cli/ja/docs/overview/)を使用します。

# 目的
- 踏み台サーバーに`Fargate`を使用してサーバーの管理コストを下げる
- `AWS Copilot`を使用してコンテナのデプロイを簡略化する

# 前提
- `AWS Copilot`でVPCを作成済み
    - `copilot app init example`
    - `copilot env init --app example --name dev --default-config`
    - `copilot env deploy --app example --name dev`
- 作成したVPCのプライベートサブネットにRDBを作成済み
- RDBに接続可能なセキュリティグループを作成済み

<details><summary>セキュリティグループの作成</summary>

addonsでAWSリソースを作成する場合の例
`copilot/envilonments/dev/addons/rds.yml`
```yaml
Parameters:
  App:
    Type: String
    Description: Your application's name.
  Env:
    Type: String
    Description: The environment name your service, job, or workflow is being deployed to.

Resources:
# ~~ RDSの作成部分省略 ~~

  AllowDBAccessSecurityGroup:  # DBに接続するため踏み台サーバーにアタッチするセキュリティグループ
    Metadata:
      'aws:copilot:description': 'A security group for your workload to access the db'
    Type: 'AWS::EC2::SecurityGroup'
    Properties:
      GroupDescription: !Sub 'The Security Group for ServiceCluster to access ${App}-${Env}-rds.'
      GroupName: !Sub ${App}-${Env}-allow-db-access-security-group
      VpcId:
        Fn::ImportValue:
          !Sub '${App}-${Env}-VpcId'

  DBClusterSecurityGroup:
    Metadata:
      'aws:copilot:description': 'A security group for your db'
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: !Sub The Security Group for the ${App}-${Env}-rds.
      GroupName: !Sub ${App}-${Env}-DBSecurityGroup
      SecurityGroupIngress:
        - ToPort: 3306
          FromPort: 3306
          IpProtocol: tcp
          Description: !Sub 'From the Aurora Security Group of the workload.'
          SourceSecurityGroupId: !Ref AllowDBAccessSecurityGroup
      VpcId:
        Fn::ImportValue:
          !Sub '${App}-${Env}-VpcId'

Outputs:
  AllowDBAccessSecurityGroupId:
    Description: Id of Service Cluster Security Group from environments addons.
    Value: !Ref AllowDBAccessSecurityGroup
    # DBと接続が必要なコンテナのCloudFormationでimportするためexportしておく
    Export:
      Name: !Sub ${App}-${Env}-AllowDBAccessSecurityGroup
```
</details>

# 踏み台サーバーの構築
ディレクトリ構成
```
copilot
├── bastion
│   └── Dockerfile
└── environments
    └── dev
        └── manifest.yml
```
`copilot/bastion/Dockerfile`
```dockerfile
FROM gcr.io/distroless/static-debian11:debug
```
Dockerfileを作成したら`copilot svc init`コマンドでサービスを初期化します。
サービスのタイプは`Backend Service`を選択します。
```bash
copilot svc init \
--name bastion \
--dockerfile './copilot/bastion/Dockerfile' \
--svc-type 'Backend Service'
```
すると、`copilot/bastion/manifest.yml`というファイルができるので、中身を編集します。
```yml
#  https://aws.github.io/copilot-cli/docs/manifest/backend-service/
name: bastion
type: Backend Service

image:
  build: 
    dockerfile: ./copilot/bastion/Dockerfile

cpu: 256
memory: 512
platform: linux/x86_64
count: 1
exec: true

network:
  vpc:
    security_groups:
      groups: # ここにRDSと接続可能なセキュリティグループを設定する
        - 'sg-xxxxxx'
        # CloudFormationのexportから取得する場合
        # - from_cfn: ${COPILOT_APPLICATION_NAME}-${COPILOT_ENVIRONMENT_NAME}-AllowDBAccessSecurityGroup

# タスク定義の上書き
taskdef_overrides:
  - path: ContainerDefinitions[0].PseudoTerminal
    value: true
```
ポイントは以下です.
- `exec: true`
    - コンテナ内部に入れるように設定
- `network.vpc.security_groups.groups`
    - プライベートサブネット内に配置したRDSに接続できるセキュリティグループを追加
- `taskdef_overrides`の設定
    - 今回のDockerfileの書き方の場合、デプロイ後にすぐサービスが終了してしまうので永続化の設定が必要
ローカルで実行する場合の`docker run --tty`に相当する設定を追加することで起動したままにします
        - [タスク定義のオーバーライド](https://aws.github.io/copilot-cli/ja/docs/developing/overrides/taskdef-overrides/)
        - [コンテナ定義](https://docs.aws.amazon.com/ja_jp/AmazonECS/latest/userguide/task_definition_parameters.html#container_definitions)

デプロイ
```bash
copilot svc deploy --app example --env dev --name bastion
```
これでデプロイできたので次はコンテナに接続していきます。

# Session Manager
踏み台サーバーへの接続にはSSHではなく[Session Manager](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager.html)を使用します。
SSH接続のために鍵やネットワークの管理をする必要がなくなるのでより運用コストを下げることができます。
また、ポートを許可する必要もないのでセキュリティ面でのメリットもあります。

ローカルからの接続には`Session Manager Plugin`が必要なので入っていなければインストールします。
[AWS CLI 用の Session Manager プラグインをインストールする](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html#install-plugin-macos)
```bash
# Homebrewからインストールする場合
brew install --cask session-manager-plugin
```
踏み台サーバーに接続するだけであれば、`copilot`のコマンド経由でコンテナに入れます。
```bash
copilot svc exec --app example --env dev --name bastion
```
なのでコンテナに入ることだけが目的であればここまでで実現できます。

今回はポートフォワーディングが目的なのでもう少し進めます。

## ポートフォワード

`copilot svc exec`でポートフォワーディングするオプションはなさそうなので`AWS CLI`と`Session Manager Plugin`を使用して接続します。

ひとまず`Session Manager Plugin`経由でコンテナに入れるかを試します。

コンテナとの接続を開始するには`aws ssm start-session`コマンドを使用します。
`--target`オブションの引数には`ecs:<cluster_id>_<task>_<runtime_id>`で接続先のコンテナを指定します。
```bash
aws ssm start-session \
--target ecs:<cluster_name>_<task_id>_<runtime_id>
```

<details><summary>cluster_idの取得</summary>

`copilot env deploy --name dev`でenvをデプロイしていると、`cluster_id`がCloudFormationでexportされているので下記で取得できます。
```bash
aws cloudformation list-exports | jq -r '.Exports[] | select(.Name == "example-dev-ClusterId").Value'
```
</details>

<details><summary>task_idの取得</summary>

```bash
copilot svc status -a example -e dev -n bastion --json | jq -r '.tasks[].id'
```

</details>

<details><summary>runtime_idの取得</summary>

```shell
aws ecs describe-tasks \
--cluster <cluster_id> \
--tasks <task_id> \
| jq -r '.tasks[].containers[].runtimeId'
```

</details>


上記でコンテナに入れることを確認できたと思いますので続けてポートフォワードを試します。
ポートフォワーディングする際のオプションは[こちら](https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/session-manager-working-with-sessions-start.html#sessions-remote-port-forwarding)を参考に、`--document-name`と`--parameters`を指定します。

```shell
aws ssm start-session \
--target ecs:<cluster_name>_<task_id>_<runtime_id> \
--document-name AWS-StartPortForwardingSessionToRemoteHost \
--parameters '{"host": ["<RDBのエンドポイント>"], "portNumber": ["<RDBのポート番号>"], "localPortNumber": ["<ローカルのポート番号>"]}'
```
これで接続が開始されたと思うので、あとはローカルからAWS上のDBへ接続を試します。
```shell
mysql -h 127.0.0.1 -u user　-P　<ローカルのポート番号>　-p

mysql>
```


# 最後に
以上、サーバーレスな踏み台サーバーを構築する方法を紹介しました。
どなたかの参考になれば幸いです。
あと、`bastion`の発音は「バスチャン」らしいです。