ECS FargateでSSMセッションマネージャーのリモートホストのポートフォワード環境を構築する

概要

先日リリースされたSystems Manager セッションマネージャーリモートホストへのポートフォワード機能を使って、ローカルから直接プライベートサブネットのRDSなどへトンネリングする環境を、ECS on Fargateで構築してみました。

具体的には、プライベートサブネットにあるMySQLPostgreSQLに、ローカルPCのターミナル等から直接アクセスできるようになります。


目次

目次


リモートホストへのポートフォワード

Systems Manager(SSM)のセッションマネージャーのリモートホストへのポートフォワード機能ですが、以下の記事に説明を記載しています。

go-to-k.hatenablog.com


システム構成


処理のフローとしては、以下の流れになります。

ECS on Fargate

  • SSMエージェントを入れたコンテナをECS on Fargateで立ち上げ、マネージドインスタンスとしてSSMに登録
  • SSMエージェントが発行したインスタンスIDをSSMパラメータストアに登録

クライアント(ローカルPC)


※補足(現在はこの構成でなくても出来ます!)

SSMセッションマネージャーのリモートホストのポートフォワードの機能が出た当初は、ECS Fargate内にデフォルトで入っているSSMエージェントのバージョンが古くそのままでは使えませんでした。

そのため本記事では、「SSMエージェントをわざわざDockerfile内でインストール」して、かつ「コンテナ内からSSMマネージドインスタンス(ノード)の登録」をして使用しています。

現在はリモートホストのポートフォワード対応済みのバージョンがFargate内に入っているためこれらは必要がなく、ECSを立ち上げた後に以下のようなコマンドでポートフォワードが可能になっています。

また、記事に出てくるSSMパラメータストアなども必要ありません。

※targetにマネージドインスタンスを指定するのではなく、ecs:(クラスター)_(タスクID)_(コンテナID)を指定するだけでOK

TARGET="ecs:${CLUSTER}_${TASK_ID}_${CONTAINER_ID}"

PARAMETERS="{\"host\":[\"${DB_HOST}\"],\"portNumber\":[\"${REMOTE_DB_PORT}\"], \"localPortNumber\":[\"${LOCAL_DB_PORT}\"]}"

aws ssm start-session \
    --target ${TARGET} \
    --document-name AWS-StartPortForwardingSessionToRemoteHost \
    --parameters "${PARAMETERS}"


またDockerfileも以下のようなシンプルなものでOKです。

FROM arm64v8/ubuntu:20.04

WORKDIR /

ENTRYPOINT ["tail", "-F", "/dev/null"]


前提

プライベートサブネットのFargateからSSMやECRにアクセスするためにはNAT GatewayVPCエンドポイントが必要になりますが、本記事ではすでにあることを前提として省略しています。


構築

CloudFormationで構築します。

デプロイ用のシェルファイルを叩くだけで、AWS環境にECS on Fargate + Systems Managerによるトンネリング環境が構築されるようになっています。

また実際にローカル環境からトンネリングする際も、1コマンドでいけるようスクリプトを提供し、パスを通すところまでも用意しています。


※上記前提の通りNAT GatewayVPCエンドポイント、またVPCなどのリソースも構築済みを前提としているため、本CloudFormationテンプレートには含まれていません。 (それをテンプレートのパラメータとして渡すようにしています。)


※全コードは以下GitHubに載せています。詳しい使い方はREADMEにあります。

github.com


スタック・スクリプト構成

デプロイスクリプト・CloudFormation

以下のようなデプロイスクリプト・CloudFormationスタック構成になっています。


ローカル実行スクリプト

また、ローカルPCで実行するスクリプトは以下になります。

詳細解説(デプロイ編)

重要なところを抜粋して解説します。

01_deploy_preparation.sh

01_deploy_preparation.sh 全文(折りたたみ)

#!/bin/bash

set -eu

cd $(dirname $0) && cd ../../

trap 'echo Error Occurred!!! Exit...' ERR

CFN_TEMPLATE="./deploy_scripts/resources/preparation.yaml"
SESSION_MANAGER_RUN_SHELL_CONTENT_PATH="./deploy_scripts/sessionManagerRunShell.json"

DEPLOYMODE="on"
PROFILE=""
APPNAME="Bastion"
REGION="ap-northeast-1"

while getopts p:d: OPT; do
    case $OPT in
        p)
            PROFILE="$OPTARG"
            ;;
        d)
            DEPLOYMODE="$OPTARG"
            ;;
    esac
done

if [ "${DEPLOYMODE}" != "on" -a "${DEPLOYMODE}" != "off" ]; then
    echo "required DEPLOYMODE"
    echo "[-p (profile)](option): aws profile name"
    echo "[-d on|off](option): deploy mode (off=change set mode)"
    exit 0
fi

function deployPreparation {
    local profileOption=""

    if [ -n "${1:-}" ]; then
        profileOption="--profile ${profile}"
    fi

    local repositoryName=$(echo "${APPNAME}-ECR" | tr '[:upper:]' '[:lower:]')
    local stackName="${APPNAME}-Preparation"


    local changesetOption="--no-execute-changeset"

    if [ "${DEPLOYMODE}" == "on" ]; then
        echo "deploy mode"
        changesetOption=""
    fi

    aws cloudformation deploy \
        --stack-name ${stackName} \
        --region ${REGION} \
        --template-file ${CFN_TEMPLATE} \
        --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
        --no-fail-on-empty-changeset \
        ${changesetOption} \
        --parameter-overrides \
        AppName=${APPNAME} \
        RepositoryName=${repositoryName} \
        ${profileOption}

    if [ "${DEPLOYMODE}" == "on" ]; then
        local accountId=$(aws sts get-caller-identity --query "Account" --output text ${profileOption})
        local settingId="arn:aws:ssm:${REGION}:${accountId}:servicesetting/ssm/managed-instance/activation-tier"
        local settingValue=$(aws ssm get-service-setting --setting-id ${settingId} ${profileOption} | jq -r .ServiceSetting.settingValue)
        if [ "${settingValue}" == "standard" ]; then
            aws ssm update-service-setting \
                --setting-id ${settingId} \
                --setting-value advanced \
                ${profileOption}
        fi

        local documentCount=$( \
           aws ssm list-documents \
               --filters Key=Name,Values=SSM-SessionManagerRunShell \
               ${profileOption} \
           | jq '.DocumentIdentifiers|length' \
       )

        if [ ${documentCount} -eq 0 ]; then
            aws ssm create-document \
                --name "SSM-SessionManagerRunShell" \
                --content "file://${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
                --document-type "Session" \
                ${profileOption} > /dev/null
        else
            local currentSessionManagerRunShellDocument=$( \
               aws ssm get-document \
                   --name "SSM-SessionManagerRunShell" \
                   --document-version \$LATEST \
                   ${profileOption} \
               | jq -r .Content \
               | jq -c . \
           )
            local newSessionManagerRunShellDocument=$( \
               cat "${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
               | jq -c '.' \
           )

            if [ "${currentSessionManagerRunShellDocument}" != "${newSessionManagerRunShellDocument}" ]; then
                aws ssm update-document \
                    --name "SSM-SessionManagerRunShell" \
                    --content "file://${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
                    --document-version "\$LATEST" \
                    ${profileOption} > /dev/null
            fi
        fi
    fi
}

deployPreparation "${PROFILE:-}"

preparation.yaml 全文(折りたたみ)

AWSTemplateFormatVersion: '2010-09-09'
Description: ECR and SSM Parameter Store

# ------------------------------------------------------------#
#  Metadata
# ------------------------------------------------------------#
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "AppName"
        Parameters:
          - AppName
      - Label:
          default: "RepositoryName"
        Parameters:
          - RepositoryName

# ------------------------------------------------------------#
#  Parameters
# ------------------------------------------------------------#
Parameters:
  AppName:
    Type: String
  RepositoryName:
    Type: String

# ------------------------------------------------------------#
#  Resources
# ------------------------------------------------------------#
Resources:
  ECR:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: !Ref RepositoryName
      ImageTagMutability: IMMUTABLE
      ImageScanningConfiguration:
        ScanOnPush: "true"
      LifecyclePolicy:
        LifecyclePolicyText: |
          {
            "rules": [
              {
                "rulePriority": 1,
                "description": "Delete more than 20 images",
                "selection": {
                  "tagStatus": "any",
                  "countType": "imageCountMoreThan",
                  "countNumber": 20
                },
                "action": {
                  "type": "expire"
                }
              }
            ]
          }
      Tags:
        - Key: Name
          Value: !Sub "${AppName}-ECR"

  ManagedInstanceIDParameter:
    Type: AWS::SSM::Parameter
    Properties:
      Name: !Sub "/ManagedInstanceIDParameter/${AppName}"
      Type: "String"
      Value: "ManagedInstanceIDParameter"
      Description: "Managed Instance ID Parameter" 

# ------------------------------------------------------------#
#  Outputs
# ------------------------------------------------------------#
Outputs:
  #ECRRepositoryUri:
    # Export:
    #   Name: !Sub "${AppName}-ECR-RepositoryUri"
  #  Description: "ECR RepositoryUri"
  #  Value: !GetAtt ECR.RepositoryUri #Not currently supported by AWS CloudFormation.

  ECRArn:
    Export:
      Name: !Sub "${AppName}-ECR-Arn"
    Description: "ECR ARN"
    Value: !GetAtt ECR.Arn

  ManagedInstanceIDParameterName:
    Export:
      Name: !Sub "${AppName}-SSM-ManagedInstanceIDParameterName"
    Description: "Managed Instance ID Parameter Name"
    Value: !Ref ManagedInstanceIDParameter


アドバンスドインスタンスティア

FargateでSSMエージェントを起動し、マネージドインスタンスとして登録するためには、Systems Managerのインスタンスティアの設定をアドバンスドインスタンスティアに変更する必要があります。

local accountId=$(aws sts get-caller-identity --query "Account" --output text ${profileOption})
local settingId="arn:aws:ssm:${REGION}:${accountId}:servicesetting/ssm/managed-instance/activation-tier"
local settingValue=$(aws ssm get-service-setting --setting-id ${settingId} ${profileOption} | jq -r .ServiceSetting.settingValue)
if [ "${settingValue}" == "standard" ]; then
    aws ssm update-service-setting \
        --setting-id ${settingId} \
        --setting-value advanced \
        ${profileOption}
fi


General preferences

また、SSMセッションマネージャーの接続設定(General preferences)も自由に変更できるようにしました。(すでに設定があれば更新、なければ作成)

local documentCount=$( \
    aws ssm list-documents \
        --filters Key=Name,Values=SSM-SessionManagerRunShell \
        ${profileOption} \
    | jq '.DocumentIdentifiers|length' \
)

if [ ${documentCount} -eq 0 ]; then
    aws ssm create-document \
        --name "SSM-SessionManagerRunShell" \
        --content "file://${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
        --document-type "Session" \
        ${profileOption} > /dev/null
else
    local currentSessionManagerRunShellDocument=$( \
        aws ssm get-document \
            --name "SSM-SessionManagerRunShell" \
            --document-version \$LATEST \
            ${profileOption} \
        | jq -r .Content \
        | jq -c . \
    )
    local newSessionManagerRunShellDocument=$( \
        cat "${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
        | jq -c '.' \
    )

    if [ "${currentSessionManagerRunShellDocument}" != "${newSessionManagerRunShellDocument}" ]; then
        aws ssm update-document \
            --name "SSM-SessionManagerRunShell" \
            --content "file://${SESSION_MANAGER_RUN_SHELL_CONTENT_PATH}" \
            --document-version "\$LATEST" \
            ${profileOption} > /dev/null
    fi
fi


sessionManagerRunShell.json

この設定ファイルでタイムアウト設定などができます。

{
    "schemaVersion": "1.0",
    "description": "Document to hold regional settings for Session Manager",
    "sessionType": "Standard_Stream",
    "inputs": {
        "s3BucketName": "",
        "s3KeyPrefix": "",
        "s3EncryptionEnabled": true,
        "cloudWatchLogGroupName": "",
        "cloudWatchEncryptionEnabled": true,
        "idleSessionTimeout": "30",
        "maxSessionDuration": "30",
        "cloudWatchStreamingEnabled": true,
        "kmsKeyId": "",
        "runAsEnabled": false,
        "runAsDefaultUser": "",
        "shellProfile": {
            "windows": "",
            "linux": ""
        }
    }
}


02_build_ecs.sh

Dockerfile(後述)をビルドし、ECRにプッシュします。

02_build_ecs.sh 全文(折りたたみ)

#!/bin/bash

set -eu

cd $(dirname $0) && cd ../../

trap 'echo Error Occurred!!! Exit...' ERR

PROFILE=""
APPNAME="Bastion"
REGION="ap-northeast-1"

while getopts p: OPT; do
    case $OPT in
        p)
            PROFILE="$OPTARG"
            ;;
    esac
done

function buildECS {
    local profileOption=""

    if [ -n "${1:-}" ]; then
        profileOption="--profile ${profile}"
    fi

    #### docker build & push
    local repositoryName=$(echo "${APPNAME}-ECR" | tr '[:upper:]' '[:lower:]')
    local accountId=$(aws sts get-caller-identity --query "Account" --output text ${profileOption})
    local repositoryEnddpoint="${accountId}.dkr.ecr.ap-northeast-1.amazonaws.com"
    local repositoryUri="${repositoryEnddpoint}/${repositoryName}"

    local ecrTag="$(git rev-parse HEAD)"

    local ecrTagPrevious=$(aws ecr describe-images --repository-name ${repositoryName} \
       --query "reverse(sort_by(imageDetails[*], &imagePushedAt))[0].imageTags[0]" \
       ${profileOption} |
       sed -e 's/"//g')

    docker build \
        --cache-from ${ecrTagPrevious} \
        --build-arg BUILDKIT_INLINE_CACHE=1 \
        -t ${repositoryName} \
        .

    docker tag ${repositoryName}:latest ${repositoryUri}:${ecrTag}


    ### Dockle
    local dockleVersion=$(
       curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \
       grep '"tag_name":' | \
       sed -E 's/.*"v([^"]+)".*/\1/' \
   )

    docker run \
        --rm \
        -v /var/run/docker.sock:/var/run/docker.sock \
        -v $(pwd)/.dockleignore:/.dockleignore \
        -e AWS_DEFAULT_REGION=${REGION} \
        goodwithtech/dockle:v${dockleVersion} \
        --exit-code 1 \
        --exit-level "FATAL" \
        ${repositoryUri}:${ecrTag}

    aws ecr get-login-password --region ${REGION} ${profileOption} |
        docker login --username AWS --password-stdin ${repositoryEnddpoint}

    docker push ${repositoryUri}:${ecrTag}
}

buildECS "${PROFILE:-}"


Dockle

本題と関係ないのですが、dockerの脆弱性検査ツールである「Dockle」によるセキュリティ検査を挟んでいます。

   ### Dockle
    local dockleVersion=$(
       curl --silent "https://api.github.com/repos/goodwithtech/dockle/releases/latest" | \
       grep '"tag_name":' | \
       sed -E 's/.*"v([^"]+)".*/\1/' \
   )

    docker run \
        --rm \
        -v /var/run/docker.sock:/var/run/docker.sock \
        -v $(pwd)/.dockleignore:/.dockleignore \
        -e AWS_DEFAULT_REGION=${REGION} \
        goodwithtech/dockle:v${dockleVersion} \
        --exit-code 1 \
        --exit-level "FATAL" \
        ${repositoryUri}:${ecrTag}


無視するエラー・ワーニングもファイルとして定義できます。

# Clear apt-get caches ->BUILDKIT使っていてクリアしないため
DKL-DI-0005


Dockerfile

肝心なFargateに載せるコンテナ部分です。

まずDockerfileですが、イメージはubuntuで、BuildKitを使用しています。SSMエージェントをインストールし、後述するシェルスクリプトを起動します。

Dockerfile

# syntax=docker/dockerfile:1

FROM 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 \
    && mv /etc/amazon/ssm/seelog.xml.template /etc/amazon/ssm/seelog.xml \
    && rm /tmp/amazon-ssm-agent.deb

COPY deploy_scripts/run.sh /run.sh

CMD ["bash", "/run.sh"]


run.sh

次にコンテナ内で実行するシェルスクリプトです。

具体的にはSSMエージェントを起動したり、マネージドインスタンス登録する処理になります。

run.sh 全文(折りたたみ)

#!/bin/bash
set -e

SSM_SERVICE_ROLE_NAME="${APP_NAME}-SSMServiceRole"
SSM_PARAMETER_NAME="/ManagedInstanceIDParameter/${APP_NAME}"
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})

SSM_ACTIVATION_ID=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationId)
SSM_ACTIVATION_CODE=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationCode)

result=$(amazon-ssm-agent -register -code "${SSM_ACTIVATION_CODE}" -id "${SSM_ACTIVATION_ID}" -region ${AWS_REGION})

trap "cleanup" EXIT ERR

echo "${result}"

MANAGED_INSTANCE_ID=$(echo ${result} | grep -Eo "instance-id: .*$" | grep -Eo "[^ ]*$")
echo "Managed instance-id: ${MANAGED_INSTANCE_ID}"

aws ssm put-parameter --name "${SSM_PARAMETER_NAME}" --value "${MANAGED_INSTANCE_ID}" --type String --overwrite

aws ssm delete-activation --activation-id ${SSM_ACTIVATION_ID}

amazon-ssm-agent


エラー対処

いきなりエラー対処とありますが、これがないとエラーが出たため、最初に削除しています。

# エラー対処
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})

SSM_ACTIVATION_ID=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationId)
SSM_ACTIVATION_CODE=$(echo ${ACTIVATION_PARAMETERS} | jq -r .ActivationCode)

result=$(amazon-ssm-agent -register -code "${SSM_ACTIVATION_CODE}" -id "${SSM_ACTIVATION_ID}" -region ${AWS_REGION})


SSMパラメータストアにインスタンスID登録

ログからインスタンスIDを抽出し、それをSSMパラメータストア(事前にCloudFormationにて作成済み)に登録します。

MANAGED_INSTANCE_ID=$(echo ${result} | grep -Eo "instance-id: .*$" | grep -Eo "[^ ]*$")
echo "Managed instance-id: ${MANAGED_INSTANCE_ID}"

aws ssm put-parameter --name "${SSM_PARAMETER_NAME}" --value "${MANAGED_INSTANCE_ID}" --type String --overwrite


アクティベーション削除・SSMエージェント起動

先ほど作成したアクティベーションを削除し、最後にSSMエージェントを起動します。

aws ssm delete-activation --activation-id ${SSM_ACTIVATION_ID}

amazon-ssm-agent


終了時の後処理

また、trapでコンテナ終了時にマネージドインスタンスの登録を解除するようにします。

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


03_deploy_ecs.sh

最後にECSを構築・デプロイするところです。

03_deploy_ecs.sh 全文(折りたたみ)

#!/bin/bash

set -eu

cd $(dirname $0) && cd ../../

trap 'echo Error Occurred!!! Exit...' ERR

CFN_TEMPLATE="./deploy_scripts/resources/ecs.yaml"
CONFIG_FILE_NAME="./deploy_scripts/ecs.config"

DEPLOYMODE="on"
PROFILE=""
APPNAME="Bastion"
REGION="ap-northeast-1"

VPC_ID="vpc-*****************"
SUBNET_ID1="subnet-*****************"
SUBNET_ID2="subnet-*****************"

while getopts p:d: OPT; do
    case $OPT in
        p)
            PROFILE="$OPTARG"
            ;;
        d)
            DEPLOYMODE="$OPTARG"
            ;;
    esac
done

if [ "${DEPLOYMODE}" != "on" -a "${DEPLOYMODE}" != "off" ]; then
    echo "required DEPLOYMODE"
    echo "[-p (profile)](option): aws profile name"
    echo "[-d on|off](option): deploy mode (off=change set mode)"
    exit 0
fi

function deployECS {
    local profileOption=""

    if [ -n "${1:-}" ]; then
        profileOption="--profile ${profile}"
    fi

    local stackName="${APPNAME}-ECS"


    if ! [ -f "${CONFIG_FILE_NAME}" ]; then
        echo "====================================="
        echo "[${CONFIG_FILE_NAME}]ファイルがありません"
        echo "====================================="
        return 1
    fi

    source "${CONFIG_FILE_NAME}"

    if [ -z "${ECSTaskCPUUnit:-}" ] \
        || [ -z "${ECSTaskMemory:-}" ] \
        || [ -z "${ECSRestMemory:-}" ] \
        || [ -z "${ECSTaskDesiredCount:-}" ] \
        || [ -z "${TaskMinContainerCount:-}" ] \
        || [ -z "${TaskMaxContainerCount:-}" ] \
        || [ -z "${TaskMinContainerCountDuringOffPeakTime:-}" ] \
        || [ -z "${TaskMaxContainerCountDuringOffPeakTime:-}" ] \
        || [ -z "${OffPeakStartTimeCron:-}" ] \
        || [ -z "${OffPeakEndTimeCron:-}" ] \
        || [ -z "${ECSDeploymentMaximumPercent:-}" ] \
        || [ -z "${ECSDeploymentMinimumHealthyPercent:-}" ] \
        || [ -z "${ServiceScaleEvaluationPeriods:-}" ] \
        || [ -z "${ServiceCpuScaleOutThreshold:-}" ] \
        || [ -z "${ServiceCpuScaleInThreshold:-}" ]; then
        echo "コンフィグファイルに設定漏れがあります"

        echo "ECSTaskCPUUnit: ${ECSTaskCPUUnit:-}"
        echo "ECSTaskMemory: ${ECSTaskMemory:-}"
        echo "ECSRestMemory: ${ECSRestMemory:-}"
        echo "ECSTaskDesiredCount: ${ECSTaskDesiredCount:-}"
        echo "TaskMinContainerCount: ${TaskMinContainerCount:-}"
        echo "TaskMaxContainerCount: ${TaskMaxContainerCount:-}"
        echo "TaskMinContainerCountDuringOffPeakTime: ${TaskMinContainerCountDuringOffPeakTime:-}"
        echo "TaskMaxContainerCountDuringOffPeakTime: ${TaskMaxContainerCountDuringOffPeakTime:-}"
        echo "OffPeakStartTimeCron: ${OffPeakStartTimeCron:-}"
        echo "OffPeakEndTimeCron: ${OffPeakEndTimeCron:-}"
        echo "ECSDeploymentMaximumPercent: ${ECSDeploymentMaximumPercent:-}"
        echo "ECSDeploymentMinimumHealthyPercent: ${ECSDeploymentMinimumHealthyPercent:-}"
        echo "ServiceScaleEvaluationPeriods: ${ServiceScaleEvaluationPeriods:-}"
        echo "ServiceCpuScaleOutThreshold: ${ServiceCpuScaleOutThreshold:-}"
        echo "ServiceCpuScaleInThreshold: ${ServiceCpuScaleInThreshold:-}"

        return 1
    fi


    #### docker build & push
    local repositoryName=$(echo "${APPNAME}-ECR" | tr '[:upper:]' '[:lower:]')
    local accountId=$(aws sts get-caller-identity --query "Account" --output text ${profileOption})
    local repositoryEnddpoint="${accountId}.dkr.ecr.ap-northeast-1.amazonaws.com"
    local repositoryUri="${repositoryEnddpoint}/${repositoryName}"

    local ecrTag="$(git rev-parse HEAD)"

    #### parameters for ecs
    local ecsImageName="${repositoryUri}:${ecrTag}"
    local ecsAppTaskMemoryReservation=$(expr ${ECSTaskMemory} - ${ECSRestMemory})

    local s3BucketNameForECSExecLogs=$(echo "ecs-exec-logs-${APPNAME}-${accountId}" | tr '[:upper:]' '[:lower:]')


    local changesetOption="--no-execute-changeset"

    if [ "${DEPLOYMODE}" == "on" ]; then
        echo "deploy mode"
        changesetOption=""
    fi

    aws cloudformation deploy \
        --stack-name ${stackName} \
        --region ${REGION} \
        --template-file ${CFN_TEMPLATE} \
        --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM \
        --no-fail-on-empty-changeset \
        ${changesetOption} \
        --parameter-overrides \
        AppName=${APPNAME} \
        VpcID="${VPC_ID}" \
        SubnetID1="${SUBNET_ID1}" \
        SubnetID2="${SUBNET_ID2}" \
        ECSTaskCPUUnit=${ECSTaskCPUUnit} \
        ECSTaskMemory=${ECSTaskMemory} \
        ECSAppTaskMemoryReservation=${ecsAppTaskMemoryReservation} \
        ECSImageName=${ecsImageName} \
        ECSTaskDesiredCount=${ECSTaskDesiredCount} \
        ECSDeploymentMaximumPercent=${ECSDeploymentMaximumPercent} \
        ECSDeploymentMinimumHealthyPercent=${ECSDeploymentMinimumHealthyPercent} \
        ServiceScaleEvaluationPeriods=${ServiceScaleEvaluationPeriods} \
        ServiceCpuScaleOutThreshold=${ServiceCpuScaleOutThreshold} \
        ServiceCpuScaleInThreshold=${ServiceCpuScaleInThreshold} \
        TaskMinContainerCount=${TaskMinContainerCount} \
        TaskMaxContainerCount=${TaskMaxContainerCount} \
        TaskMinContainerCountDuringOffPeakTime=${TaskMinContainerCountDuringOffPeakTime} \
        TaskMaxContainerCountDuringOffPeakTime=${TaskMaxContainerCountDuringOffPeakTime} \
        OffPeakStartTimeCron="${OffPeakStartTimeCron}" \
        OffPeakEndTimeCron="${OffPeakEndTimeCron}" \
        S3BucketNameForECSExecLogs=${s3BucketNameForECSExecLogs} \
        ${profileOption}
}

deployECS "${PROFILE:-}"

ecs.yaml 全文(折りたたみ)

AWSTemplateFormatVersion: 2010-09-09
Description: ECS

# ------------------------------------------------------------#
#  Metadata
# ------------------------------------------------------------#
Metadata:
  "AWS::CloudFormation::Interface":
    ParameterGroups:
      - Label:
          default: "AppName"
        Parameters:
          - AppName
      - Label:
          default: "VpcID"
        Parameters:
          - VpcID
      - Label:
          default: "SubnetID1"
        Parameters:
          - SubnetID1
      - Label:
          default: "SubnetID2"
        Parameters:
          - SubnetID2
      - Label:
          default: "S3BucketNameForECSExecLogs"
        Parameters:
          - S3BucketNameForECSExecLogs
      - Label:
          default: "Fargate for ECS Configuration"
        Parameters:
          - ECSImageName
          - ECSTaskCPUUnit
          - ECSTaskMemory
          - ECSAppTaskMemoryReservation
          - ECSTaskDesiredCount
          - ECSDeploymentMaximumPercent
          - ECSDeploymentMinimumHealthyPercent
      - Label:
          default: "Scaling Configuration"
        Parameters:
          - ServiceScaleEvaluationPeriods
          - ServiceCpuScaleOutThreshold
          - ServiceCpuScaleInThreshold
          - TaskMinContainerCount
          - TaskMaxContainerCount
          - TaskMinContainerCountDuringOffPeakTime
          - TaskMaxContainerCountDuringOffPeakTime
          - OffPeakStartTimeCron
          - OffPeakEndTimeCron

# ------------------------------------------------------------#
#  Parameters
# ------------------------------------------------------------#
Parameters:
  AppName:
    Type: String

  VpcID:
    Type: String

  SubnetID1:
    Type: String

  SubnetID2:
    Type: String

  S3BucketNameForECSExecLogs:
    Type: String

  ECSImageName:
    Type: String

  ECSTaskCPUUnit:
    AllowedValues: [256, 512, 1024, 2048, 4096]
    Type: String
    Default: 256

  ECSTaskMemory:
    AllowedValues: [256, 512, 1024, 2048, 4096]
    Type: String
    Default: 512

  ECSAppTaskMemoryReservation:
    Type: Number
    Default: 64

  ECSTaskDesiredCount:
    Type: Number
    Default: 1

  ECSDeploymentMaximumPercent:
    Type: Number
    Default: 200

  ECSDeploymentMinimumHealthyPercent:
    Type: Number
    Default: 100

  ServiceScaleEvaluationPeriods:
    Type: Number
    Default: 2
    MinValue: 2

  ServiceCpuScaleOutThreshold:
    Type: Number
    Description: Average CPU value to trigger auto scaling out
    Default: 50
    MinValue: 0
    MaxValue: 100
    ConstraintDescription: Value must be between 0 and 100

  ServiceCpuScaleInThreshold:
    Type: Number
    Description: Average CPU value to trigger auto scaling in
    Default: 25
    MinValue: 0
    MaxValue: 100
    ConstraintDescription: Value must be between 0 and 100

  TaskMinContainerCount:
    Type: Number
    Description: Minimum number of containers to run for the service
    Default: 1
    MinValue: 0
    ConstraintDescription: Value must be >= 0

  TaskMaxContainerCount:
    Type: Number
    Description: Maximum number of containers to run for the service when auto scaling out
    Default: 2
    MinValue: 0
    ConstraintDescription: Value must be >= 0

  TaskMinContainerCountDuringOffPeakTime:
    Type: Number
    Description: Minimum number of containers to run for the service during OffPeak time
    Default: 1
    MinValue: 0
    ConstraintDescription: Value must be >= 0

  TaskMaxContainerCountDuringOffPeakTime:
    Type: Number
    Description: Maximum number of containers to run for the service during OffPeak time
    Default: 1
    MinValue: 0
    ConstraintDescription: Value must be >= 0

  OffPeakStartTimeCron:
    Type: String

  OffPeakEndTimeCron:
    Type: String

# ------------------------------------------------------------#
#  Resources
# ------------------------------------------------------------#
Resources:
  # ------------------------------------------------------------#
  #  Security Group
  # ------------------------------------------------------------#
  ECSServiceSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref VpcID
      GroupName: !Sub "${AppName}-ECS-sg"
      GroupDescription: "ECS security group"
      Tags:
        - Key: "Name"
          Value: !Sub "${AppName}-ECS-sg"

  # ------------------------------------------------------------#
  # ECS Cluster
  # ------------------------------------------------------------#
  ECSCluster:
    Type: "AWS::ECS::Cluster"
    Properties:
      ClusterName: !Sub "${AppName}-Cluster"
      Configuration:
        ExecuteCommandConfiguration:
          LogConfiguration:
            CloudWatchLogGroupName: !Ref EcsExecLogGroup
            S3BucketName: !Ref S3BucketNameForECSExecLogs
          Logging: OVERRIDE
      ClusterSettings:
        - Name: containerInsights
          Value: disabled

  # ------------------------------------------------------------#
  #  ECS LogGroup
  # ------------------------------------------------------------#
  ECSLogGroup:
    Type: "AWS::Logs::LogGroup"
    Properties:
      LogGroupName: !Sub "/ecs/logs/${AppName}"

  # ------------------------------------------------------------#
  #  ECS Exec LogGroup
  # ------------------------------------------------------------#
  EcsExecLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/ecs-exec/logs/${AppName}"

  # ------------------------------------------------------------#
  #  ECS Exec Log Bucket
  # ------------------------------------------------------------#
  EcsExecLogBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Ref S3BucketNameForECSExecLogs
      AccessControl: Private
      PublicAccessBlockConfiguration:
        BlockPublicAcls: True
        BlockPublicPolicy: True
        IgnorePublicAcls: True
        RestrictPublicBuckets: True
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

# ------------------------------------------------------------#
#  SSM Service Role
# ------------------------------------------------------------#
  SSMServiceRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AppName}-SSMServiceRole"
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ssm.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
      Policies:
        - PolicyName: !Sub "${AppName}-SSMServiceRole-DeregisterManagedInstance-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ssm:DeregisterManagedInstance"
                Resource: "*"

# ------------------------------------------------------------#
#  ECS Task Execution Role
# ------------------------------------------------------------#
  ECSTaskExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AppName}-ECSTaskExecutionRole"
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy

# ------------------------------------------------------------#
#  ECS Task Role
# ------------------------------------------------------------#
  ECSTaskRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub "${AppName}-ECSTaskRole"
      Path: /
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: !Sub "${AppName}-SessionManager-SSM-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ssm:DeleteActivation"
                  - "ssm:RemoveTagsFromResource"
                  - "ssm:AddTagsToResource"
                  - "ssm:CreateActivation"
                  - "ssm:DeregisterManagedInstance"
                Resource: "*"
        - PolicyName: !Sub "${AppName}-ParameterStore-SSM-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ssm:PutParameter"
                  - "ssm:GetParameter*"
                  - "ssm:DescribeParameters"
                Resource: "*"
        - PolicyName: !Sub "${AppName}-SessionManager-PassRole-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "iam:PassRole"
                Resource: "*"
                Condition:
                  StringEquals:
                    iam:PassedToService: "ssm.amazonaws.com"
        - PolicyName: !Sub "${AppName}-ECSExec-SSM-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "ssmmessages:CreateControlChannel"
                  - "ssmmessages:CreateDataChannel"
                  - "ssmmessages:OpenControlChannel"
                  - "ssmmessages:OpenDataChannel"
                Resource: "*"
        - PolicyName: !Sub "${AppName}-ECSExec-Logging-Policy"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:DescribeLogGroups"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "logs:DescribeLogStreams"
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !GetAtt EcsExecLogGroup.Arn
              - Effect: Allow
                Action:
                  - "s3:PutObject"
                Resource: !Sub 'arn:aws:s3:::${EcsExecLogBucket}/*'
              - Effect: Allow
                Action:
                  - "s3:GetBucketLocation"
                Resource: "*"
              - Effect: Allow
                Action:
                  - "s3:GetEncryptionConfiguration"
                Resource: !Sub 'arn:aws:s3:::${EcsExecLogBucket}'

  # ------------------------------------------------------------#
  #  ECS TaskDefinition
  # ------------------------------------------------------------#
  ECSTaskDefinition:
    Type: "AWS::ECS::TaskDefinition"
    Properties:
      Cpu: !Ref ECSTaskCPUUnit
      ExecutionRoleArn: !GetAtt ECSTaskExecutionRole.Arn
      TaskRoleArn: !GetAtt ECSTaskRole.Arn
      Family: !Sub "${AppName}-Task"
      Memory: !Ref ECSTaskMemory
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ContainerDefinitions:
        - Name: !Sub "${AppName}-Container"
          Image: !Ref ECSImageName
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref ECSLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: !Sub "${AppName}"
          MemoryReservation: !Ref ECSAppTaskMemoryReservation
          Environment:
            - Name: APP_NAME
              Value: !Ref AppName

  # ------------------------------------------------------------#
  #  ECS Service
  # ------------------------------------------------------------#
  ECSService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !Ref ECSCluster
      DesiredCount: !Ref ECSTaskDesiredCount
      DeploymentConfiguration:
        DeploymentCircuitBreaker:
          Enable: true
          Rollback: true
        MaximumPercent: !Ref ECSDeploymentMaximumPercent
        MinimumHealthyPercent: !Ref ECSDeploymentMinimumHealthyPercent
      LaunchType: FARGATE
      NetworkConfiguration:
        AwsvpcConfiguration:
          SecurityGroups:
            - !Ref ECSServiceSecurityGroup
          Subnets:
            - !Ref SubnetID1
            - !Ref SubnetID2
      ServiceName: !Sub "${AppName}-Service"
      TaskDefinition: !Ref ECSTaskDefinition
      EnableExecuteCommand: true

  # ------------------------------------------------------------#
  #  Auto Scaling Service
  # ------------------------------------------------------------#
  ServiceAutoScalingRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              Service: application-autoscaling.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub "${AppName}-Container-autoscaling"
          PolicyDocument:
            Statement:
              - Effect: Allow
                Action:
                  - application-autoscaling:*
                  - cloudwatch:DescribeAlarms
                  - cloudwatch:PutMetricAlarm
                  - ecs:DescribeServices
                  - ecs:UpdateService
                Resource: "*"

  ServiceScalingTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    DependsOn:
      - ECSService
    Properties:
      MinCapacity: !Ref TaskMinContainerCount
      MaxCapacity: !Ref TaskMaxContainerCount
      ResourceId: !Sub
        - service/${EcsClusterName}/${EcsDefaultServiceName}
        - EcsClusterName: !Ref ECSCluster
          EcsDefaultServiceName: !Sub "${AppName}-Service"
      RoleARN: !GetAtt ServiceAutoScalingRole.Arn
      ScalableDimension: ecs:service:DesiredCount
      ServiceNamespace: ecs
      ScheduledActions:
        - ScheduledActionName: OffPeakStartTime
          Schedule: !Ref OffPeakStartTimeCron
          Timezone: "Asia/Tokyo"
          ScalableTargetAction:
            MinCapacity: !Ref TaskMinContainerCountDuringOffPeakTime
            MaxCapacity: !Ref TaskMaxContainerCountDuringOffPeakTime
        - ScheduledActionName: OffPeakEndTime
          Schedule: !Ref OffPeakEndTimeCron
          Timezone: "Asia/Tokyo"
          ScalableTargetAction:
            MinCapacity: !Ref TaskMinContainerCount
            MaxCapacity: !Ref TaskMaxContainerCount

  ServiceScaleOutPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: !Sub "${AppName}-Service-ScaleOutPolicy"
      PolicyType: StepScaling
      ScalingTargetId: !Ref ServiceScalingTarget
      StepScalingPolicyConfiguration:
        AdjustmentType: ChangeInCapacity
        Cooldown: 60
        MetricAggregationType: Average
        StepAdjustments:
          - ScalingAdjustment: 1
            MetricIntervalLowerBound: 0

  ServiceScaleInPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Properties:
      PolicyName: !Sub "${AppName}-Service-ScaleInPolicy"
      PolicyType: StepScaling
      ScalingTargetId: !Ref ServiceScalingTarget
      StepScalingPolicyConfiguration:
        AdjustmentType: ChangeInCapacity
        Cooldown: 300
        MetricAggregationType: Average
        StepAdjustments:
          - ScalingAdjustment: -1
            MetricIntervalUpperBound: 0

  ServiceScaleOutAlarm:
    Type: AWS::CloudWatch::Alarm
    DependsOn:
      - ECSService
    Properties:
      AlarmName: !Sub "${AppName}-Service-ScaleOutAlarm"
      EvaluationPeriods: !Ref ServiceScaleEvaluationPeriods
      Statistic: Average
      TreatMissingData: notBreaching
      Threshold: !Ref ServiceCpuScaleOutThreshold
      AlarmDescription: Alarm to add capacity if CPU is high
      Period: 60
      AlarmActions:
        - !Ref ServiceScaleOutPolicy
      Namespace: AWS/ECS
      Dimensions:
        - Name: ClusterName
          Value: !Ref ECSCluster
        - Name: ServiceName
          Value: !Sub "${AppName}-Service"
      ComparisonOperator: GreaterThanThreshold
      MetricName: CPUUtilization

  ServiceScaleInAlarm:
    Type: AWS::CloudWatch::Alarm
    DependsOn:
      - ECSService
    Properties:
      AlarmName: !Sub "${AppName}-Service-ScaleInAlarm"
      EvaluationPeriods: !Ref ServiceScaleEvaluationPeriods
      Statistic: Average
      TreatMissingData: notBreaching
      Threshold: !Ref ServiceCpuScaleInThreshold
      AlarmDescription: Alarm to reduce capacity if container CPU is low
      Period: 300
      AlarmActions:
        - !Ref ServiceScaleInPolicy
      Namespace: AWS/ECS
      Dimensions:
        - Name: ClusterName
          Value: !Ref ECSCluster
        - Name: ServiceName
          Value: !Sub "${AppName}-Service"
      ComparisonOperator: LessThanThreshold
      MetricName: CPUUtilization

# ------------------------------------------------------------#
#  Outputs
# ------------------------------------------------------------#
Outputs:
  ECSServiceArn:
    Export:
      Name: !Sub "${AppName}-ECSService-Arn"
    Value: !Ref ECSService

  ECSClusterArn:
    Export:
      Name: !Sub "${AppName}-ECSCluster-Arn"
    Value: !GetAtt ECSCluster.Arn


VPC設定

こちらは各自のVPC設定を入れてください。

VPC_ID="vpc-*****************"
SUBNET_ID1="subnet-*****************"
SUBNET_ID2="subnet-*****************"


ecs.config

細かいのですが、こちらでECSの細かい設定やスペックをコンフィグファイルとしていじれるようにしています。

ecs.config 全文(折りたたみ)

#### spec for ecs
ECSTaskCPUUnit="256" #[ 256, 512, 1024, 2048, 4096 ]
ECSTaskMemory="512" #[ 256, 512, 1024, 2048, 4096 ]
ECSRestMemory="64" # -> ECSAppTaskMemoryReservation=$(expr ${ECSTaskMemory} - ${ECSRestMemory})

#### autoscailing for ecs
ECSDeploymentMaximumPercent="200"
ECSDeploymentMinimumHealthyPercent="100"
ServiceScaleEvaluationPeriods="2"
ServiceCpuScaleOutThreshold="75"
ServiceCpuScaleInThreshold="25"

ECSTaskDesiredCount="1"
TaskMinContainerCount="1"
TaskMaxContainerCount="1"
TaskMinContainerCountDuringOffPeakTime="0"
TaskMaxContainerCountDuringOffPeakTime="0"

OffPeakStartTimeCron="cron(30 23 * * ? *)" # Timezone = "Asia/Tokyo"
OffPeakEndTimeCron="cron(30 9 * * ? *)" # Timezone = "Asia/Tokyo"


以下パラメータによって、夜間停止を実現しています。

具体的には、23:30~09:30はFargateを停止する設定になります。

停止したくない場合は、TaskMinContainerCountDuringOffPeakTime,TaskMaxContainerCountDuringOffPeakTimeを1に設定してください。

TaskMinContainerCountDuringOffPeakTime="0"
TaskMaxContainerCountDuringOffPeakTime="0"

OffPeakStartTimeCron="cron(30 23 * * ? *)" # Timezone = "Asia/Tokyo"
OffPeakEndTimeCron="cron(30 9 * * ? *)" # Timezone = "Asia/Tokyo"


詳細解説(ローカル編)

tunnel.sh

こちらがローカルPCで、実際にSSMセッションマネージャーによってポートフォワードを張るときに叩くスクリプトになります。

tunnel.sh 全文(折りたたみ)

#!/bin/bash

set -eu

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

CUR_DIR=$(pwd)

LOCAL_DB_PORT="13306"
TARGET_DB_PORT="3306"
TARGET_HOST="abcde.cluster-1234567890.ap-northeast-1.rds.amazonaws.com"

REGION="ap-northeast-1"
PROFILE=""
APP_NAME="Bastion"
SSM_PARAMETER_NAME="/ManagedInstanceIDParameter/${APP_NAME}"

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

while getopts p:l:t:h:r: OPT; do
    case $OPT in
        p)
            PROFILE="$OPTARG"
            ;;
        l)
            LOCAL_DB_PORT="$OPTARG"
            ;;
        t)
            TARGET_DB_PORT="$OPTARG"
            ;;
        h)
            TARGET_HOST="$OPTARG"
            ;;
        r)
            REGION="$OPTARG"
            ;;
    esac
done

if [ -z ${LOCAL_DB_PORT} ] || [ -z ${TARGET_DB_PORT} ] || [ -z ${TARGET_HOST} ] || [ -z ${REGION} ]; then
    echo "ex) tunnel [-p profile] [-l 13306] [-t 3306] [-h host] [-r ap-northeast-1]"
    echo "-p : AWSプロファイル(デフォルト : 空)"
    echo "-l : ローカルポート(デフォルト : 13306)"
    echo "-t : ターゲットポート(リモートポート)(デフォルト : 3306)"
    echo "-h : ターゲットホスト(デフォルト : abcde.cluster-1234567890.ap-northeast-1.rds.amazonaws.com)"
    echo "-r : ECSを構築したAWSリージョン(デフォルト : ap-northeast-1)"
    exit 0
fi

profileOption=""

if [ -n "${PROFILE}" ]; then
    profileOption="--profile ${profile}"
fi

target=$(aws ssm get-parameters \
   --name "${SSM_PARAMETER_NAME}" \
   ${profileOption} \
   | jq -r .Parameters[0].Value \
)

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

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

echo "30分経つと接続が切れます。"

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

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


SSMパラメータからインスタンスID取得

コンテナを起動する際にSSMパラメータ登録したインスタンスIDを取得します。

target=$(aws ssm get-parameters \
   --name "${SSM_PARAMETER_NAME}" \
   ${profileOption} \
   | jq -r .Parameters[0].Value \
)


ポートフォワード

ホスト、ローカルポート、ターゲットポートを指定して、AWS-StartPortForwardingSessionToRemoteHostによるstart-sessionを実行します。

これにより、リモートホストへのポートフォワードが張られます。

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

...
...(省略)
...

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


init.sh

パスを通す

こちらにより、上記のtunnel.shがtunnelというコマンドで、どのパスからでも叩けるようになります。

#!/bin/sh
set -eu

cd `dirname $0`

ln -s $(pwd)/tunnel.sh /usr/local/bin/tunnel
chmod 755 /usr/local/bin/tunnel


リモートホスト(RDS・Auroraなど)へアクセス

上記でパスを通したあとtunnelコマンドを叩くと、こんな感じで、ローカルPCから直接プライベートサブネット内のRDS・Aurora(MySQLPostgreSQL)へアクセスできるようになります。

$ tunnel
30分経つと接続が切れます。
$ mysql -u goto -p -h127.0.0.1 -P13306 # localポートを13306にしてある
Enter password: 

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 38686
Server version: 5.7.12-log MySQL Community Server (GPL)

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.
s
mysql> SELECT COUNT(*) FROM tbl1;
+----------+
| count(*) |
+----------+
|      122 |
+----------+
1 row in set (0.02 sec)


tunnelコマンドのオプションで、ポートなどを変更することができます。

  • -p : AWSプロファイル
    • デフォルト : 空
  • -l : ローカルポート
    • デフォルト : 13306
  • -t : ターゲットポート(リモートポート)
    • デフォルト : 3306
  • -h : ターゲットホスト
    • デフォルト : abcde.cluster-1234567890.ap-northeast-1.rds.amazonaws.com
  • -r : ECSを構築したAWSリージョン
    • デフォルト : ap-northeast-1


参考記事

こちらの記事を参考にさせていただきました。

特にDockerfile、コンテナ内で実行するシェルスクリプトなど、非常に参考になりました。


おまけ(App Runner + CDKでやってみた!)

ECS Fargate部分をApp Runnerでやってみた(かつCDKで構築)話を記事にしました。

go-to-k.hatenablog.com


最後に

EC2無しでリモートホストへのポートフォワードができるようになり、非常に捗っています。

あとはAWSのマネージドサービスとしてこういう機能を提供してくれるようになれば完璧ですね。

まあでもFargateでとりあえず動かせているので良しとします。