EC2 Instance Connect Endpoint を活用して踏み台なしで RDS に接続するスクリプト

追記: 仕様変更により、本記事に記載の方法は利用できなくなっています。

以下の通り、 SSH / RDP のみ利用可能な仕様となっています。

The specified RemotePort is not valid. Specify either 22 or 3389 as the RemotePort and retry your request.




エンジニアの唐津です。
先日、AWS から EC2 Instance Connect Endpoint(EIC Endpoint) という新機能 が発表されましたね。

こちらを利用することで、パブリック IP アドレスを持たない EC2 に SSH/RDS することが可能となります。
それ自体はこれまでも実現可能でしたが、EIC Endpoint はなんと無料で利用可能で、かつ設定も簡単ですので、ぜひ積極的に活用していきたいサービスです(データ転送料金はかかります)。

こちらの導入により Bastion サーバを削減し、セキュアな状況を保ちつつも、コストや運用の手間を削減できるケースも多いのではないでしょうか。

EIC Endpoint を活用して踏み台無しで RDS に接続する

さて、ホットな新機能ということで、色々と検証されている方も多いと思います。
例えば、クラスメソッド様の記事 では、 EC2 だけでなく、 RDS にも踏み台なしで接続可能であることを検証・確認されています。

私も例に漏れず、便利に活用できないかと考え、
上記の記事を参考にさせていただきながら、プライベートサブネットにある RDS インスタンスを対話的に選択し、
接続設定を行うシェルスクリプトを書いてみました。

EIC Endpoint を活用していますが、そもそも EIC や AWS CLI を利用した操作にあまりに馴染みが無い方もいらっしゃるかと思います。
少し事前準備は必要となりますが、普段 AWS CLI 等をあまり使用しない方でも楽に使えるようにできればと思い、
スクリプト実行時には、対象の RDS を選択するだけで、接続設定が完了できるようにしています。

シェルスクリプトの動作

記事が少し長くなってしまったので、
はじめに、作成したシェルスクリプトで何ができるかを示したいと思います。

ディレクトリ構造

まず、検証に用いたディレクトリ構造は以下となります。

❯ tree .
.
├── eice_rds.sh          # スクリプト本体 
└── instance_list        # 接続したいRDSの情報を格納するディレクトリ
    ├── mysql-test       
    └── postgresql-test

2 directories, 3 files

instance_list というディレクトリの中に、接続したい RDS の情報を記載したファイルを入れています。
接続対象の RDS が増えるごとにファイルが増えていくイメージです。
DB のパスワードを入れ込むことも可能ですが、一応センシティブな情報は書かないような想定としています。
ここで指定する RDS はプライベートサブネットへの配置で OK で、当然パブリックアクセスを許可しておく必要もありません。

❯ cat instance_list/mysql-test                      
SSO_SESSION_NAME="~/.aws.config に設定している「sso-session」 の名称" # AWS Identity Center を利用している場合のみ。なくてもOK
TMP_AWS_PROFILE="~/.aws.config で設定しているプロファイル名"
RDS_NAME="対象の DB インスタンス名"

INSTANCE_CONNECT_ENDPOINT_ID="eice-xxxxxxxxxx" # 利用する EIC エンドポイントのID
RDS_PRIVATE_IP_ADDRESS=$(dig +short test-mysql.xxxxxxxx.<リージョン>.rds.amazonaws.com)  # 接続したい DB インスタンスのエンドポイントを入力
LOCAL_PORT=13306      # 任意の値でOK。13306 or 15432 などを想定
REMOTE_PORT=3306      # 3306 or 5432 を想定

DB_TYPE="MySQL" # MySQL / PostgreSQL
DB_USER="xxx"   # 接続に用いるユーザ名
DB_NAME=""      # 必要な場合のみでOK。接続するデータベース名。

RDS_PRIVATE_IP_ADDRESS は、「172.31.x.x」 のように名前解決され、指定した RDS のエンドポイントに割り当てられているプライベート IP に置き換わります。
RDS for MySQL / PostgreSQL では正常に動作することを確認済で、恐らく RDS for MariaDB でも動作すると思います。
Aurora は未確認ですが、エンドポイントを名前解決すると CNAME レコードが返ってくる関係で、少しコマンドを工夫する必要があるかもしれません。
暫定的には、直接 dig して出てきたプライベート IP を指定する形でも接続できるかと思います。

実行例

以下では、crds(Connect to RDS のイメージ) というエイリアスで実行できるようにし、いくつかオプションを設定しています。
スクリプト実行時に、上記の内容を記載したファイル一覧から接続対象を対話的に選択し、情報を読み込むようにしています。

以下に実行例を示します。

  • -h : ヘルプを表示

      ❯ crds -h
      "eice_rds.sh" is Connect to RDS Instance with EC2 instance Connect Endpoint.
    
      Usage:
          crds <option>  # This is alias
    
      Options:
          <no-option>   Establish a WebSocket tunnel. Connect using any GUI client.
          -c            Connect to RDS Instance from Command Line Client.
          -d            Disconnect WebSocket tunnel for specified RDS instance.
          -h            Display this message.
    
  • オプションなし: 接続したい RDS を選択し、WebSocket トンネルを張る
    正常に完了した場合は、GUI / CLI いずれでも接続可能な状態となり、後続で必要となる対応を出力します。

      ❯ crds          
      ### 任意のRDSインスタンスを選択
      1) mysql-test
      2) postgresql-test
      #? 1
    
      ### 出力例
      ---------------------------- Trying to open tunnel... ----------------------------
      'mysql-test' 接続用の WebSocket トンネルが確立されました。最大存続時間は 3600 秒です。
      GUIクライアントで接続する場合は、'127.0.0.1:13306' を指定してください。    # ポート番号は、読み込んだファイルで指定した「LOCAL_PORT」の値に置き換わります。
      CLIで接続する場合は、'crds -c' を実行してください。
      トンネルを閉じる場合は、'crds -d' を実行してください。
    
  • -c : CLI から接続

      ❯ crds -c
      ### 任意のRDSインスタンスを選択
      1) mysql-test
      2) postgresql-test
      #? 1
    
      ### 選択した RDS に CLI で接続。実行マシンに mysql / psql コマンドが必要となります。
      ---------------------------- Connect to mysql-test... ----------------------------
      Enter password:   # パスワード入力
      (略)
      mysql>
    
  • -d: トンネルを閉じる
    ※ローカルで使用するポートが重複するとエラーになるので、開いたトンネルを任意のタイミングで閉じられるようにしています。

      ❯ crds -d
      ### 任意のRDSインスタンスを選択
      1) mysql-test
      2) postgresql-test
      #? 1
    
      ### 出力例
      RDS: 'mysql-test' 接続用のトンネルを切断しました。
    

また、上記の例では、select コマンドで選択していますが、
個人的に fzf が好きなので、fzf がインストールされている場合は、そちらを使うように分岐させています。
以下のように設定の内容をプレビューしながら選択できるようにしているので、必要であれば fzf をインストールしてください。

導入手順

事前準備: EIC Endpoint の作成

EIC Endpoint がないと始まらないので、事前準備として作成しておきます。
今回は、サクッと CLI で作成してみます。
検証なので、あまり細かいことを考慮せず以下の設定で行っていますが、
EIC Endpoint は、IAMやセキュリティグループでアクセス制御可能なようなので、導入の際は適宜修正してください。

  • 作成する EIC Endpoint にアタッチするセキュリティグループ
    • インバウンド:設定不要
    • アウトバウンド: MySQL(TCP 3306), PostgreSQL(TCP 5432)の許可
      (※以下の例ではすべてのポートで許可)
  • RDS にアタッチする セキュリティグループ
    • EIC Endpoint にアタッチしたセキュリティグループからのインバウンドを許可

以下の順番でコマンドを実行し、後半で作成した RDS 用のセキュリティグループを、接続したい RDS にアタッチしてください。

  • EIC エンドポイント用のセキュリティグループ作成

    # 環境変数・変数の定義
    export AWS_PROFILE="任意のプロファイル名"
    VPC_ID="任意のVPCのID"
    EICE_SECURITY_GROUP_DESCRIPTION="test sg for ec2 instance connect endpoint"  
      EICE_SECURITY_GROUP_NAME="test-sg-for-instance-connect-endpoint"
    
    # セキュリティグループを作成
    aws ec2 create-security-group \
      --description "${EICE_SECURITY_GROUP_DESCRIPTION}" \
      --group-name "${EICE_SECURITY_GROUP_NAME}" \
      --vpc-id "${VPC_ID}" \
      --tag-specifications "ResourceType=security-group,Tags=[{Key=Name,Value=${EICE_SECURITY_GROUP_NAME}}]"
    
  • EIC エンドポイントの作成 & 上記の SG をアタッチ

    # 変数の定義
    EICE_SECURITY_GROUP_ID="sg-xxxxxxxxxxxxxxx" # 作成した SG のID
    EICE_NAME="test-instance-connect-endpoint"
    SUBNET_ID="subnet-xxxxxxxxxxxxxx" # 任意のサブネットID
    
    # EIC エンドポイントの作成  
    aws ec2 create-instance-connect-endpoint \
      --subnet-id "${SUBNET_ID}" \
      --security-group-ids "${SECURITY_GROUP_ID}" \
      --tag-specifications "ResourceType=instance-connect-endpoint,Tags=[{Key=Name,Value=${EICE_NAME}}]"
    
  • EIC Endpoint のステータス確認

    # 変数の定義
    EICE_ID="eice-xxxxxxxxxxx"  # 作成したエンドポイントのID
    
    # EIC エンドポイントのステータス確認
    aws ec2 describe-instance-connect-endpoints \
      --instance-connect-endpoint-ids "${EICE_ID}" \
      --query "InstanceConnectEndpoints[].State"
    
    # create-in-progress から create-complete に変わればOK
    [
        "create-complete"
    ]
    
  • RDS にアタッチする用の SG を作成

    # 変数の定義
    RDS_SECURITY_GROUP_DESCRIPTION="Allow from ec2 instance connect endpoint"
    RDS_SECURITY_GROUP_NAME="test-sg-from-eice"
    
    # セキュリティグループを作成
    aws ec2 create-security-group \
      --description ${RDS_SECURITY_GROUP_DESCRIPTION} \
      --group-name ${RDS_SECURITY_GROUP_NAME} \
      --tag-specifications "ResourceType=security-group,Tags=[{Key=Name,Value=${RDS_SECURITY_GROUP_NAME}}]"
    
  • EIC Endpoint にアタッチした SG からの接続を許可

     # 変数を定義
     RDS_SECURITY_GROUP_ID="sg-xxxxxxxxxxxxxxxx"  # RDS用のセキュリティグループのID
    
     # MySQL 用のインバウンドルールを追加
     aws ec2 authorize-security-group-ingress \
       --group-id "${RDS_SECURITY_GROUP_ID}" \
       --protocol tcp \
       --port 3306 \
       --source-group "${EICE_SECURITY_GROUP_ID}"
    
     # PostgreSQL 用のインバウンドルールを追加
     aws ec2 authorize-security-group-ingress \
       --group-id "${RDS_SECURITY_GROUP_ID}" \
       --protocol tcp \
       --port 5432 \
       --source-group "${EICE_SECURITY_GROUP_ID}"
    

シェルスクリプト の作成

さて、本題のシェルスクリプトの中身です。
コピペした後に、中盤の関数内で指定している変数 INFORMATION_DIR を任意のパスに書き換えてください。

指定したパスに接続したい RDS の情報を保管したファイルを配置いただければ、ご利用いただけるかと思います。
スクリプトと同じ場所を指定する必要はなく、全く異なる場所でも OK です。

なお、本スクリプトは、以下にて動作確認済となります。

  • OS: Mac OS Ventura (M2) / Ubuntu 22.04(WSL)
  • AWS CLI: バージョン 2.12.0 以上
  • DB: RDS for MySQL, RDS for PostgreSQL
#!/bin/bash
#
# Connect to RDS Instance with EC2 instance Connect Endpoint
set -euo pipefail

################ Usage and Options ################
function usage() {
    cat <<EOF
"$(basename "$0")" is Connect to RDS Instance with EC2 instance Connect Endpoint.

Usage:
    crds <option>  # This is alias
    
Options:
    <no-option>   Establish a WebSocket tunnel. Connect using any GUI client.
    -c            Connect to RDS Instance from Command Line Client.
    -d            Disconnect WebSocket tunnel for specified RDS instance.
    -h            Display this message.

EOF
    exit 1
}

use_cli=false
disconnect_tunnel=false
while getopts 'hcd' opt; do
  case "${opt}" in
    h) usage ;;
    c) use_cli="true" ;;
    d) disconnect_tunnel="true" ;;
    *) 
      if [ -n "$1" ]; then
        # 許可された引数でない場合は usage を実行する
       usage
      fi
      # 引数がない場合はそのまま続行する 
      ;;
  esac
done

################ functions ################
# 接続情報を指定したファイルで指定した変数を読み込む関数
function read_instance_information() {
  # 接続情報を保存したディレクトリを指定
  local INFORMATION_DIR="/Users/hogehoge/hugahuga/instance_list" # インスタンスの情報を格納したディレクトリを指定

  # インスタンスの情報を記述したファイルを選択して読み込み
  local selected_instance
      
  if type fzf >/dev/null 2>&1; then
    # fzf コマンドがある場合の処理
    selected_instance=$(ls -1 "${INFORMATION_DIR}" |
    fzf --prompt "Select the RDS instance. > " \
        --height 50% --layout=reverse --border --preview-window 'right:50%' \
        --preview "cat \"${INFORMATION_DIR}\"/{}"
    )
  else  
    # fzf がない場合は select で処理
    rds_instances=$(ls -1 "${INFORMATION_DIR}")
    select instance in $rds_instances
    do
      selected_instance="${instance}"
      break
    done
  fi

  # 選択したファイルを読み込み
  source "${INFORMATION_DIR}/${selected_instance}"
}

# AWS 認証情報を確認する関数
function check_aws_profile() {
  set +e
  local check_aws_profile
  check_aws_profile=$(aws sts get-caller-identity --profile "${TMP_AWS_PROFILE}" 2>&1)

  if [[ "${check_aws_profile}" == *"Token has expired"* ]]; then
    # AWS Identity Center を利用しており、セッションが切れている場合はログイン
    echo -e "\n----------------------------\nYour Session has expired! Please login...\n----------------------------\n"
    aws sso login --sso-session "${SSO_SESSION_NAME}"
  elif [[ "${check_aws_profile}" != *"Account"* ]]; then
    # その他のエラーの場合は、そのまま表示
    echo "指定された PROFILE の情報が正しくありません。エラーの内容を確認してください"
    echo -e "\nError:${check_aws_profile}"
    exit 1
  fi
  set -e
}

# EC2 Instance Connect Endpoint を利用して Websocket トンネルを張る関数
function open_tunnel() {
  local MAX_TUNNEL_DURATION="3600" # WebSocket トンネルを存続させる最大時間(秒単位)

  set +e
  port_used_pid=$(lsof -i :"${LOCAL_PORT}" | grep "LISTEN" | awk '{print $2}')
 
  # ポートを使用している場合は、利用しているプロセスによるものかどうかを確認
  if [[ -n "${port_used_pid}" ]]; then
    ps -p "${port_used_pid}" -f | grep -w "${RDS_PRIVATE_IP_ADDRESS}" > /dev/null
    if [ $? -eq 0 ]; then
      echo "すでに '${RDS_NAME}' 用のトンネルは開かれています。"
      exit 1
    else
      echo "他のプロセスで指定されたポートを利用中です。"
      echo "ローカルポート: ${LOCAL_PORT} を利用中のプロセス"
      ps -p "${port_used_pid}" -f 
      exit 1
    fi
  fi 
  
  # バックグラウンドで実行するように設定
  echo "---------------------------- Trying to open tunnel... ----------------------------"
  aws ec2-instance-connect open-tunnel \
    --instance-connect-endpoint-id "${INSTANCE_CONNECT_ENDPOINT_ID}" \
    --private-ip-address "${RDS_PRIVATE_IP_ADDRESS}" \
    --local-port "${LOCAL_PORT}" \
    --remote-port "${REMOTE_PORT}" \
    --max-tunnel-duration "${MAX_TUNNEL_DURATION}" \
    --profile "${TMP_AWS_PROFILE}" \
    >/dev/null 2>&1 &
  
  # 数秒待って、ポートの使用状況を確認
  sleep 5
  lsof -i :"${LOCAL_PORT}" | grep "LISTEN" > /dev/null
  if [ $? -eq 0 ]; then
    echo "'${RDS_NAME}' 接続用の WebSocket トンネルが確立されました。最大存続時間は ${MAX_TUNNEL_DURATION} 秒です。"
    echo "GUIクライアントで接続する場合は、'127.0.0.1:${LOCAL_PORT}' を指定してください。"
    echo "CLIで接続する場合は、'crds -c' を実行してください。"
    echo "トンネルを閉じる場合は、'crds -d' を実行してください。"
  else
    echo "'${RDS_NAME}' への WebSocket トンネルの確立に失敗しました。指定したRDSの情報が正しいかどうか確認してください。"
  fi
  set -e
}

# トンネルを閉じる関数
function close_tunnel(){
  set +e
  port_used_pid=$(lsof -i :"${LOCAL_PORT}" | grep "LISTEN" | awk '{print $2}')
 
  # 指定したローカルのポートが利用されていなければ、何もしない
  if [[ -z "${port_used_pid}" ]]; then
    echo "現在、ローカルポート:${LOCAL_PORT} は利用されていません。"
    exit 1
  fi

  # ポートを利用中の場合は、指定したRDS に関連するものかどうかを確認
  ps -p "${port_used_pid}" -f | grep -w "${RDS_PRIVATE_IP_ADDRESS}" > /dev/null
  if [ $? -eq 0 ]; then
    # トンネルのプロセスを kill
    kill "${port_used_pid}" 
    echo "RDS: '${RDS_NAME}' 接続用のトンネルを切断しました。"
  else
    echo "他のプロセスで使用しているため、トンネルを閉じられません。"
    echo "ローカルポート:${LOCAL_PORT} を利用中のプロセス"
    ps -p "${port_used_pid}" -f 
  fi
  set -e
}

# コマンドラインクライアントで接続する関数
function connect_with_cli() {
  echo "---------------------------- Connect to ${RDS_NAME}... ----------------------------"

  # CLIで接続
  if [[ "$DB_TYPE" == "MySQL" || "$DB_TYPE" == "MariaDB" ]]; then
    mysql -h 127.0.0.1 -P "${LOCAL_PORT}" -u "${DB_USER}" -p "${DB_NAME}"
  elif [[ "$DB_TYPE" == "PostgreSQL" ]]; then
    psql -h 127.0.0.1 -p "${LOCAL_PORT}" -U "${DB_USER}" -d "${DB_NAME}"
  fi
}

################ 実行部分 ################
function main() {
  read_instance_information
  if [[ "${use_cli}" == "true" ]]; then
    connect_with_cli
  elif [[ "${disconnect_tunnel}" == "true" ]]; then
    close_tunnel
  else
    check_aws_profile
    open_tunnel
  fi
}

main "$@" 

エイリアスの設定

上記のスクリプトをどこからでも呼び出せるようにするために、エイリアスを設定します。
~/.zshrc など、お使いのシェルの設定ファイルに、以下の内容を追記してください。

alias crds="/bin/bash <スクリプト配置場所までのパス>/eice_rds.sh"

その後、追記した設定を読み込ませ、which などで alias が効いていることが確認できれば OK です。
前述の「シェルスクリプトの動作」に記載したコマンドで実行できるかと思います。

❯ zsh
❯ which crds 
crds: aliased to /bin/bash <スクリプト配置場所までのパス>/eice_rds.sh

以降、接続したい対象が増えた際には、
INFORMATION_DIR に指定したディレクトリ内にファイルを追加することで、接続可能な RDS を追加できるようになる想定です。

解説

各関数の処理について、簡単に解説します。

  • usage
    • スクリプトの使用方法を出力します
    • -h を付与した際や、引数を正しく指定していない場合に実行されます
  • read_instance_information
    • 指定したディレクトリに存在するファイルの一覧を出力します
    • 利用したいファイルを対話的に選択し、指定したファイルで定義されている変数を読み込みます
    • 対象の選択には fzf がインストールされていれば fzf を、されていなければ select を用います。
    • -h を付与しない場合は、常に実行されます。
  • check_aws_profile
    • 指定したプロファイルが正しいか検証します。
    • IAM Identity Center を利用しており、セッションが切れている場合は自動でログインの処理を行います。
    • オプションを付与しない場合に、実行されます。
  • open_tunnel
    • 定義した EIC Endpoint を利用して、トンネルを確立します。指定したローカルのポートをすでに使用中の場合はエラーになります。
    • オプションを付与しない場合に実行されます。
  • close_tunnel
    • 確立したトンネルを閉じます。ポートの競合等が発生している場合に、任意のタイミングで閉じられるようにしています。
    • -d オプションを付与した際に実行されます。
  • connect_with_cli
    • CLI で対象の RDS インスタンスに接続します。パスワードは手動入力です。
    • -c オプションを付与した際に実行されます。
  • main
    • 実行部分です。オプションに応じて処理を決定します。

まとめ

新機能である EC2 Instance Connect Endpoint を活用するスクリプトをご紹介してみました。
勢いで書いたので、適宜アップデートしていく想定ですが、雛形としてご活用いただければと思います。

導入までが少し手間かもなのと、そもそもシェルスクリプトでやるべきではないのでは…?というくらい長くなってしまったので、もっとシンプルにしていければよいですね。
(Go とか Deno とかで CLI ツール作れるようになりたい…)

お読みいただきありがとうございました。
この記事が何かの参考になれば幸いです。