every Tech Blog

株式会社エブリーのTech Blogです。

RDS踏み台サーバをよく見かけるECS Fargate+PortForward+Adhocな機構に変更する

概要

この記事は every Tech Blog Advent Calendar 2023 の10日目です。

TIMELINE開発部の内原です。

本日はTIMELINE開発部で利用しているAWS RDSへの踏み台サーバの構成を、ECS Fargate+PortForward+Adhocな機構に変更した話を書きます。

似たような記事はいたるところで見かけるので何番煎じになるか分からない状況ですが、以前からやってみたいと考えていたものだったので個人的にはよかったです。

変更前の構成

変更前の踏み台サーバの構成は以下のようなものでした。

  • EC2インスタンス (Amazon Linux, t2.micro)
  • SSHポート転送を利用してRDSに接続
  • SSH用アカウントはgithubの公開鍵を手動で登録

上記構成を利用してSSHポート転送を行うには、以下のようなコマンドを実行します。

$ ssh -L 3306:database.xxxxxxxxxx.ap-northeast-1.rds.amazonaws.com:3306 $user@$db_proxy_host

その後MySQLクライアントなどで127.0.0.1:3306に接続することで、RDSに接続できます。

問題点

この構成には以下のような問題点がありました。

  1. EC2インスタンス固定費が発生
    • t2.micro の料金は USD 0.0116/hour で、1ヶ月で 24 * 30 = 720 時間稼働すると USD 8.35 かかります
    • 微々たるものですが、使っていないにも関わらず費用が発生するのはもったいないです
  2. EC2インスタンスメンテナンスが必要
    • AMIの更新やセキュリティ脆弱性など、必要に応じて定期的にメンテナンスが必要になります
  3. アカウントの管理が面倒
    • 開発者が増える度に、手動でUnixユーザ追加と公開鍵の登録を行う必要があります
    • また、退職者のアカウントの削除も手動で行う必要があり、忘れるとセキュリティ上問題があるため注意が必要です

対応策

以下のようなことを実現したいと考えました。

EC2インスタンスを廃止

ECS Fargateを利用して、必要に応じてコンテナを起動する方針とします。 これによりインスタンスの管理が不要となります。

SSHポート転送を廃止

ポート転送にはAWS SSM Session Managerを利用することにします。 これによりSSHの管理も不要となります。

アカウントの個別管理を廃止

エブリーではAWSのアカウント管理にAWS IAM Identity Center(旧AWS SSO)を利用しており、認証にはGoogle Workspaceを用いています。

退職者はGoogle Workspaceにアクセスできなくなるため、自動的にAWSへのアクセスも不可能となり、アカウントの管理が不要となります。

自動的に停止する機能

せっかくECS Fargateに切り替えても、常時コンテナが起動していたのでは却って費用が割高になってしまいます。

このため、RDSへの接続が不要になったタイミングでコンテナが自動的に終了するようにしたいと考えました。

また安全のため、なんらかの理由でコンテナの自動終了ができなかった場合でも、一定時間経過したら強制的に終了するようにしておきたいです。

なるべく軽くかつ安くしたい

ECS Fargateのコンテナは、最低でもvCPU 0.25、メモリ 512MBのリソースを必要とします。やりたいことはポート転送だけなので、最低限のスペックにしておきます。

また2024/02/01より、AWSではPublic IP Addressに対する課金が発生することになっています。このため、Public IP Addressを割り当てないように設定します。

RDSはVPC内にあるため、private subnetにコンテナを配置しても問題なく接続できます。

というわけでできました

ECSタスク定義

あらかじめ以下のようなタスク定義を作成しておきます。

スペックは最低限、かつ一定時間が経過したらコンテナが自動的に終了する構成にしておきます。

{
    "taskDefinitionArn": "arn:aws:ecs:ap-northeast-1:xxxxxxxxxxxx:task-definition/db-proxy:1",
    "containerDefinitions": [
        {
            "name": "db-proxy",
            "image": "alpine:latest",
            "cpu": 0,
            "portMappings": [],
            "essential": true,
            "entryPoint": [
                "sh",
                "-c"
            ],
            "command": [
                "sleep $TIMEOUT_SEC"
            ],
            "environment": [
                {
                    "name": "TIMEOUT_SEC",
                    "value": "3600"
                }
            ],
            "mountPoints": [],
            "volumesFrom": []
        }
    ],
    "family": "db-proxy",
    "taskRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/ecs-task-role",
    "executionRoleArn": "arn:aws:iam::xxxxxxxxxxxx:role/ecsTaskExecutionRole",
    "networkMode": "awsvpc",
    "volumes": [],
    "requiresAttributes": [
        {
            "name": "com.amazonaws.ecs.capability.task-iam-role"
        },
        {
            "name": "com.amazonaws.ecs.capability.docker-remote-api.1.18"
        },
        {
            "name": "ecs.capability.task-eni"
        }
    ],
    "placementConstraints": [],
    "compatibilities": [
        "EC2",
        "FARGATE"
    ],
    "requiresCompatibilities": [
        "FARGATE"
    ],
    "cpu": "256",
    "memory": "512"
}

起動スクリプト

そして、RDSに接続をする際には以下のようなスクリプトを用います。

スクリプトではFargateコンテナの起動とポート転送を行い、スクリプト終了時にはコンテナを停止します。

スクリプトの終了はCtrl-Cで行うことができます。

#!/bin/bash

set -e

# タスクが終了するまで待機する場合は wait_stopped=1 を指定する
wait_stopped=

cluster="YOUR_CLUSTER_NAME"
remote_db_host="YOUR_DB_HOST"
remote_db_port="3306"
local_db_port="3306"
profile="${AWS_PROFILE:-default}"
task_definition="db-proxy:1"
subnets="subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx,subnet-xxxxxxxxxxxxxxxxx" # private subnetsを指定
security_groups="sg-xxxxxxxxxxxxxxxxx"

running_task_arn=""
# Ctrl-C で終了した場合にコンテナを停止する
function shutdown() {
  if [[ -z $running_task_arn ]]; then
    exit 0
  fi

  aws \
    --profile $profile \
    ecs stop-task \
    --cluster $cluster \
    --task $running_task_arn >/dev/null

  if [[ -z $wait_stopped ]]; then
    exit 0
  fi

  aws \
    --profile $profile \
    ecs wait tasks-stopped \
    --cluster $cluster \
    --tasks $running_task_arn >/dev/null
}

trap shutdown 2

# タスク起動
running_task_arn=$(aws \
  --profile $profile \
  ecs run-task \
  --cluster $cluster \
  --enable-execute-command \
  --task-definition $task_definition \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$subnets],securityGroups=[$security_groups],assignPublicIp=DISABLED}" |
  jq -r '.tasks[0].containers[0].taskArn')

# タスクが起動するまで待機
aws \
  --profile $profile \
  ecs wait tasks-running \
  --cluster $cluster \
  --tasks $running_task_arn

# runtimeId を取得
runtime_id=$(aws \
  --profile $profile \
  ecs describe-tasks \
  --cluster $cluster \
  --tasks $running_task_arn |
  jq -r '.tasks[0].containers[0].runtimeId')

task_id=$(echo "$running_task_arn" | cut -d '/' -f 3)
target="ecs:${cluster}_${task_id}_${runtime_id}"
# コンテナに対しポート転送を行う
aws \
  --profile $profile \
  ssm start-session \
  --target $target \
  --document-name AWS-StartPortForwardingSessionToRemoteHost \
  --parameters '{"host":["'$remote_db_host'"],"portNumber":["'$remote_db_port'"], "localPortNumber":["'$local_db_port'"]}'

以下のような出力が得られたら、MySQLクライアントなどで127.0.0.1:3306に接続することでRDSに接続できます。

Starting session with SessionId: xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69
Port 3306 opened for sessionId xxxxxxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69.
Waiting for connections...

その後 Ctrl-C を押すと、以下のような出力が得られ、コンテナが終了します。

Terminate signal received, exiting.

Exiting session with sessionId: xxxxxxxxxxxxxxxxxxxxxxx-04631105c5c065f69.

仮にスクリプトを長時間放置したままにしていた場合や、なんらか不測の事態によりコンテナを正しく終了できなかった場合でも、1時間経過すると自動的に終了します。

まとめ

以上で、ECS Fargate+PortForward+Adhocな機構による踏み台サーバの構成変更を行うことができました。

これにより、EC2インスタンスの管理やSSHの管理、アカウントの管理が不要となり、かつコンテナの起動は必要最低限に抑えられるため、費用も削減できるようになりました。

セキュリティ向上やメンテナンスコストがなくなったのが個人的には一番のメリットでした。

以上、何番煎じか分からないRDS踏み台サーバを作る話でした。