AWS Systems ManagerにSession Managerという機能があります。 Session Managerを使用することで、専用の踏み台サーバーの用意や、SSHおよびRDPポートを開けずに、ブラウザやCLIからプライベートネットワークにアクセスできます。

また、セッションログを出力でき、実際に操作した人やその内容の監査が可能です。

今回は、Session Managerを用いて、プライベートネットワークにセキュアにアクセスするための環境を構築する方法を紹介します。

また、この記事はFOLIO Advent calendar 2020の7日目の記事でもあります。(3日遅れの投稿です…)

今回のゴールと構成

今回は、プライベートネットワークにあるAmazon AuroraのMySQLデータベースに接続することをゴールとします。 作成する構成は以下の図のとおりです。

今回構築するインフラ構成図

SSMエージェントを動作させているサーバー、つまり、踏み台の役目をするサーバーをECS on Fargeteにすることで、サーバーレスな構成にしています。

環境構築

上記の図の構成を実現するために大きく分けて、

  • Dockerイメージの準備
  • AWSの設定

が必要となります。

それぞれを構築方法を順に紹介します。

Dockerイメージの準備

まずは、踏み台の役目となるSSMエージェントを動作させるためのDockerコンテナのイメージを作成します。 用意するファイルは以下の3つです。

├── Dockerfile
├── run.sh
└── seelog.xml.mustache
  • seelog.xml.mustache
    • SSMエージェントがログ出力のために使用しているseelogロギングライブラリ
    • コンテナ起動時に動的にファイルを生成したいため、テンプレートエンジンとしてmustacheとして用意
      • 環境変数を埋め込めればよいのですが、機能が提供されていないため、このようなことをしている
      • 環境変数サポートは開発要望としてすでに挙げており、AWSのバックログに積まれている

Dockerfile

Dockerイメージを作成するためのファイルです。 必要なツールをインストールして、SSMエージェントを起動するための処理を実行するスクリプトを実行するようにしています。

SSMエージェントはrootユーザーで実行しなければいけないため、non-rootユーザーに切り替えていません。

FROM amazonlinux:2

ENV AWS_REGION ${AWS_REGION}
ENV INSTANCE_NAME ${INSTANCE_NAME}
ENV INSTANCE_IAM_ROLE ${INSTANCE_IAM_ROLE}
ENV CLOUDWATCH_RECEIVER_LOG_GROUP ${CLOUDWATCH_RECEIVER_LOG_GROUP}

RUN yum localinstall -y https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm && \
    yum install -y \
    # mysql CLIのメジャーバーションを指定するためにyum-config-managerが必要なのでインストール
    yum-utils \
    # run.sh内で使用しているのでインストール
    jq \
    # aws CLIをインストールする際に必要なのでインストール
    unzip \
    # SSMエージェントをインストール
    amazon-ssm-agent \
    # セッションログを取得する際に必要になるのでインストール
    screen \
    # aws CLIはhelpコマンドなどでlessを要求してくるのでインストール
    less \
    # Amazon Linux 2に入っているmysql CLIはバーションを指定できないのでアンインストール
    && yum remove mariadb-libs \
    # mysql 5.7系のCLIをインストール
    && yum-config-manager --disable mysql80-community \
    && yum-config-manager --enable mysql57-community \
    && yum install -y mysql-community-client \
    # yum関連で必要なくなったリソースを削り、少しでもDockerイメージを軽くする
    && rm -rf /var/cache/yum/* \
    && yum clean all

# run.sh内で使用するmustache CLIをインストール
RUN curl -sfL -o /usr/local/bin/mo https://git.io/get-mo && \
    chmod +x /usr/local/bin/mo

WORKDIR /tmp

# run.sh内で使用するaws CLIをインストール
RUN curl -sfL -o "awscliv2.zip" https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip && \
    unzip awscliv2.zip && \
    ./aws/install

WORKDIR /

# /tmpディレクトリはいくつかのツールのインストールが終わったあと必要なくなったファイルが存在するため、少しでもDockerイメージを軽くする
RUN rm -rf /tmp/*

COPY seelog.xml.mustache .
COPY run.sh .

CMD ["./run.sh"]

seelog.xml.mustache

SSMエージェントがログ出力のために使用しているseelogロギングライブラリをコンテナ起動時に動的にファイルを生成したいため、テンプレートエンジンとしてmustacheのファイルとして用意しています。

本来、環境変数を埋め込めればよいのですが、機能が提供されていないため、このようなことをしています。1

また、seelogの形式や設定方法については、Examples of Seelog usageを参照してください。

今回はコンテナ起動時に、CloudWatch Logsのロググループ名が入っているCLOUDWATCH_RECEIVER_LOG_GROUP環境変数が存在した場合、指定された値でcloudwatch_receiverを設定するようにしています。

<seelog type="adaptive" mininterval="2000000" maxinterval="100000000" critmsgcount="500" minlevel="info">
    <exceptions>
        <exception filepattern="test*" minlevel="error"/>
    </exceptions>
    <outputs formatid="fmtinfo">
        <console formatid="fmtinfo"/>
        {{#CLOUDWATCH_RECEIVER_LOG_GROUP}}
        <custom name="cloudwatch_receiver" formatid="fmtinfo" data-log-group="{{CLOUDWATCH_RECEIVER_LOG_GROUP}}"/>
        {{/CLOUDWATCH_RECEIVER_LOG_GROUP}}
        <filter levels="error,critical" formatid="fmterror">
            <console formatid="fmterror"/>
            {{#CLOUDWATCH_RECEIVER_LOG_GROUP}}
            <custom name="cloudwatch_receiver" formatid="fmterror" data-log-group="{{CLOUDWATCH_RECEIVER_LOG_GROUP}}"/>
            {{/CLOUDWATCH_RECEIVER_LOG_GROUP}}
        </filter>
    </outputs>
    <formats>
        <format id="fmterror" format="%Date %Time %LEVEL [%FuncShort @ %File.%Line] %Msg%n"/>
        <format id="fmtinfo" format="%Date %Time %LEVEL %Msg%n"/>
    </formats>
</seelog>

run.sh

Dockerfile内で呼び出すShell Scriptをまとめたものです。

ECSでコンテナを起動する場合、EC2と異なり、自動でSession Managerのインスタンスとして登録されません。 そのため、Session Managerのハイブリッドアクティベーションを使用してコンテナをマネージドインスタンスとしてSession Managerに登録するようにします。

#!/bin/bash

# CLOUDWATCH_RECEIVER_LOG_GROUPを動的に設定したいため、テンプレートエンジンを使用してseelog.xmlを配置
mo seelog.xml.mustache > /etc/amazon/ssm/seelog.xml

# aws ssm create-activationコマンドの実行時に動的に設定する
# 必須の値なのでバリデーションを行っている
if [[ -z "${INSTANCE_IAM_ROLE}" ]];then
    echo "INSTANCE_IAM_ROLE was not specified. Please set this variable."
    exit 1
fi

# aws ssm create-activationコマンドの実行時に動的に設定する
# 必須の値なのでバリデーションを行っている
if [[ -z "${INSTANCE_NAME}" ]];then
    echo "INSTANCE_NAME was not specified. Please set this variable."
    exit 1
fi

# ハイブリッドアクティベーションのIDとコードを発行
ACTIVATE_PARAMETERS=$(aws ssm create-activation \
--default-instance-name "${INSTANCE_NAME}" \
--iam-role "${INSTANCE_IAM_ROLE}" \
--region "${AWS_REGION}" \
--registration-limit 1)
SSM_AGENT_CODE=$(echo "${ACTIVATE_PARAMETERS}" | jq -r .ActivationCode)
SSM_AGENT_ID=$(echo "${ACTIVATE_PARAMETERS}" | jq -r .ActivationId)

# このコンテナをSession Managerのマネージドインスタンスとして登録する
amazon-ssm-agent -register -code "${SSM_AGENT_CODE}" -id "${SSM_AGENT_ID}" -region "${AWS_REGION}"

# ハイブリッドアクティベーションのコードはもう必要ないため、削除する
aws ssm delete-activation --activation-id "${SSM_AGENT_ID}"

export REGISTRATION_FILE="/var/lib/amazon/ssm/registration"

cleanup() {
    # このコンテナのSession Managerのマネージドインスタンス登録を解除する
    aws ssm deregister-managed-instance --instance-id "$(cat "${REGISTRATION_FILE}" | jq -r .ManagedInstanceID)" || true
    exit 0
}

# コンテナが終了する際にcleanup関数が動作するようにTRAPを仕込む
trap 'cleanup' EXIT

# SSMエージェントを起動して、Session Managerとコネクションを構築する
amazon-ssm-agent start &
SSM_AGENT_PID="$!"

# Session Managerとのコネクション登録が環境するのを待つ
while :;do
    sleep 1
    if [ -e "${REGISTRATION_FILE}" ];then
        break
    fi
done
# registrationファイルは600で作成されるため、全ユーザーに読み込み権限を与えている
# 入っているデータは、マネージドインスタンスのIDとリージョン名だけであるため、権限を付与しても問題ない
chmod +r "${REGISTRATION_FILE}"

# SSMエージェントが終了するまで、つまり永久的にSSMエージェントを起動する
wait "${SSM_AGENT_PID}"
run.shについていくつかのポイント
aws ssm create-activation \
--default-instance-name "${INSTANCE_NAME}" \
--iam-role "${INSTANCE_IAM_ROLE}" \
--region "${AWS_REGION}" \
--registration-limit 1

default-instance-nameオプションはマネージドインスタンスの名前になるので、引数で渡して判別しやすいようにしています。

AWSのWebコンソール上のマネージドインスタンス一覧

iam-roleオプションは、立ち上げたコンテナが指定したロールにAssumeRoleされて実行されます。 このロールにAsuumeRoleされるのは、amazon-ssm-agent startを行ってインスタンスが登録されたあとである点に注意が必要です。 このスクリプトでは、

aws ssm delete-activation --activation-id "${SSM_AGENT_ID}"

がECSのタスクIAMロールで指定したロール、

cleanup() {
    # このコンテナのSession Managerのマネージドインスタンス登録を解除する
    aws ssm deregister-managed-instance --instance-id "$(cat "${REGISTRATION_FILE}" | jq -r .ManagedInstanceID)" || true
    exit 0
}

TRAPで実行されるこの関数はオプションで指定したロールで実行されます。

また、後述するセッションログの送信には、このロールに適切な権限を付与する必要があります。

AWSのインフラ環境構築

  1. Session Managerの構築
  2. VPCの構築
  3. IAMロールの構築
  4. ECSのクラスタ構築

の順でAWSのインフラ環境を構築していきます。

インフラ環境の構築に関しては、Terraformのコードで表現していきます。 Terraformのバーションは以下の通りです。

$ terraform version
Terraform v0.13.5

それでは先立って、使い回す値を変数として設定しておきます。

### var.tf
data "aws_region" "current" {}
data "aws_caller_identity" "self" {}

locals {
  log_group_name = "/ssm/session-manager"
  s3_prefix      = "ssm/session-log"
}

1. Session Managerの構築

Session Managerでは、セッションログと呼ばれる実行したコマンドの内容を記録する機能があります。 今回は、セッションログを暗号化できるようにSession Managerを構築します。

前述したDockerイメージを登録方法、およびイメージを登録するためのECRのリソース作成に関しては割愛します。

セッションログをCloudWatch Logsに流すためのロググループの設定

ログをもとに何かしらアクションを起こしたい場合はCloudWatch Logsに出力するのが良いです。

CloudWatch Logsのロググループはサーバーサイド暗号化が可能です。 これにはKMSのKeyが必要であるため、合わせてKeyを用意します。

また、Keyの暗号、復号などの操作の権限を絞るために、対応したロググループおよびrootアカウントのみに制限します。

### log.tf
data "aws_iam_policy_document" "log_group_policy" {
  statement {
    effect    = "Allow"
    actions   = ["kms:*"]
    resources = ["*"]

    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.self.account_id}:root"]
    }
  }

  statement {
    effect = "Allow"
    actions = [
      "kms:Encrypt*",
      "kms:Decrypt*",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:Describe*"
    ]
    resources = ["*"]

    principals {
      type        = "Service"
      identifiers = ["logs.${data.aws_region.current.name}.amazonaws.com"]
    }

    condition {
      test     = "ArnEquals"
      values   = ["arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.self.account_id}:log-group:${local.log_group_name}"]
      variable = "kms:EncryptionContext:aws:logs:arn"
    }
  }
}

resource "aws_kms_key" "log_group_encryption_key" {
  description              = "${local.log_group_name} log group encription key"
  customer_master_key_spec = "SYMMETRIC_DEFAULT"
  key_usage                = "ENCRYPT_DECRYPT"
  enable_key_rotation      = true
  deletion_window_in_days  = 7
  policy                   = data.aws_iam_policy_document.log_group_policy.json
}

resource "aws_kms_alias" "log_group_encryption" {
  name          = "alias/kms-ssm-log-group-encryption-key"
  target_key_id = aws_kms_key.log_group_encryption_key.key_id
}

resource "aws_cloudwatch_log_group" "ssm" {
  name       = local.log_group_name
  kms_key_id = aws_kms_key.ssm_encryption_key.arn
}
セッションログを保存するためのS3の設定

ログをアーカイブしたい場合はS3に出力すると良いです。 S3ではKMSのKeyなしでサーバーサイド暗号化が可能です。

### s3.tf
resource "aws_s3_bucket" "ssm" {
  bucket = "session-manager"
  acl    = "private"

  versioning {
    enabled = true
  }

  server_side_encryption_configuration {
    rule {
      apply_server_side_encryption_by_default {
        sse_algorithm = "AES256"
      }
    }
  }
}

resource "aws_s3_bucket_public_access_block" "ssm" {
  bucket                  = aws_s3_bucket.ssm.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}
Session Managerのセッションログの設定

Session ManagerのセッションログをKMSのKeyで暗号化してS3とCloudWatch Logsに転送する設定をします。

ポイントなのは、aws_ssm_documentnameです。 この属性に設定しているSSM-SessionManagerRunShellというドキュメント名は、AWS Webコンソール上で操作する際に使用される特別なものです。

また、AWS CLIを用いてセッションを作成する際には、オプションでドキュメント名を指定できますが、デフォルト値としてこのドキュメントが使用されます。

### ssm.tf
resource "aws_kms_key" "session_log" {
  description              = "Session log encription key"
  customer_master_key_spec = "SYMMETRIC_DEFAULT"
  key_usage                = "ENCRYPT_DECRYPT"
  enable_key_rotation      = true
  deletion_window_in_days  = 7
}

resource "aws_kms_alias" "session_log_encryption" {
  name          = "alias/kms-ssm-session-log-encryption-key"
  target_key_id = aws_kms_key.session_log.key_id
}

resource "aws_ssm_document" "session_log" {
  name = "SSM-SessionManagerRunShell"

  document_type   = "Session"
  document_format = "JSON"

  content = jsonencode({
    schemaVersion = "1.0"
    description   = "Document to hold regional settings for Session Manager"
    sessionType   = "Standard_Stream"
    inputs = {
      s3BucketName                = aws_s3_bucket.ssm.bucket
      s3KeyPrefix                 = local.s3_prefix
      s3EncryptionEnabled         = true
      cloudWatchLogGroupName      = aws_cloudwatch_log_group.ssm.name
      cloudWatchEncryptionEnabled = true
      kmsKeyId                    = aws_kms_key.session_log.arn
    }
  })
}

2. VPCの構築

今回のテーマである、パブリックには一切口を開けずにネットワークを構築してみます。

VPCの設定

Session ManagerとAmazon Aurora MySQL用に、それぞれプライベートサブネットを用意します。

### vpc.tf
resource "aws_vpc" "vpc" {
  cidr_block           = "10.10.0.0/16"
  enable_dns_hostnames = true
}

resource "aws_subnet" "session_manager" {
  for_each = {
    subnet_a = { az = "ap-northeast-1a", tail_octet = "11" },
    subnet_c = { az = "ap-northeast-1c", tail_octet = "12" },
  }
  vpc_id            = aws_vpc.vpc.id
  availability_zone = each.value.az
  cidr_block        = "10.10.${each.value.tail_octet}.0/24"
  tags              = {
    "Name" = "session-manager-${each.key}"
  }
}

resource "aws_route_table" "session_manager" {
  vpc_id = aws_vpc.vpc.id
}

resource "aws_route_table_association" "session_manager" {
  for_each       = aws_subnet.session_manager
  subnet_id      = each.value.id
  route_table_id = aws_route_table.session_manager.id
}
セキュリティの設定

プライベートなネットワーク構成にするのはよいですが、ECS on FargateでSSMエージェントを動作させる場合、以下のことが行われます。

  • ECRからDockerイメージをPull
    • Dockerイメージのレイヤ情報はS3に保存されているため、S3へのアクセスが必要
  • SSMエージェントがSession Managerに通信
  • セッションログをS3とCloudWatch Logsに送信
  • セッションログを暗号化する際にKMSのKey情報を取得

これらをプライベートなネットワークで実現するにはPrivate Linkを使用する必要があります。 Private Linkを使用することで、パブリックなインターネットを介さずにAWSが提供するサービスに接続できます。

これが今回の肝です。

また、Private Linkはセキュリティグループに紐付くので、関係がわかるように合わせてセキュリティグループグループの作成も行ってみます。

### security.tf
resource "aws_vpc_endpoint" "session_manager_s3" {
  vpc_id            = aws_vpc.vpc.id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [aws_route_table.session_manager.id]
}

resource "aws_security_group" "session_manager" {
  name        = "session-manager-security-group"
  description = "Allow ingress/egress for session manager"
  vpc_id      = aws_vpc.vpc.id

  # AWSの各種サービスに通信する際にHTTPSでアクセスが行われるため、アクセスを許可
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = [aws_vpc.vpc.cidr_block]
  }

  # S3のエンドポイントはGatewayなので、S3エンドポイントのCIDRへのアクセスを許可する
  egress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = concat([aws_vpc.vpc.cidr_block], aws_vpc_endpoint.session_manager_s3.cidr_blocks)
  }

  # SSMエージェントはインスタンスメタデータを取得するため、インスタンスメタデータが配信されているIPへのアクセスを許可する
  egress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["169.254.169.254/32"]
  }

  # MySQLのアクセスを許可
  egress {
    from_port   = 3306
    to_port     = 3306
    protocol    = "tcp"
    cidr_blocks = [for middleware in aws_subnet.middleware : middleware.cidr_block]
  }
}

resource "aws_security_group" "mysql_from_session_manager" {
  name        = "mysql-from-session_manager-security-group"
  description = "Allow access to mysql from session manager"
  vpc_id      = aws_vpc.vpc.id

  # Session Managerのマネージドインスタンスからのアクセスを許可
  ingress {
    from_port       = 3306
    to_port         = 3306
    protocol        = "tcp"
    security_groups = [aws_security_group.session_manager.id]
  }

  egress {
    from_port = 0
    to_port   = 0
    protocol  = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_vpc_endpoint" "session_manager_logs" {
  vpc_id              = aws_vpc.vpc.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.logs"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.session_manager : subnet.id]
  security_group_ids  = [aws_security_group.session_manager.id]
  private_dns_enabled = true
}

resource "aws_vpc_endpoint" "session_manager_ecr" {
  vpc_id              = aws_vpc.vpc.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.ecr.dkr"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.session_manager : subnet.id]
  security_group_ids  = [aws_security_group.session_manager.id]
  private_dns_enabled = true
}

resource "aws_vpc_endpoint" "session_manager_ssm" {
  vpc_id              = aws_vpc.vpc.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.ssm"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.session_manager : subnet.id]
  security_group_ids  = [aws_security_group.session_manager.id]
  private_dns_enabled = true
}

resource "aws_vpc_endpoint" "session_manager_ssmmessages" {
  vpc_id              = aws_vpc.vpc.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.ssmmessages"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.session_manager : subnet.id]
  security_group_ids  = [aws_security_group.session_manager.id]
  private_dns_enabled = true
}

resource "aws_vpc_endpoint" "session_manager_kms" {
  vpc_id              = aws_vpc.vpc.id
  service_name        = "com.amazonaws.${data.aws_region.current.name}.kms"
  vpc_endpoint_type   = "Interface"
  subnet_ids          = [for subnet in aws_subnet.session_manager : subnet.id]
  security_group_ids  = [aws_security_group.session_manager.id]
  private_dns_enabled = true
}

3. IAMロールの構築

今回登場するIAMロールは、以下の4つです。

  1. ECSのタスク実行ロールのためのロール
  2. ECSのタスクロールのためのロール
  3. SSMエージェントのためのロール
  4. オペレーターがSession Managerを使用するためのロール
ECSのタスク実行ロールの設定

タスクロール実行ロールは、ECSのコンテナエージェントが使用するロールです。 ECRからDockerイメージのPullやコンテナログCloudWatchに送信する際に必要です。

AWSが管理しているポリシーで実現する場合、作成するロールにアタッチすべき最低限必要なポリシーは以下のとおりです。

ポリシー 用途
arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy ECSのコンテナのログを送信
arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly ECRからイメージをpull
arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole Elastic Load BalancingロードバランサがECSコンテナインスタンスを登録および登録解除
arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy ECSのタスク実行ロールに一般的に必要なアクセス

また、作成するロールにはecs-tasks.amazonaws.comを信頼関係に追加する必要があります。

### ecs_task_execution_role.tf
data "aws_iam_policy_document" "ecs_task_execution_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task_execution" {
  name               = "ecs-task-execution"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_execution_assume_role.json
}

resource "aws_iam_role_policy_attachment" "ecs_task_execution" {
  for_each   = {
    dummy_key_1 = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
    dummy_key_2 = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
    dummy_key_3 = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceRole"
    dummy_key_4 = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
  }
  role       = aws_iam_role.ecs_task_execution.name
  policy_arn = each.value
}
ECSのタスクロールの設定

タスクロールはコンテナが使用するロールです。 コンテナ内のアプリケーション(処理)がAWSサービスに接続する際に必要です。

前述したDockerイメージでは、

  • ECSのタスクからSystems ManagerにIAMロールを渡す
  • System Managerのアクティベーションの発行
  • System Managerのアクティベーションの削除

が行われているため、それらに対応する権限を付与します。

また、SSMエージェントを起動するために必要なAWSが管理しているポリシーであるAmazonSSMManagedInstanceCoreも合わせて付与します。

### ecs_task_role.tf
data "aws_iam_policy_document" "ecs_task_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ecs-tasks.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ecs_task_role" {
  name               = "ecs-task-role"
  assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_role.json
}

data "aws_iam_policy_document" "ecs_task_role" {
  statement {
    actions   = ["iam:PassRole"]
    # SSMエージェントが動作するロールに対してのみ制限をかけてもよい
    resources = ["*"]
    condition {
      test     = "StringEquals"
      variable = "iam:PassedToService"
      values   = ["ssm.amazonaws.com"]
    }
  }

  statement {
    actions = [
      "ssm:CreateActivation",
      "ssm:DeleteActivation"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "ecs_task_role" {
  name   = "ecs-task-role"
  policy = data.aws_iam_policy_document.ecs_task_role.json
}

resource "aws_iam_role_policy_attachment" "ecs_task_role" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  role       = aws_iam_role.ecs_task_role.name
}
SSMエージェント用のロールの設定

SSMエージェントがSession Managerに登録するために必要な権限を付与します。

また、Dockerイメージの設定で言うところのrun.shamazon-ssm-agent start &からこのロールで実行されます。 具体的には、

  • タスク終了時にマネージドインスタンスの登録の解除
  • セッションログの暗号化

が行われるため、それらに対応する権限を付与します。

### ssm_service_role.tf
data "aws_iam_policy_document" "ssm_service_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ssm.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "ssm_service_role" {
  name               = "ssm-agent-role"
  assume_role_policy = data.aws_iam_policy_document.ssm_service_assume_role.json
}

data "aws_iam_policy_document" "ssm_service_role" {
  # See also: https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/ssm-agent-minimum-s3-permissions.html
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject"
    ]
    resources = [
      "arn:aws:s3:::aws-ssm-${data.aws_region.current.name}/*",
      "arn:aws:s3:::amazon-ssm-${data.aws_region.current.name}/*",
      "arn:aws:s3:::amazon-ssm-packages-${data.aws_region.current.name}/*",
      "arn:aws:s3:::${data.aws_region.current.name}-birdwatcher-prod/*",
      "arn:aws:s3:::patch-baseline-snapshot-${data.aws_region.current.name}/*"
    ]
  }

  # セッションログを保存できるようにする
  statement {
    effect = "Allow"
    actions = [
      "s3:PutObject"
    ]
    resources = [
      "arn:aws:s3:::${aws_s3_bucket.ssm.bucket}/${local.s3_prefix}/*"
    ]
  }

  # セッションログを暗号化して保存する際にS3バケットのデフォルト暗号化設定が必要
  statement {
    effect = "Allow"
    actions = [
      "s3:GetEncryptionConfiguration"
    ]
    resources = ["*"]
  }

  # セッションログをCloud Watch Logsに保存するために必要
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
      "logs:DescribeLogGroups",
      "logs:DescribeLogStreams"
    ]
    resources = [aws_cloudwatch_log_group.ssm.arn]
  }

  # セッションログの暗号化のためのKMSキーで復号化するために必要
  statement {
    effect = "Allow"
    actions = [
      "kms:Decrypt"
    ]
    resources = [aws_kms_key.session_log.arn]
  }

  # run.sh内でコンテナ終了時にマネージドインスタンスの登録解除するようにしているので、そのための権限を付与
  statement {
    effect = "Allow"
    actions = [
      "ssm:DeregisterManagedInstance"
    ]
    resources = ["*"]
  }
}

resource "aws_iam_policy" "ssm_service_role" {
  name   = "ssm-service-role-policy"
  policy = data.aws_iam_policy_document.ssm_service_role.json
}

resource "aws_iam_role_policy_attachment" "ssm_service_role" {
  for_each = {
    dummy_key_1 = "arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy"
    dummy_key_2 = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
    dummy_key_3 = aws_iam_policy.ssm_service_role.arn
  }
  policy_arn = each.value
  role       = aws_iam_role.ssm_service_role.name
}
オペレーターがSession Managerを使用する用のロールの設定

オペレーターがSession Managerを操作できるようにするための権限を付与します。 また、セッションログの暗号化に使用されるデータ暗号化キーを生成する必要があるため、そのための権限を付与します。

### session_manager_operator_role.tf
data "aws_iam_policy_document" "session_manager_operator_assume_role" {
  statement {
    actions = ["sts:AssumeRole"]

    principals {
      type        = "Service"
      identifiers = ["ec2.amazonaws.com"]
    }
  }
}

resource "aws_iam_role" "session_manager_operator" {
  name               = "session-manager-operator"
  assume_role_policy = data.aws_iam_policy_document.session_manager_operator_assume_role.json
}

# See also: https://docs.aws.amazon.com/ja_jp/systems-manager/latest/userguide/getting-started-restrict-access-quickstart.html
data "aws_iam_policy_document" "session_manager_operator" {
  statement {
    effect = "Allow"
    actions = [
      "ssm:StartSession"
    ]
    resources = [
      "arn:aws:ec2:*:*:instance/*",
      "arn:aws:ssm:${data.aws_region.current.name}:${data.aws_caller_identity.self.account_id}:document/${aws_ssm_document.session_log.name}"
    ]
  }

  statement {
    effect = "Allow"
    actions = [
      "ssm:TerminateSession",
      "ssm:ResumeSession",
      "ssm:DescribeSessions",
      "ssm:GetConnectionStatus"
    ]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "ssm:GetDocument"
    ]
    resources = ["arn:aws:ssm:::document/${aws_ssm_document.session_log.name}"]
  }

  statement {
    effect = "Allow"
    actions = [
      "kms:GenerateDataKey"
    ]
    resources = [aws_kms_key.session_log.arn]
  }
}

resource "aws_iam_policy" "session_manager_operator" {
  name   = "session-manager-for-operator"
  policy = data.aws_iam_policy_document.session_manager_operator.json
}

resource "aws_iam_role_policy_attachment" "session_manager_operator" {
  policy_arn = aws_iam_policy_document.session_manager_operator.arn
  role       = aws_iam_role.session_manager_operator.name
}

4. ECSのクラスタ構築

ECS on FargateでDockerコンテナを動かすための設定をします。 そのためには、

  • ECSのクラスタの構築
  • Dockerイメージをタスクとして実行するためのタスク定義
    • 使用するコンピュータリソースの設定
    • どのイメージを使用するかの設定
    • コンテナ起動時の環境変数の設定

が必要です。

### ecs.tf
resource "aws_ecs_cluster" "ssm_agent_ecs_cluster" {
  name = "ssm-agent-ecs-cluster"
  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_cloudwatch_log_group" "ssm_agent_log_group" {
  name = "/ecs/ssm_agent"
}

locals {
  container_definitions = [
    {
      name  = "ssm_agent"
      image = "xxxxxx.dkr.ecr.${data.aws_region.current.name}.amazonaws.com/YOUR_REPOSITORY_NAME:YOUR_DOCKER_IMAGE_TAG"
      environment = [
        {
          name  = "INSTANCE_NAME"
          value = "ssm-agent-from-ecs"
        },
        {
          name  = "INSTANCE_IAM_ROLE"
          value = aws_iam_role.ssm_service_role.name
        },
        {
          name  = "AWS_REGION"
          value = data.aws_region.current.name
        },
        {
          name  = "CLOUDWATCH_RECEIVER_LOG_GROUP"
          value = aws_cloudwatch_log_group.ssm_agent_log_group.name
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.ssm_agent_log_group.name
          "awslogs-region"        = data.aws_region.current.name
          "awslogs-stream-prefix" = "ecs"
        }
      }
      essential    = true
      startTimeout = 120
      stopTimeout  = 120
    }
  ]
}

resource "aws_ecs_task_definition" "ssm_agent" {
  family                   = "ssm-agent"
  container_definitions    = jsonencode(local.container_definitions)
  task_role_arn            = aws_iam_role.ecs_task_role.arn
  execution_role_arn       = aws_iam_role.ssm_service_role.arn
  network_mode             = "awsvpc"
  memory                   = "2048"
  cpu                      = "1024"
  requires_compatibilities = ["FARGATE"]
}

用意したファイルのおさらい

Terraformはどのファイルにどの設定を書いてもよいですが、今回は以下のようなファイルを用意しました。

├── var.tf # 共通で使用する変数の定義
├── log.tf # セッションログをCloudWatch Logsに流すためのロググループの設定
├── s3.tf # セッションログを保存するためのS3の設定
├── ssm.tf # Session Managerのセッションログの設定
├── vpc.tf # VPCの設定
├── security.tf # セキュリティの設定
├── ecs_task_execution_role.tf # ECSのタスク実行ロールの設定
├── ecs_task_role.tf # ECSのタスクロールの設定
├── ssm_service_role.tf # SSMエージェント用のロールの設定
├── session_manager_operator_role.tf # Session Managerを使用するオペレーター用のロールの設定
└── ecs.tf # ECSクラスタおよびタスク定義の設定

SSMエージェントの起動

環境構築が終わったので、あとはSSMエージェントのコンテナを起動するだけです。 構築したセキュリティグループでタスクを実行すれば、セッションマネージャーを使用できます。

AWS WebコンソールでのECSのタスク実行

注意点

アドバンスドインスタンスティアについて

ハイブリッドアクティベーションの場合、インスタンス層がアドバンスドインスタンスティアとなります。

ハイブリッドアクティベーション経由で登録したマネージドインスタンスが存在する状態で、Webコンソール上のSystem ManagerのSession Managerのページに遷移すると、アドバンスドインスタンスティアへの変更許可を求めるダイアログが自動でてくるので、ティアを変更できます。

事前に許可したい場合は、以下のコマンドを実行します。

aws ssm update-service-setting --setting-id arn:aws:ssm:YOUR_REGION:YOUR_ACCOUNT_ID:servicesetting/ssm/managed-instance/activation-tier --setting-value advanced

終わりに

今回はSession Managerでプライベートネットワークにセキュアにアクセスするために、

  • SSMエージェントを動かすDockerイメージの作成
  • AWSのインフラ構築

について紹介しました。

見ていただいたように、サーバーレスとはいえ、いくつか準備しないといけないことがあるため、まだまだ面倒です。 Azure Bastionのように、AWSもマネージドな踏み台のようなものを用意してほしいと思いました。

また、運用を始めると、まとまったファイルなどを入力および出力したくなることもあります。

その際は、専用のS3バケットを用意して入力および出力するのが良いと考えています。 S3を介することで、引き続きセキュアな環境が保てます。さらに、もし監査が必要な場合でも、対応が可能です。

参考記事

  1. 環境変数サポートは開発要望としてすでに挙げており、AWSのバックログに積まれている。