LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

踏み台の管理コストを削減!ECS ExecとTerraformでつくる本番オペレーション環境

こんにちは。マッハバイトを運営するアルバイト事業部エンジニアの mnmandahalf です。

みなさんは本番DBへのSQLの手動実行等の作業をどんな環境で行なっていますか? 通常はDBにアクセスする用の踏み台サーバにSSHログインして作業を行うケースが多いと思います。

マッハバイトでも最近まで(現在もDBによっては)踏み台を使用していたのですが、最近新・本番作業環境を導入したのでその背景とつまづきポイント等についてご紹介します。

これまでのマッハバイトにおける本番作業

現在マッハバイトのシステムはAWS移行の真っ最中であり、大部分の環境がオンプレで稼働しています。 これまでの本番作業には以下のような痛みを抱えていました。

  • デプロイやDBへのクエリの実行はオペレーション用のオンプレサーバに多段SSHログインした上で行う必要があり、SSHキーの管理が煩雑になっている
  • オンプレサーバ自体のOSが古かったり、長年運用を続ける中で既存のAnsible Playbookが実行できなくなっていたりしていて、オンプレサーバへの新しいツールの導入が困難
  • IT統制の監査に対応するため、事前の手順書レビューと実行時のログを手で残す運用が手間になっている

これらの点を踏まえつつ、やがてはシステム全体をクラウドに移行する計画があり上記に代わる作業環境が必要になってきました。

これからのマッハバイトにおける本番作業

以下の点を満たすべく、オペレーション用のECS Serviceを用意し、ECS Exec (ecs execute-command) による作業を行うことにしました。

  • SSHキーの管理をしたくない → セッションマネージャーの利用
  • EC2の管理をしたくない → オペレーションサーバのコンテナ化
  • 気軽にツールを導入したい → オペレーションサーバのコンテナ化
  • 作業の監査ログを残せるようにしたい → セッションマネージャーの利用
  • GitHub Actionsから処理をキックすることでPull Request ベースで承認・実行の運用をしたい(こちらは残課題)

ECS Exec は、AWS Systems Manager (SSM) セッションマネージャーを使用して実行中のコンテナとの接続を確立し、AWS Identity and Access Management (IAM) ポリシーを使用して実行中のコンテナで実行中のコマンドへのアクセスを制御します。 − デバッグ用にAmazon ECS Exec を使用 - Amazon ECS

構成は以下の図の通りになっています。

左側のオンプレ踏み台サーバの多段SSHが廃止となり、右側のセッションマネージャーを介したコンテナログインが新しい経路になります。

TerraformでのECS Execの設定方法

前提としてECS Serviceを用意する必要があるのですが、そちらは割愛します。今回はECS on Fargateを導入しているので、EC2の管理も不要になっています。

今回はECS Execに対応することに加え、監査に対応するためセッションマネージャーの監査ログをCloudWatch Logsに保存すること、ロググループをKMSキーで暗号化することが必要でした。

docs.aws.amazon.com

基本的には上記のAWS公式ユーザーガイドに従って追加しますが、若干前提知識が必要で試行錯誤したので以下にまとめてみました。

Terraformバージョン: 1.2.8
AWS Provider バージョン: 4.30.0
ECSタスクロールにアタッチするポリシードキュメント
data "aws_iam_policy_document" "operation_ecs_task" {
  # 独自に付与したいポリシー
  statement {
    effect = "Allow"
    actions = [
      "s3:GetObject",
    ]
    resources = [
      "${aws_s3_bucket.operation_configration_data.arn}/*"
    ]
  }
  # SSMに必要なポリシー
  statement {
    effect = "Allow"
    actions = [
      "ssmmessages:CreateControlChannel",
      "ssmmessages:CreateDataChannel",
      "ssmmessages:OpenControlChannel",
      "ssmmessages:OpenDataChannel"
    ]
    resources = ["*"]
  }
  # 監査ログの書き込みに必要なポリシー
  statement {
    effect = "Allow"
    actions = [
      "logs:DescribeLogGroups"
    ]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:DescribeLogStreams",
      "logs:PutLogEvents"
    ]
    resources = ["${aws_cloudwatch_log_group.execute_command_audit.arn}:*"]
  }

  statement {
    effect = "Allow"
    actions = [
      "kms:Decrypt"
    ]
    resources = [aws_kms_key.execute_command_audit.arn]
  }
}
ECS Execを実行するのに必要なIAMにアタッチするポリシードキュメント
data "aws_iam_policy_document" "ecs_operation" {
  # ECS Execに必要なポリシー
  statement {
    effect = "Allow"
    actions = [
      "ecs:ExecuteCommand",
      "ecs:DescribeTasks",
      "kms:GenerateDataKey"
    ]
    resources = [
      aws_ecs_cluster.main.arn,
      "arn:aws:ecs:${var.main_region}:${data.aws_caller_identity.current.account_id}:task/${aws_ecs_cluster.main.name}/*",
      aws_kms_key.execute_command_audit.arn
    ]
  }
  # ECS Execに必要なTask IDを取得するポリシー
  statement {
    effect = "Allow"
    actions = [
      "ecs:ListTasks"
    ]
    resources = ["*"]
    condition {
      test     = "ArnEquals"
      variable = "ecs:cluster"
      values   = [aws_ecs_cluster.main.arn]
    }
  }
}
ECSクラスタでexecute-commandの監査ログをCloudWatchに流し、ログをKMSキーで暗号化する設定
resource "aws_ecs_cluster" "main" {
  name = "${var.env_name}-main"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }

  configuration {
    execute_command_configuration {
      kms_key_id = aws_kms_key.execute_command_audit.id
      logging    = "OVERRIDE"

      log_configuration {
        cloud_watch_encryption_enabled = true
        cloud_watch_log_group_name     = aws_cloudwatch_log_group.execute_command_audit.name
      }
    }
  }

  tags = {
    Name = "${var.env_name}-main"
    Env  = var.env_name
  }
}

#
# KMS 
#
data "aws_iam_policy_document" "execute_command_audit" {
  statement {
    sid    = "EnableIAMUserPermissions"
    effect = "Allow"
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"]
    }
    actions   = ["kms:*"]
    resources = ["*"]
  }

  statement {
    effect = "Allow"
    principals {
      type        = "Service"
      identifiers = ["logs.${var.main_region}.amazonaws.com"]
    }
    actions = [
      "kms:Encrypt*",
      "kms:Decrypt*",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:Describe*"
    ]
    resources = ["*"]
    condition {
      test     = "ArnEquals"
      variable = "kms:EncryptionContext:aws:logs:arn"
      values = [
        # 循環参照を回避
        "arn:aws:logs:${var.main_region}:${data.aws_caller_identity.current.account_id}:log-group:/ecs/${var.env_name}/${var.env_name}-main/execute-command-audit"
      ]
    }
  }
}
resource "aws_kms_key" "execute_command_audit" {
  description              = "Master Key for ${var.env_name}-main ecs execute command audit log"
  is_enabled               = true
  customer_master_key_spec = "SYMMETRIC_DEFAULT"
  key_usage                = "ENCRYPT_DECRYPT"
  enable_key_rotation      = false
  deletion_window_in_days  = 30
  policy                   = data.aws_iam_policy_document.execute_command_audit.json

  tags = {
    Name = "${var.env_name}-main execute-command-audit"
    Env  = var.env_name
  }
}

resource "aws_kms_alias" "execute_command_audit" {
  name          = "alias/${var.env_name}-main-execute-command-audit"
  target_key_id = aws_kms_key.execute_command_audit.key_id

  depends_on = [aws_kms_key.execute_command_audit]
}

#
# CloudWatch Logs
#
resource "aws_cloudwatch_log_group" "execute_command_audit" {
  name = "/ecs/${var.env_name}/${var.env_name}-main/execute-command-audit"

  retention_in_days = 365
  kms_key_id        = aws_kms_key.execute_command_audit.arn

  tags = {
    Name = "${var.env_name}-main execute-command-audit"
  }
  depends_on = [aws_kms_key.execute_command_audit]
}

監査に対応可能にするためCloudWatch Log Groupの retention_in_days を簡易的に1年にしていますが、場合によってはS3に保存してもいいかもしれません。

ハマりどころ

キーポリシーの設定ミスで監査ログが出力されない

検証の途中、execute-commandでエラーが発生しないものの監査ログが出力されないということが起こりました。 調べるとscript cat コマンドがインストールされていること、等の要件がありつつもクリアできていたため、 コンテナのシェルにログインし/var/log/amazon/ssm/のログを確認したところ、以下のようなエラーが表示されていました。

2022-09-01 17:46:44 ERROR [HandleAwsError @ awserr.go.49] [ssm-session-worker] [ecs-execute-command-0d8934d5f1338cfe8] [DataBackend] [pluginName=InteractiveCommands] error when calling AWS APIs. error details - AccessDeniedException: The specified KMS key does not exist or is not allowed to be used with LogGroup 'arn:aws:logs:ap-northeast-1:${account_id}:log-group:/ecs/staging/staging-main/execute-command-audit'
        status code: 400, request id: 586eca5f-7373-4e4f-b649-07a94d7d3987

今回はログの暗号化に使用しているKMSキーのキーポリシーの設定に誤字があったことが判明しました。

シェルにログインしないと監査ログがうまく出ない

※ 厳密には「インタラクティブなコマンドを実行しないと」が正しいかと思います。 当初はGitHub Actionsのワークフローの中で --non-interactiveでコマンドを実行するというPull Requestベースの本番作業運用を考えていました。 現在は--non-interactiveはオプションが予約されているだけで--interactiveしか対応していないようですが、コマンドが終了したらセッションが終了するので非インタラクティブ風に使うこともできなくはありませんでした。

そこで下記のようなコマンドをGitHub Actionsで実行してみたのですが、

aws ecs execute-command \
    --cluster mycluster \
    --task ${TASK_ID} \
    --interactive \
    --command "mysql -h ${DB_HOST} -u ${DB_USER} -p${{ secrets.DB_PASSWORD }}  -e 'CREATE database mydatabase IF NOT EXISTS;'"

上記の監査ログ(セッションマネージャーのログ)には実行したコマンド mysql -h ${DB_HOST} -u ${DB_USER} -p${{ secrets.DB_PASSWORD }} -e 'CREATE database mydatabase IF NOT EXISTS;' が記載されませんでした。

Script started on 2022-09-01 19:21:38+09:00 [<not executed on terminal>]
mysql: [Warning] Using a password on the command line interface can be insecure.

Script done on 2022-09-01 19:21:38+09:00 [COMMAND_EXIT_CODE="0"]
Script started on 2022-09-01 19:21:38+09:00 [<not executed on terminal>] mysql: [Warning] Using a password on the command line interface can be insecure. Script done on 2022-09-01 19:21:38+09:00 [COMMAND_EXIT_CODE="0"]

GitHub Actions側にコマンドのログは残るものの、監査ログで実行されたコマンドを見ることができないので現在は実用に向かないと判断し、しばらく手順書運用を継続することにしました。

おそらく、--non-interactiveオプションが実装されたら単発コマンド実行のログが残るのではないかと期待したく思います。

改善したいところ

execute-command のコマンドを打つ手間を軽減したい

実際にコンテナにログインしてコマンドを実行するには aws ecs execute-command を実行する必要があります。

execute-command — AWS CLI 1.29.31 Command Reference

少なくともクラスタID (デフォルトのクラスタ以外を利用の場合) タスクID を指定する必要があるのですが、作業者にタスクIDの確認を毎回実行してもらうには手間がかかります。

aws-cliやsession-manager-pluginのインストール自体も若干手間がかかるため、aws-sdk-goを利用してタスクIDの確認も行ってくれるcliを実装したいと当初思ったのですが、 現在は各種SDKがセッションにログインしての対話的なコマンド実行をサポートしていないというのもあり、各自のsession-manager-pluginのインストールが必須になります。

少なくともタスクIDの確認については簡易なコマンドを用意しておくことで、毎回AWSコンソールにログインして確認する手間は減らすという運用でお茶を濁しています。

(今回のECSはタスクの実行数を1、タスクに紐づくコンテナも1つにしているため、list-tasks で取得したタスクリストの1つ目からタスクIDを抜粋してexecute-command に渡す単純なコマンドにしています)

また、実際に本番環境にアクセス可能かつMFA必須なロールへのスイッチロールを簡略化するためにaws-vaultを利用しています。

# 事前に${TASK_ID}を取得
TASK_ID=$(aws-vault exec myprofile -- aws ecs list-tasks --cluster mycluster --service-name staging-mb-operation | jq .taskArns[0] | sed -e 's/"//g' | cut -d "/" -f 3)
 
# コンテナにログインしてインタラクティブにコマンドを実行
aws-vault exec myprofile -- aws ecs execute-command \
    --cluster mycluster \
    --task ${TASK_ID} \
    --interactive \
    --command "/bin/bash"

非インタラクティブなコマンド実行に対応する

先述の通り、GitHub ActionsのワークフローでECS Execを実行したいと思っていたのですが、シェルにログインできないとCloudWatch Logsにコマンドの監査ログが残らないので悩ましく感じています。

ExecuteCommandの実行ログ自体はCloudTrailに残るのですが、シェルログインした場合はその後実行したコマンドのログを残したいですし、そうでない場合も合わせて実行コマンドがCloudWatch Logs一箇所で確認できると嬉しいです。

あるいはこのECSをGitHub ActionsのSelf-Hosted Runnerに登録することで、セッションマネージャーを経由せず何らかの方法でログを残すことができれば、コンテナ化の便益を得つつPull Requestベースの作業運用ができるので、そういった活用法も視野に入れています。

実行コマンドを制限する

非インタラクティブなコマンドの実行に対応するまでは、毎回シェルにログインしてもらうために何らかの形で/bin/bash等にのみ実行コマンドを絞れないかというフィードバックをもらっているので、こちらも上記と併せて検討したいです。

We are hiring!

マッハバイトでは開発者のアジリティ獲得に向け一緒にアーキテクチャやオペレーションの刷新を行なってくれる仲間を求めています! まだまだ解く必要のある面白いパズルがたくさんあるので、ご興味を持った方はぜひカジュアル面談などお声掛けください。

hrmos.co

hrmos.co