FargateとSSMでssh(ぽい)環境を構築してみた

<!-- FargateとSSMでssh環境を構築してみた -->
## 自己紹介

SRE歴2年目、普段はAWS/GCPなどのインフラとrailsでサーバサイドをやっています。
たまに、GolangとDart書いてます。
基本的になんでも屋さん。

## やりたいこと

みなさん、AWS環境でsshはどうしていますか？？ :thinking: 
ほとんどの方はEC2で作って、そこにsshの公開鍵を置いたりしているはずです。
最近だと、Instance ConnectやSession Managerなど公開鍵を登録せずにシェルに入れる仕組みが増えてきました。
ただ、ほとんどの人がssh用にサーバは立ち上げた状態のはずです。
セキュリティグループなどを適切に設定していれば問題ありませんが、なんか怖い。
そんな人のために、必要なときだけ立ち上げて接続できるコンテナ環境を今回は作っていきます :smile: 

## やること

今回は、Fargateを使ってssh環境を作ってみます。
Fargateを使ってsshする場合、以下が候補になると思います。

- sshのpublic keyをコンテナに埋め込みssh接続
- パスワードとユーザ名でssh接続
- aws systems manager(session manager)で接続

この記事ではsession managerを使って接続してみたいと思います。

## 参考URLと使うサービス

今回はこの記事を参考にさせて頂いています！

- https://qiita.com/pocari/items/3f3d77c80893f9f1e132

ちなみに使うものは以下になります。

- クラウド環境
  - AWS ECS Fargate
  - AWS ECR
  - AWS SSM Session Manager
  - AWS SSM Managed Instances
  - AWS SSM Hybrid Activations
  - AWS Secrets Manager
  - AWS VPC
  - AWS CloudWatch Logs
- その他ツール
  - Terraform 0.12.7
  - fargatecli
    - https://github.com/awslabs/fargatecli

## ソースコード

ソースコードはGithubに公開しているので参考にしてみてください。

- https://github.com/fnaoto/aws-sample

## ポイント

### コンテナ

- ssmへの登録はdocker buildのタイミングで行います
    - コンテナ起動時に登録すると、再起動のたびにssmに登録されるのであまりオススメしません :sweat:
    - 今回は登録数の上限を10個にしています

- Fargateで使うDockerコンテナはamazonlinux2を使用しています
  - yumでamazon-ssm-agentをインストールするために使っています

```Dockerfile:terraform/test/docker/fargate_ssh/Dockerfile
FROM amazonlinux:2

ARG SSM_AGENT_CODE
ARG SSM_AGENT_ID
ARG AWS_REGION
ARG ACCESS_KEY_ID
ARG SECRET_ACCESS_KEY

RUN yum update -y && \
    yum install -y amazon-ssm-agent
RUN amazon-ssm-agent \
    -register \
    -code ${SSM_AGENT_CODE} \
    -id ${SSM_AGENT_ID} \
    -region ${AWS_REGION}
COPY entrypoint.sh .

CMD ["./entrypoint.sh"]
```

- Entrypointではシェルを実行しています
  - ここでagentを起動しています
  - 起動から一時間後に再起動するようにしています

```bash:terraform/test/docker/fargate_ssh/entrypoint.sh
#!/bin/sh

amazon-ssm-agent &
sleep 3600
```

### task definition (container definition)

- ログはCloudWatch Logsに保存
  - 環境変数はterraformで読み込む際にrenderで置き換えています

```json:terraform/test/task-definitions/fargate_ssh.json
[
    {
        "essential": true,
        "image": "${DOCKER_IMAGE_URL}",
        "name": "fargate_ssh",
        "network_mode": "awsvpc",
        "logConfiguration": {
            "logDriver": "awslogs",
            "options": {
                "awslogs-group": "${AWS_LOGS_GROUP}",
                "awslogs-region": "${AWS_LOGS_REGION}",
                "awslogs-stream-prefix": "logs"
            }
        }
    }
]
```

### ECS Fargate

- サブネットはPrivateにして、Internet Gatewayをつけないようにしています
  - session managerはVPC Endpointを使って接続するようです

- セキュリティグループのingressは開放せず、トラフィックを遮断しています
  - session managerは厳密にはsshではないので、インターネット接続とポートの開放が必要ありません

- パブリックIPアドレスも付与しないようにしています

```terraform:terraform/test/main.tf
module "fargate_ssh" {
  source                = "../modules/fargate"
  name                  = "${local.name}-fargate_ssh"
  subnets               = module.subnet.private_subnet_ids
  security_groups       = [module.sg_deny_ingress.id]
  assign_public_ip      = false
  task_cpu              = 256
  task_memory           = 512
  log_group_name        = "/aws/ecs/${var.project}/${local.ws}/fargate_ssh"
  tags                  = local.tags
  container_definitions = file("task-definitions/fargate_ssh.json")

  container_definitions_vars = {
    DOCKER_IMAGE_URL = module.ecr_ssh.repository_url
    AWS_LOGS_GROUP   = "/aws/ecs/${var.project}/${local.ws}/fargate_ssh"
    AWS_LOGS_REGION  = var.region
  }
}
```

```terraform:terraform/modules/fargate/main.tf
# ECS Cluster

resource "aws_ecs_cluster" "fargate" {
  name = var.name
}

## ECS Service

resource "aws_ecs_service" "fargate" {
  name            = var.name
  cluster         = aws_ecs_cluster.fargate.id
  task_definition = aws_ecs_task_definition.fargate.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = var.subnets
    security_groups  = var.security_groups
    assign_public_ip = var.assign_public_ip
  }

  lifecycle {
    ignore_changes = [desired_count]
  }
}

## ECS Task

resource "aws_ecs_task_definition" "fargate" {
  family                   = var.name
  container_definitions    = data.template_file.fargate.rendered
  task_role_arn            = aws_iam_role.fargate.arn
  execution_role_arn       = aws_iam_role.fargate.arn
  network_mode             = "awsvpc"
  cpu                      = var.task_cpu
  memory                   = var.task_memory
  requires_compatibilities = ["FARGATE"]
  tags                     = var.tags
}

data "template_file" "fargate" {
  template = var.container_definitions
  vars     = var.container_definitions_vars
}

## CloudWatch logs

resource "aws_cloudwatch_log_group" "fargate" {
  name = var.log_group_name
  tags = var.tags
}

## IAM

resource "aws_iam_role" "fargate" {
  name               = var.name
  assume_role_policy = data.aws_iam_policy_document.assume_policy.json
  tags               = var.tags
}

data "aws_iam_policy_document" "assume_policy" {
  statement {
    actions = ["sts:AssumeRole"]

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

resource "aws_iam_role_policy_attachment" "task_execution_role_policy" {
  role       = aws_iam_role.fargate.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}

resource "aws_iam_role_policy_attachment" "fargate" {
  role       = aws_iam_role.fargate.name
  policy_arn = aws_iam_policy.fargate.arn
}

resource "aws_iam_policy" "fargate" {
  name   = "${var.name}_fargate"
  policy = data.aws_iam_policy_document.fargate.json
}

data "aws_iam_policy_document" "fargate" {
  statement {
    actions = [
      "kms:Decrypt",
      "secretsmanager:GetSecretValue",
      "ssm:*",
      "iam:PassRole",
    ]

    resources = [
      "*",
    ]
  }
}
```

- IAMの権限ではSecrets Managerから秘匿情報を取得できるようにしています
- エージェント経由でコンテナを登録するため`ssm:*`の権限も付与しています


### VPC Endpoint

- コンテナイメージはVPC Endpoint経由で取得します
  - FargateはPrivateサブネットで動かすので、インターネット経由でECRからコンテナを取得できません
  - s3を追加しているのは、ECRの裏でS3が使われているためです
  - CloudWatch Logsも同様でPrivateサブネットからアクセス可能にします

```terraform:terraform/modules/vpc_endpoint/main.tf
resource "aws_vpc_endpoint" "ecr_api" {
  service_name        = "com.amazonaws.${var.region}.ecr.api"
  vpc_endpoint_type   = "Interface"
  vpc_id              = var.vpc_id
  subnet_ids          = var.subnet_ids
  security_group_ids  = var.security_group_ids
  private_dns_enabled = true
  tags                = var.tags
}

resource "aws_vpc_endpoint" "ecr_dkr" {
  service_name        = "com.amazonaws.${var.region}.ecr.dkr"
  vpc_endpoint_type   = "Interface"
  vpc_id              = var.vpc_id
  subnet_ids          = var.subnet_ids
  security_group_ids  = var.security_group_ids
  private_dns_enabled = true
  tags                = var.tags
}

resource "aws_vpc_endpoint" "logs" {
  service_name        = "com.amazonaws.${var.region}.logs"
  vpc_endpoint_type   = "Interface"
  vpc_id              = var.vpc_id
  subnet_ids          = var.subnet_ids
  security_group_ids  = var.security_group_ids
  private_dns_enabled = true
  tags                = var.tags
}

resource "aws_vpc_endpoint" "s3" {
  service_name      = "com.amazonaws.${var.region}.s3"
  vpc_endpoint_type = "Gateway"
  vpc_id            = var.vpc_id
  route_table_ids   = var.private_route_table_ids
  tags              = var.tags
}
```

## 環境作成と動作確認

- 今回の場合、ECSサービスの`desired_cout`は`0`にしているので初期状態ではコンテナは動いていません。

### terraformでAWS環境を作成

- インストールなどは飛ばします
  - 参考URL: https://qiita.com/kamatama_41/items/ba59a070d8389aab7694

```sh:terraform/test
$ cd terraform/test
$ terraform init
$ terraform plan
$ terraform apply
# outputの結果はメモしておきます
```

### コンテナイメージの作成

- ローカル環境でBUILDして、ECRにPUSHします

```sh:terraform/test/docker/fargate_ssh/docker
# aws profileの設定
$ aws configure --profile test
$ export AWS_PROFILE=test
# push_ecr.shを修正して、環境変数を入力
$ vi push_ecr.sh
```
```sh:terraform/test/docker/fargate_ssh/docker/push_ecr.sh
## ここを修正
export ECR_URL=""
export SSM_AGENT_CODE=""
export SSM_AGENT_ID=""
export AWS_REGION="ap-northeast-1"
export ACCESS_KEY_ID=""
export SECRET_ACCESS_KEY=""
```
```sh:terraform/test/docker/fargate_ssh/docker
$ ./push_ecr.sh
```

### コンテナ起動

- コンテナの起動にはfargatecliを使います

```sh
# インストール
$ go get -u github.com/awslabs/fargatecli
$ export PATH=$HOME/go/bin:$PATH
$ export AWS_PROFILE=test
$ export AWS_REGION=ap-northeast-1
$ fargatecli --version

fargate version 0.3.2

# ECSサービスの確認
$ fargatecli service list \
  --cluster aws-sample-test-fargate_ssh

# コンテナを1個だけ起動
$ fargatecli service scale aws-sample-test-fargate_ssh 1 \
  --cluster aws-sample-test-fargate_ssh

# 起動確認
$ fargatecli service info aws-sample-test-fargate_ssh \
  --cluster aws-sample-test-fargate_ssh

Service Name: aws-sample-test-fargate_ssh
Status: 
  Desired: 1
  Running: 0 #ここが1になればOK
  Pending: 1
```

### session managerからコンテナに接続

```sh
# 登録したインスタンスIDを確認
$ aws ssm describe-instance-information --query 'InstanceInformationList[0].InstanceId'
"mi-XXXXXXXX"
# コンテナに接続
$ aws ssm start-session --target mi-XXXXXXXX
```

### コンテナを停止

```sh
$ fargatecli service scale aws-sample-test-fargate_ssh 0 \
  --cluster aws-sample-test-fargate_ssh
```

## 利点と欠点

- 利点
    - パブリックIPアドレスを付与せずにsshぽいことが可能
    - 必要なときに立ち上げて接続可能
    - こまめに止めればお得
- 欠点
    - sshではないので、ポートフォワードなどができない
        - 設定次第ではできるみたい
    - /var/log/secureにログが残らない
        - セッションのログはSSMに残っている
    - 止め忘れると利用料がお高くなる
    - ssmのagentがインストールできるコンテナのみ利用可能

## まとめ

便利な半面、利用料が高かったり、sshの便利機能を使えなかったりします。
今後、Fargateのサービスが増えてくれば、標準でこういう機能もでてくると思います。
今後に期待!!! :muscle: