ECSタスクの単発実行によるオンデマンド踏み台サーバーの実現

前書き

こんにちは!株式会社アルファドライブに所属していたくすのきです。 4月からは、アルファドライブの一部事業カーブアウトに伴い株式会社ユーザベース Holdings Productのエンジニアとしてユーザベースのすべての社員がより効率的に働ける環境づくりに邁進しています。

本稿は、アルファドライブで実施した「踏み台サーバーのオンデマンド化」についての紹介です。 インターンをしてくれたbe3さんが記事を書いてくれました。インターン期間後の寄稿となったため、共著という形で掲載します。 アルファドライブのご担当者に許可をいただき当時の構成を記載しています。

省コストな踏み台サーバーの構築に有益な内容になっています。ぜひ参考にしてください。


はじめに

こんにちは!株式会社アルファドライブでインターンをさせていただいていた、be3と申します。

アルファドライブではサーバーサイドやインフラを中心にタスクを任せていただいていました。

踏み台サーバーを導入した背景としては、これまでClient VPNを使ったDB接続をしていたことから以下のような課題があったためです。

  • 証明書ファイルがあると誰でも接続できてしまう
    • DBのエンドポイントとパスワードがわかっていると退職後も接続できる
  • Client VPNだけで毎月10万円の料金が発生
  • 社内VPNと同時に接続できない

サーバーと言うと常時稼働しているイメージがありますが、踏み台サーバーは開発者が何か他のサーバーに対して作業をする際にのみ必要になるという特性があるため、常時稼働している必要はないと考えました。

そこで今回は、踏み台サーバーを使ったDB接続を実現するために、必要に応じてECSタスクの単発実行する方法をご紹介したいと思います。

TL;DR

以降では詳細に触れていきますが、今回の内容をまとめると以下の通りです。

  • 必要に応じてECSタスクを単発実行し、アクセスがないと自動停止することでコストを節約
  • リモートポートフォワーディングにより踏み台サーバーもプライベートネットワークに配置
  • AssumeRoleを使った踏み台用権限によるECSタスクの実行とSSMセッションの開始

踏み台サーバーの構築方針

今回は、以下の構成で踏み台サーバーを実現しました。

構成図

バックエンドのタスクを稼働させるためのクラスターが既にあるため、既存のクラスターに踏み台サーバーのためのタスクを配置しました。

今回の構築方法における特徴をまとめると、以下のようになると思います。

機能面

  • ローカル環境からプライベートサブネットにある踏み台サーバーを経由してDBに接続
  • CloudWatchのロググループにログを出力

セキュリティ面

  • SSM Session Managerを利用するため、踏み台サーバーもプライベートサブネットに配置

コスト面

  • 踏み台サーバーが必要になった場合だけECSタスクを実行するため、踏み台サーバーが常時稼働することがなく、また踏み台のためにECSサービスを実行する必要もない
  • 誰も利用していないECSタスクは一定時間経過後に自動停止

AWS CDKを使った踏み台用リソースの構築

チーム内では既にAWS CDKを使ったリソース管理が進められていたため、踏み台サーバーもAWS CDKを使って構築することにしました。

踏み台サーバーとなるECSタスクを定義したConstructは以下になります。

import { Construct } from "constructs";
import {
  FargateTaskDefinition,
  OperatingSystemFamily,
  CpuArchitecture,
  ContainerImage,
  LogDrivers,
} from "aws-cdk-lib/aws-ecs";
import { IVpc, Peer, Port, SecurityGroup } from "aws-cdk-lib/aws-ec2";
import {
  Effect,
  ManagedPolicy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} from "aws-cdk-lib/aws-iam";
import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
import { IDatabaseCluster } from "aws-cdk-lib/aws-rds";
import { Repository } from "aws-cdk-lib/aws-ecr";

export type BastionServiceProps = {
  vpc: IVpc;
  database: IDatabaseCluster;
};

export class Bastion extends Construct {
  constructor(scope: Construct, id: string, props: BastionServiceProps) {
    super(scope, id);

    const { vpc, database } = props;

    const taskRole = new Role(this, "BastionTaskRole", {
      assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com"),
      roleName: "your-bastion-task-role",
    });
    taskRole.addToPolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        actions: [
          "ssmmessages:CreateControlChannel",
          "ssmmessages:CreateDataChannel",
          "ssmmessages:OpenControlChannel",
          "ssmmessages:OpenDataChannel",
          "logs:DescribeLogGroups",
          "logs:CreateLogStream",
          "logs:DescribeLogStreams",
          "logs:PutLogEvents",
        ],
        resources: ["*"],
      }),
    );

    const taskExecutionRole = new Role(this, "BastionTaskExecutionRole", {
      assumedBy: new ServicePrincipal("ecs-tasks.amazonaws.com"),
      roleName: "your-bastion-task-execution-role",
      managedPolicies: [
        ManagedPolicy.fromAwsManagedPolicyName(
          "service-role/AmazonECSTaskExecutionRolePolicy",
        ),
      ],
    });
    taskExecutionRole.addToPolicy(
      new PolicyStatement({
        effect: Effect.ALLOW,
        actions: ["ecs:ExecutionCommand"],
        resources: ["*"],
      }),
    );

    const taskLogGroup = new LogGroup(this, "BastionLogGroup", {
      logGroupName: "your-bastion-log-group",
      retention: RetentionDays.ONE_DAY,
    });

    const bastionRepository = new Repository(this, "BastionRepository", {
      repositoryName: "your-repository/bastion-server",
    });

    const taskDef = new FargateTaskDefinition(this, "BastionTaskDefinition", {
      family: "your-bastion-task",
      cpu: 256,
      memoryLimitMiB: 512,
      executionRole: taskExecutionRole,
      taskRole: taskRole,
      runtimePlatform: {
        operatingSystemFamily: OperatingSystemFamily.LINUX,
        cpuArchitecture: CpuArchitecture.ARM64,
      },
    });
    taskDef.addContainer("BastionContainer", {
      containerName: "your-bastion-container",
      image: ContainerImage.fromEcrRepository(bastionRepository, "latest"),
      logging: LogDrivers.awsLogs({
        streamPrefix: "ecs-bastion",
        logGroup: taskLogGroup,
      }),
    });

    const bastionSecurityGroup = new SecurityGroup(
      this,
      "BastionSecurityGroup",
      {
        vpc: vpc,
        allowAllOutbound: false,
        securityGroupName: "your-bastion-security-group",
        description: "Security group of bastion server",
      },
    );
    bastionSecurityGroup.addEgressRule(Peer.anyIpv4(), Port.tcp(443));
    bastionSecurityGroup.addEgressRule(
      Peer.anyIpv4(),
      Port.tcp(database.clusterEndpoint.port),
    );

    database.connections.allowDefaultPortFrom(bastionSecurityGroup);
  }
}

以下の記事を参考にさせていただきAWS CDKを使ったリソース定義をしました。

Fargate+Session ManagerポートフォワードでRDSに接続する踏み台サーバをAWS CDKで構築する

さらにコストを抑える目的で、今回の構築方法には上記の記事との差分となる特徴が2点あります。

1点目は、踏み台サーバーのためのECSタスクは、ECSサービスには含めず単発実行(実行方法は後述)にした点です。

ECSサービスを利用する場合、踏み台サーバーのタスクが常駐することになりコンピューティングリソースを消費することになります。

ECSタスク単体で実行し、踏み台サーバーへのアクセスを止めた時点で実行が終了するスクリプトをタスクのメインプロセスとすることで、必要な場合だけコンピューティングリソースを消費する形にしました。

2点目は、ECSタスクとSSM、CloudWatch Logsとの通信には、VPCエンドポイントではなくNATゲートウェイを利用した点です。

どちらのサービスを利用しても機能的には十分でしたが、踏み台サーバーを利用する上で接続頻度が多くなく1回あたりの接続時間が長くないことから、通信量は多くないと予想してNATゲートウェイを利用することに決めました。

ここでは、損益分岐点となる通信量を超える場合はVPCエンドポイントを使用し、超えない場合はNATゲートウェイを使用することに決めた社内の報告を参考にしました。

また、開発メンバーによってはAWSリソースへのアクセス権限が異なるため、踏み台タスクを実行するユーザーにとって必要十分なロールを作成します。(ここで作成したロールは、後述する踏み台用スクリプトでAssumeRoleに使用)

ポリシーの内容は以下の通りです。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "iam:PassRole"
            ],
            "Resource": [
                "arn:aws:iam::123456789012:role/your-bastion-task-execution-role",
                "arn:aws:iam::123456789012:role/your-bastion-task-role"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ecs:RunTask"
            ],
            "Resource": [
                "arn:aws:ecs:ap-northeast-1:123456789012:task-definition/your-bastion-task*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ecs:DescribeTasks"
            ],
            "Resource": [
                "arn:aws:ecs:ap-northeast-1:123456789012:task/your-backend-cluster/*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssm:StartSession"
            ],
            "Resource": [
                "arn:aws:ecs:ap-northeast-1:123456789012:task/your-backend-cluster/*",
                "arn:aws:ssm:ap-northeast-1::document/AWS-StartPortForwardingSessionToRemoteHost"
            ]
        },
        {
            "Action": [
                "ssm:TerminateSession"
            ],
            "Effect": "Allow",
            "Resource": [
                "arn:aws:ssm:*:*:session/start_session_script-*"
            ]
        }
    ]
}

踏み台コンテナの定義

踏み台コンテナを定義するDockerfileは以下です。

FROM --platform=arm64 public.ecr.aws/amazonlinux/amazonlinux:latest

WORKDIR /
COPY ./check_login.sh /

RUN yum update -y && \
    yum install -y procps && \
    chmod +x check_login.sh && \
    yum clean all 

ENTRYPOINT ["./check_login.sh"]

ここでのポイントは以下の通りです。

  • SSM AgentがプリインストールされたAmazon Linuxをベースイメージに指定
  • イメージサイズを削減するためにパッケージのキャッシュを削除

そして、 ENTRYPOINT に指定したシェルスクリプトは以下です。

#!/bin/bash
while :
do
    sleep 600
    current_user_num=`ps -ef | grep ssm-session-worker | grep -v grep | wc -l | tr -d '[:space:]'`
    if [ "$current_user_num" == 0 ]; then
        exit 0
    fi
done

このスクリプトは以下の記事を参考にさせていただいて実装しました。

このスクリプトの実行をコンテナのメインプロセスとすることで、SSMのセッション数が0になったタイミング、つまり踏み台サーバーを利用するユーザーがいなくなったタイミングで自動的にECSタスクが停止します。

踏み台にはECSコンテナを。~ログイン有無を検知して自動停止させる~ - NRIネットコムBlog

タスク実行&セッション開始をするシェルスクリプトの実装

ECSタスクの実行とSSMセッションの開始をするシェルスクリプトは以下になります。

#!/bin/bash
availability_zone() {
    local zones=$(aws ec2 describe-availability-zones --region ap-northeast-1 | grep -o '"ZoneName": "[^"]*' | awk -F'"' '{print $4}')
    for z in $zones; do
        local rds_db_host=$(rds_db_host $z)
        if [ -n "$rds_db_host" ]; then
            echo $z
            break
        fi
    done
}
bastion_security_group() {
    local group=$(aws ec2 describe-security-groups \
                    --query "SecurityGroups[?contains(GroupName, 'bastion') && OwnerId=='$1'].GroupId" \
                    --output text)
    echo $group
}
bastion_vpc_id() {
    local id=$(aws ec2 describe-security-groups \
                --group-ids $1 \
                --query "SecurityGroups[].VpcId" \
                --output text)
    echo $id
}
bastion_subnets() {
    local subnets=""
    local subnet_ids=$(aws ec2 describe-subnets \
                        --filters "Name=vpc-id,Values=$1" \
                                  "Name=availability-zone,Values=$2" \
                                  "Name=map-public-ip-on-launch,Values=false" \
                        --query "Subnets[].SubnetId" \
                        --output text)
    if [[ "${#subnet_ids[@]}" -gt 0 ]]; then
        for id in $subnet_ids; do
            if [ -z $subnets ]; then
                subnets="$id"
            else
                subnets+=",$id"
            fi
        done
    fi
    echo $subnets
}
rds_db_host() {
    local host=""
    local instances=$(aws rds describe-db-instances \
                        --filters "Name=availability-zone,Values=$1" \
                        --query 'DBInstances[].Endpoint.Address' \
                        --output text)
    if [[ "${#instances[@]}" -gt 0 ]]; then
        local tmp=${instances[0]}
        host=${tmp[0]}
    else
        host=$instances
    fi
    echo $host
}
assume_role() {
    local role=$(aws sts assume-role \
                    --role-arn "arn:aws:iam::$1:role/start-bastion-exec" \
                    --role-session-name "start_session_script" \
                    --output json)
    export AWS_ACCESS_KEY_ID=$(echo "$role" | grep -o '"AccessKeyId": "[^"]*' | awk -F'"' '{print $4}')
    export AWS_SECRET_ACCESS_KEY=$(echo "$role" | grep -o '"SecretAccessKey": "[^"]*' | awk -F'"' '{print $4}')
    export AWS_SESSION_TOKEN=$(echo "$role" | grep -o '"SessionToken": "[^"]*' | awk -F'"' '{print $4}')
}
run_task() {
    local tasks=$(aws ecs run-task --cluster $1 --count 1 --launch-type FARGATE \
        --enable-execute-command \
        --network-configuration "awsvpcConfiguration={subnets=[$2],securityGroups=[$3],assignPublicIp=ENABLED}" \
        --task-definition $4 \
        --output json)
    local arn=$(echo $tasks | grep -o '"taskArn": "[^"]*' | awk -F'"' '{print $4}')
    echo $arn
}
start_session() {
    aws ssm start-session \
        --target $1 \
        --document-name AWS-StartPortForwardingSessionToRemoteHost \
        --parameters "{\"host\": [\"$2\"], \"portNumber\":[\"$3\"], \"localPortNumber\":[\"$4\"]}" \
        --region ap-northeast-1
}
runtime_id() {
    local tasks=$(aws ecs describe-tasks \
                    --cluster $1 \
                    --task $2)
    local id=$(echo $tasks | grep -o '"runtimeId": "[^"]*' | awk -F'"' '{print $4}')
    echo $id
}
session_target() {
    local tasks=$(aws ecs describe-tasks --cluster $1 --task $2)
    local id=$(echo $tasks | grep -o '"runtimeId": "[^"]*' | awk -F'"' '{print $4}')
    local target="ecs:$1_$2_$id"
    echo $target
}

main() {
    readonly account_id=$(aws sts get-caller-identity \
                            --query 'Account' \
                            --output text)
    if [ -z $account_id ]; then
        echo "Failed to get account id"
        return 1
    fi
    readonly az=$(availability_zone)
    if [ -z $az ]; then
        echo "No AZ found"
        return 1
    fi
    echo "Availability Zone: $az"
    readonly sec_group=$(bastion_security_group $account_id)
    if [ -z $sec_group ]; then
        echo "No security groups found"
        return 1
    fi
    echo "Security Group: $sec_group"
    readonly vpc_id=$(bastion_vpc_id $sec_group)
    if [ -z $vpc_id ]; then
        echo "No VPC found"
        return 1
    fi
    echo "VPC: $vpc_id"
    readonly subnets=$(bastion_subnets $vpc_id $az)
    if [ -z $subnets ]; then
        echo "No subnets found"
        return 1
    fi
    echo "Subnet: $subnets"
    readonly host=$(rds_db_host $az)
    if [ -z $host ]; then
        echo "No RDS instance found"
        return 1
    fi
    readonly cluster=your-backend-cluster
    readonly task_def=your-bastion-task
    readonly remote_port=${DB_PORT:-5432}
    readonly local_port=${LOCAL_PORT:-55432}

    assume_role $account_id
    if [ -z $AWS_ACCESS_KEY_ID ]; then
        echo "Failed to assume role. No AWS_ACCESS_KEY_ID found."
        return 1
    fi
    if [ -z $AWS_SECRET_ACCESS_KEY ]; then
        echo "Failed to assume role. No AWS_SECRET_ACCESS_KEY found."
        return 1
    fi
    if [ -z $AWS_SESSION_TOKEN ]; then
        echo "Failed to assume role. No AWS_SESSION_TOKEN found."
        return 1
    fi

    echo "localhost:$local_port -> $host:$remote_port"
    readonly task_arn=$(run_task $cluster $subnets $sec_group $task_def)
    readonly task_id=${task_arn##*/}

    status=""
    while [ "$status" != "RUNNING" ]; do
        echo "Waiting for task to reach RUNNING state..."
        sleep 5
        status=$(aws ecs describe-tasks \
                    --cluster $cluster \
                    --tasks $task_arn \
                    --query "tasks[0].lastStatus" \
                    --output text)
        if [ "$status" = "" ]; then
            echo "An error occurred before executing run-task. Check the above logs."
            return 1
        fi
        echo "Task status: $status"
        if [ "$status" = "STOPPED" ]; then
            echo "The task stopped before executing run-task for some reason."
            return 1
        fi
    done

    readonly target=$(session_target $cluster $task_id)
    echo "Target: $target"
    start_session $target $host $remote_port $local_port
}
main

上記のシェルスクリプトの流れを簡単にまとめると以下のようになります。

  1. describe 系コマンドにより踏み台タスクの実行時に指定するサブネットやセキュリティグループの識別子を取得
  2. aws sts assume-role コマンドにより踏み台タスクを実行するためのロールを一時利用
  3. aws ecs run-task コマンドにより踏み台タスクを実行
  4. aws ssm start-session コマンドにより実行した踏み台タスクに対してリモートポートフォワーディングのセッションを開始

気付いた方もいるかもしれませんが、このシェルスクリプトには分かっているだけでも以下の課題があります。

  • ライターインスタンスかリーダーインスタンスかを指定してDBのホスト名を取得できない
  • 既に実行している踏み台タスクに対してセッションを開始できない(毎回タスクを実行する)

私のインターン期間中にはこれらの点には取り組めなかったため、この記事では記載できませんでしたが、社員さんが後日対応してくれていました。(感謝🙏)

おわりに

以上が本記事の内容になります。

今回紹介したセキュリティを保ちつつコストを抑えることができる踏み台サーバーの構築がお役に立てれば幸いです。

最後までお読みいただきありがとうございました!

参考

Fargate+Session ManagerポートフォワードでRDSに接続する踏み台サーバをAWS CDKで構築する 踏み台にはECSコンテナを。~ログイン有無を検知して自動停止させる~ - NRIネットコムBlog

Page top