Systems Manager の Maintenance Windows によるメンテナンスタスクの定期実行と Datadog の Downtimes API によるアラートの抑止

次のことを実現するために意外に手間取ったので備忘録です。

  • Systems Manager の Maintenance Windows を使って負荷のかかる処理を定期的に行いたい
    • 処理が失敗したら通知したい
  • 負荷のかかる処理を行っている間は Datadog のモニターをミュートしたい

基本的に Terraform を使ってリソースを用意します。

Maintenance Windows についてのざっくりとした説明

Systems Manager に色んな機能があり過ぎて概要を把握するのが大変なんですが、Maintenance Windows に関しては次の点を押さえておけばいいんじゃないかと思います。

  • 定期実行するタスクを定義できる
    • タスクの種類として RunCommand, Automation, StepFunctions, Lambda のいずれかを指定できる
    • cron の代替として使うなら RunCommand の AWS-RunShellScript, AWS-RunRemoteScript を使うことになるんじゃないかと
  • タスクを実行するインスタンスをタグで指定できる
    • インスタンス ID を直接指定することもできるがタグを使って指定するのが一般的なんじゃないかと
  • 並列度を指定できる
    • 1 台ずつ実行していくこともできるし、2 台ずつもできるし、全体の N % 台ずつ実行していくこともできる
  • タスクの状態が特定の状態に変わった時に SNS にメッセージを送ることができる
    • 状態が変わる度に通知することもできるし、Failed 等特定の状態になった場合だけ通知することもできる
  • Execution, Execution Task, Task Invocation, Command などの概念がある
    • 1 つの Execution に対して、登録されている Maintenance Window Task の数だけ Execution Task が紐づく
    • 1 つの Execution Task に対して、登録されている Maintenance Window Target の数だけ Task Invocation が紐づく
  • Execution をキャンセルしても In Progress のタスクはキャンセルされない
    • See also aws ssm cancel-maintenance-window-execution help
  • Error threshold という概念がある
    • execution を停止するのにエラーを何個まで許可するかの数。1 であれば 1 インスタンスでエラーになっても処理を継続する。
    • どれだけエラーが起きようと全インスタンスで実行してほしい場合は 100 % を指定すれば良い
      • ただし、100 % を指定するとどれだけエラーになっていうようとタスク全体としては Success というステータスになるので注意
    • See also About Concurrency and Error Thresholds - AWS Systems Manager
  • 前回のスケジュール実行が長引いて終わらない場合は Skipped Overlapping という状態になる
  • Maintenace Windows の文脈の invocation と RunCommand の文脈での invocation は異なる

RunCommand タスクの場合、Execution, Execution Task, Task Invocation, Command 等の関係は次のようになるみたいです。


Show the source

体系的な内容はドキュメントを参照してください。

リソースの準備

EC2 インスタンス

次のようなインスタンスを準備します。

provider "aws" {
  version = "= 2.8.0"
  region  = "ap-northeast-1"
}

provider "datadog" {
  version = "= 1.8.0"
  api_key = "${var.datadog_api_key}"
  app_key = "${var.datadog_app_key}"
}

variable "datadog_api_key" {}
variable "datadog_app_key" {}
variable "security_group_id" {}

resource "aws_iam_role" "AmazonEC2RoleforSSM" {
  name = "AmazonEC2RoleforSSM"

  assume_role_policy = <<EOF
{
  "Version":"2012-10-17",
  "Statement":[
    {
      "Effect":"Allow",
      "Principal": {
        "Service": [
          "ec2.amazonaws.com"
        ]
      },
      "Action":"sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "AmazonEC2RoleforSSM" {
  role       = "${aws_iam_role.AmazonEC2RoleforSSM.id}"
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2RoleforSSM"
}

resource "aws_iam_instance_profile" "AmazonEC2RoleforSSM" {
  name = "AmazonEC2RoleforSSM"
  role = "${aws_iam_role.AmazonEC2RoleforSSM.id}"
}

data "aws_ami" "amazon_linux_2" {
  most_recent = true

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-2.0.*-x86_64-gp2"]
  }

  owners = ["amazon"]
}

resource "aws_instance" "main" {
  count = 3

  ami           = "${data.aws_ami.amazon_linux_2.id}"
  instance_type = "t3.micro"

  tags {
    MaintenanceTarget = "true"
  }

  vpc_security_group_ids = ["${var.security_group_id}"]

  iam_instance_profile = "${aws_iam_instance_profile.AmazonEC2RoleforSSM.id}"

  user_data = <<EOF
#!/bin/bash

DD_API_KEY=${var.datadog_api_key} bash -c "$(curl -L https://raw.githubusercontent.com/DataDog/dd-agent/master/packaging/datadog-agent/source/install_agent.sh)"
yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
yum install -y stress
EOF
}

Datadog モニター

直近 1 分間の CPU 使用率(system + user)の最小値が 80 % を超えると ALERT になるモニターを作成します。
状態が変化したら Slack の notification チャンネルに通知するようにします。1

resource "datadog_monitor" "cpu_utilization" {
  name  = "CPU utilization monitor on {{host.name}}"
  type  = "query alert"
  query = "min(last_1m):avg:system.cpu.system{*} by {host} + avg:system.cpu.user{*} by {host} > 80"

  message = "@slack-notification CPU utilization is very high on {{host.name}}"
}

Maintenance Windows とタスク

次のような maintenance window を作成します。

  • MaintenanceTarget=true というタグが付与されたインスタンスを対象にする
  • 5 分毎に S3 上のスクリプトを実行する
  • SNS の notify-to-slack というトピックにメッセージを飛ばす
    • command invocation が “Cancelled”, “Failed”, “TimedOut” になったインスタンスがあればその度に通知する
  • command invocation が何個失敗しようと全インスタンスでコマンドを実行する
  • Datadog の API key と application key は parapeter store から取り出す
    • 簡単のため SecureString ではなく String として保存する
variable "remote_script_s3_bucket" {}
variable "remote_script_s3_key" {}

data "aws_sns_topic" "notify_to_slack" {
  name = "notify-to-slack"
}

resource "aws_iam_role" "MySNSPublishRole" {
  name = "MySNSPublishRole"

  assume_role_policy = <<EOF
{
  "Version":"2012-10-17",
  "Statement": [
    {
      "Effect":"Allow",
      "Principal": {
        "Service": "ssm.amazonaws.com"
      },
      "Action":"sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "allow_to_publish_messages_policy" {
  name = "allow_to_publish_messages_policy"
  role = "${aws_iam_role.MySNSPublishRole.id}"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "sns:Publish",
      "Resource": "*"
    }
  ]
}
EOF
}

resource "aws_iam_role" "AmazonSSMMaintenanceWindowRole" {
  name = "AmazonSSMMaintenanceWindowRole"

  assume_role_policy = <<EOF
{
  "Version":"2012-10-17",
  "Statement":[
    {
      "Effect":"Allow",
      "Principal": {
        "Service": [
          "ssm.amazonaws.com"
        ]
      },
      "Action":"sts:AssumeRole"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy" "allow_to_pass_sns_access_role_policy" {
  name = "allow_to_pass_sns_access_role_policy"
  role = "${aws_iam_role.AmazonSSMMaintenanceWindowRole.id}"

  policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "${aws_iam_role.MySNSPublishRole.arn}"
    }
  ]
}
EOF
}

resource "aws_iam_role_policy_attachment" "AmazonSSMMaintenanceWindowRole" {
  role       = "${aws_iam_role.AmazonSSMMaintenanceWindowRole.id}"
  policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonSSMMaintenanceWindowRole"
}

resource "aws_ssm_maintenance_window" "mw_test" {
  name              = "mw-test"
  schedule          = "cron(0 */5 * * * ? *)"
  schedule_timezone = "Etc/UTC"
  duration          = 2
  cutoff            = 1
}

resource "aws_ssm_maintenance_window_task" "mw_test" {
  window_id        = "${aws_ssm_maintenance_window.mw_test.id}"
  name             = "mw_test"
  task_type        = "RUN_COMMAND"
  task_arn         = "AWS-RunRemoteScript"
  priority         = 1
  service_role_arn = "${aws_iam_role.AmazonSSMMaintenanceWindowRole.arn}"
  max_concurrency  = 1
  max_errors       = "100%"

  targets {
    key    = "WindowTargetIds"
    values = ["${aws_ssm_maintenance_window_target.mw_test.id}"]
  }

  # You can use `task_invocation_parameters` after the following PR is merged
  #   https://github.com/terraform-providers/terraform-provider-aws/pull/7823
  #task_invocation_parameters {
  #  run_command_parameters {
  #    parameters {
  #      name = "commandLine"
  #      values = [
  #        "maintenance.sh {{ssm:${aws_ssm_parameter.datadog_api_key.name}}} {{ssm:${aws_ssm_parameter.datadog_app_key.name}}}"
  #      ]
  #    }
  #
  #    parameters {
  #      name = "sourceInfo"
  #      values = ["{\"path\":\"https://s3-ap-northeast-1.amazonaws.com/${var.remote_script_s3_bucket}/${var.remote_script_s3_key}\"}"]
  #    }
  #
  #    parameters {
  #      name = "sourceType"
  #      values = ["S3"]
  #    }
  #
  #    service_role_arn = "${aws_iam_role.MySNSPublishRole.arn}"
  #
  #    notification_config {
  #      notification_arn    = "${data.aws_sns_topic.notify_to_slack.arn}"
  #      notification_events = ["TimedOut", "Cancelled", "Failed"]
  #      notification_type   = "Invocation"
  #    }
  #  }
  #}
}

resource "aws_ssm_maintenance_window_target" "mw_test" {
  window_id     = "${aws_ssm_maintenance_window.mw_test.id}"
  resource_type = "INSTANCE"

  targets {
    key    = "tag:MaintenanceTarget"
    values = ["true"]
  }
}

# Use String for simplicity
#   SSM document doesn't support SecureString in parameters.
#   cf. https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-doc-syntax.html
resource "aws_ssm_parameter" "datadog_api_key" {
  name  = "/datadog/api_key"
  type  = "String"
  value = "${var.datadog_api_key}"
}

# Use String for simplicity
#   SSM document doesn't support SecureString in parameters.
#   cf. https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-doc-syntax.html
resource "aws_ssm_parameter" "datadog_app_key" {
  name  = "/datadog/app_key"
  type  = "String"
  value = "${var.datadog_app_key}"
}

output "window_id" {
  value = "${aws_ssm_maintenance_window.mw_test.id}"
}

output "window_task_id" {
  value = "${aws_ssm_maintenance_window_task.mw_test.id}"
}

コメントにも書いてあるように、https://github.com/terraform-providers/terraform-provider-aws/pull/7823 がマージされて新しいバージョンの terraform-provider-aws がリリースされない限り、terraform を使って SNS の設定をすることができません。
代わりに awscli を使って登録します。

aws ssm update-maintenance-window-task \
  --window-id <window-id> \
  --window-task-id <window-task-id> \
  --task-invocation-parameters '{
  "RunCommand": {
    "ServiceRoleArn": "<MySNSPublishRole-ARN>",
    "NotificationConfig": {
      "NotificationArn": "<notify-to-slack-ARN>",
      "NotificationEvents": ["TimedOut", "Cancelled", "Failed"],
      "NotificationType": "Invocation"
    },
    "Parameters": {
      "commandLine": [
        "maintenance.sh {{ssm:/datadog/api_key}} {{ssm:/datadog/app_key}}"
      ],
      "sourceInfo": [
        "{\"path\":\"https://s3-ap-northeast-1.amazonaws.com/<remote_script_s3_bucket>/<remote_script_s3_key>\"}"
      ],
      "sourceType": [
        "S3"
      ]
    }
  }
}'

リモートスクリプト

次のような内容のスクリプトを s3://$remote_script_s3_bucket/$remote_script_s3_key に配置します。

  • stress コマンドで 2 分間 CPU を使い切る負荷を与える
  • stress コマンドを実行する前に対象インスタンスを 3 分間 Datadog モニターの監視対象から外す
#!/bin/bash

set -e

api_key=$1
app_key=$2

instance_id=$(curl -s http://169.254.169.254/latest/meta-data/instance-id)
end=$(date -d 3-minutes +%s)

curl -s -X POST -H "Content-type: application/json" --fail -d "{
  \"scope\": \"host:$instance_id\",
  \"end\": $end
}" "https://api.datadoghq.com/api/v1/downtime?api_key=$api_key&application_key=$app_key"

stress -c 2 -t 120

API のパラメータについては https://docs.datadoghq.com/api/?lang=bash#schedule-monitor-downtime を参照してください。

Datadog モニターの様子

リモートスクリプトを少しいじって、Downitmes API を叩かずに stress を実行するケースと、stress を 6 分間実行するケースも試してみた時のモニターの様子が次の図です。

ミュートされている時間帯が網掛けになっている箇所です。
ミュートされていない場合は高負荷状態が 1 分続くとすぐに通知が飛びます。

3 分間ミュートする場合、stress を 2 分間しか実行しない場合は通知が飛びませんが、stress を 6 分間実行するとミュートが解除されたタイミングで通知が飛びます。

意図したとおりに動いてますね!

ハマりポイント

今回色々ハマったんですが、他の人でもハマりそうな内容についてまとめてみました。

EC2 インスタンスの権限不足で NoInstancesInTag

指定した条件にマッチするインスタンスが存在するのに NoInstancesInTag となることがあります。

これは条件にマッチする EC2 の instance profile の権限不足が考えられます。上記の例では AmazonEC2RoleforSSM を attach した instance profile を指定しているので必要な権限が与えられています。

リモートスクリプトの path は Object URL でないといけない

path には次のように Object URL を記述しなければいけません。

{"path":"https://s3-ap-northeast-1.amazonaws.com/${var.remote_script_s3_bucket}/${var.remote_script_s3_key}"}

次のように記述すると Step 1 で AccessDenied: Access Denied というエラーが出ているにも関わらず Step 1 で止まらず、Step 3 で runShellScript の実行に失敗します。

{"path":"s3://${var.remote_script_s3_bucket}/${var.remote_script_s3_key}"}

これは、AWS-RunRemoteScript で使われている aws:downloadContent プラグインの仕様として Object URL を要求するようになっているからのようです。
cf. https://docs.aws.amazon.com/systems-manager/latest/userguide/ssm-plugins.html#aws-downloadContent

なお、AWS-RunRemoteScript の定義は次のように確認可能です。

% aws ssm get-document --name AWS-RunRemoteScript | jq -r .Content | jq .
{
  "schemaVersion": "2.2",
  "description": "Execute scripts stored in a remote location. The following remote locations are currently supported: GitHub (public and private) and Amazon S3 (S3). The following script types are currently supported: #! support on Linux and file associations on Windows.",
  "parameters": {
    "sourceType": {
      "description": "(Required) Specify the source type.",
      "type": "String",
      "allowedValues": [
        "GitHub",
        "S3"
      ]
    },
    "sourceInfo": {
      "description": "(Required) Specify the information required to access the resource from the source. If source type is GitHub, then you can specify any of the following: 'owner', 'repository', 'path', 'getOptions', 'tokenInfo'. If source type is S3, then you can specify 'path'.",
      "type": "StringMap",
      "displayType": "textarea",
      "default": {}
    },
    "commandLine": {
      "description": "(Required) Specify the command line to be executed. The following formats of commands can be run: 'pythonMainFile.py argument1 argument2', 'ansible-playbook -i \"localhost,\" -c local example.yml'",
      "type": "String",
      "default": ""
    },
    "workingDirectory": {
      "type": "String",
      "default": "",
      "description": "(Optional) The path where the content will be downloaded and executed from on your instance.",
      "maxChars": 4096
    },
    "executionTimeout": {
      "description": "(Optional) The time in seconds for a command to complete before it is considered to have failed. Default is 3600 (1 hour). Maximum is 28800 (8 hours).",
      "type": "String",
      "default": "3600",
      "allowedPattern": "([1-9][0-9]{0,3})|(1[0-9]{1,4})|(2[0-7][0-9]{1,3})|(28[0-7][0-9]{1,2})|(28800)"
    }
  },
  "mainSteps": [
    {
      "action": "aws:downloadContent",
      "name": "downloadContent",
      "inputs": {
        "sourceType": "{{ sourceType }}",
        "sourceInfo": "{{ sourceInfo }}",
        "destinationPath": "{{ workingDirectory }}"
      }
    },
    {
      "precondition": {
        "StringEquals": [
          "platformType",
          "Windows"
        ]
      },
      "action": "aws:runPowerShellScript",
      "name": "runPowerShellScript",
      "inputs": {
        "runCommand": [
          "",
          "$directory = Convert-Path .",
          "$env:PATH += \";$directory\"",
          " {{ commandLine }}",
          "if ($?) {",
          "    exit $LASTEXITCODE",
          "} else {",
          "    exit 255",
          "}",
          ""
        ],
        "workingDirectory": "{{ workingDirectory }}",
        "timeoutSeconds": "{{ executionTimeout }}"
      }
    },
    {
      "precondition": {
        "StringEquals": [
          "platformType",
          "Linux"
        ]
      },
      "action": "aws:runShellScript",
      "name": "runShellScript",
      "inputs": {
        "runCommand": [
          "",
          "directory=$(pwd)",
          "export PATH=$PATH:$directory",
          " {{ commandLine }} ",
          ""
        ],
        "workingDirectory": "{{ workingDirectory }}",
        "timeoutSeconds": "{{ executionTimeout }}"
      }
    }
  ]
}

commandLine の配列に複数要素を渡してはいけない

commandLine を Docker の感覚で次のように書くと “The specified perameters are incomplete or invalid.” というよくわからないエラーに遭遇することになります。

[
  "maintenance.sh",
  "{{ssm:${aws_ssm_parameter.datadog_api_key.name}}}",
  "{{ssm:${aws_ssm_parameter.datadog_app_key.name}}}"
]

RunCommand に SecureString のパラメータは指定できない

parameter store に SecureString として保存すると何の情報もなしに Failed になるので注意です。

SSM Document Syntax にも次のように非サポートである旨が記述されています。

You can reference String and StringList Systems Manager parameters in this section of a document. You can’t reference Secure String Systems Manager parameters in this section of a document.

SecureString を使う場合、次の記事のように EC2 インスタンスがリモートスクリプトを実行する際に復号するしかなさそうです。

【Tips】Run CommandでParameter StoreのSecure String(暗号化した文字列)を参照する | DevelopersIO

以上、備忘録でした!同じようにハマった人の助けになれば幸いです。

  1. Slack 連携用の設定は別途必要です