ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

Slack から AWS CLI を実行して EC2/ECS をスケーリングする

こんにちは、SRE の中村です。
この記事はユニファアドベントカレンダーの17日目の記事です!
Unifa Advent Calendar 2024 - Adventar

はじめに

今日は、年末年始のお休み期間でもスマホ一つで障害対応ができるようになりました、というお話をしていきます。(絶対にしたくはない)
先日、SRE が勤務開始する前に障害が発生し、対応権限のあるメンバーがおらず初動が遅れてしまったことがありました。

そこで、Bot 的なもので AWS リソースの操作ができないかという話になり、Slack から AWS CLI を実行できる環境を構築することになりました。
以下のような構成になります。

リソースと矢印が多く少し見づらくなってしまったのですが、ざっくり以下のようになっています。

  1. Slack から AWS CLI を実行
  2. AWS Chatbot(Slack app) が AWS Chatbot(AWS リソース) に CLI コマンドを渡す
  3. AWS Chatbot が Step Functions を実行
  4. Lambda を実行し Slack に承認可否を求める
  5. 承認結果を API Gateway を介して Lambda に渡し、承認された場合は継続、拒否された場合はそこで終了
  6. 承認された場合は該当リソースをスケーリング
  7. スケーリング結果を Slack に返す

では具体的にどうなっているのかというお話は、要点をかいつまみながら以下に続けます。

Slack から Step Functions を実行するまでの動き

そもそも Slack から AWS CLI が実行できるというだけで感動だったのですが、設定は思った以上に簡単です。
まず Slack チャンネルに AWS Chatbot というアプリを入れ、あとは AWS サービスの AWS Chatbot と Slack を連携させるのみです。
以下の記事が参考になるかと思います。
Slack と AWS Chatbot で ChatOps をやってみよう - builders.flash☆ - 変化を求めるデベロッパーを応援するウェブマガジン | AWS

この記事では初めに SNS が出てきますが、これは Slack に通知するために使用しているもので、今回とはデータの流れが逆になっています。
SNS が無くても CLI は実行できます。

記事の最後にもキャプチャが載っていましたが、実際に Slack から AWS CLI を実行するときは以下のようになります。

非常に簡単ですね!

ただし一点気をつけなければならないのは、 実行できる CLI はAWS Chatbot の権限に依存するため、 仮に admin 権限を付与していた場合、誰でも好き放題できてしまうことになります。
この辺りは Chatbot の権限 + ガードレールのポリシーで必ず制限をかけておくことをお勧めします。↓
Understanding AWS Chatbot permissions - AWS Chatbot

さて、これで Slack から AWS リソースが操作できるようになりました。
なので、必要なパラメータを持たせて EC2 や ECS をスケーリングできる Lambda を実行すれば今回の目的は達成できます。

ただ、Step Functions やら API Gateway やら色々と組み合わせているのは、Slack 上で承認フローを挟む必要があったからです。
いくら Chatbot の権限を制限しても、自由に EC2/ECS のスケーリングができるのはちょっと安全とは言い難いです。
これは悪さをする人がいるのではないか、ということではなく誤操作を恐れてのことです。誰にでもミスはあります。

そこで登場するのが Step Functions と API Gateway です。
ではどのように承認フローを実現するか見ていきます。

Step Functions, API Gateway, Lambda で承認フローを構築する

CloudFormation で構築しているので、 template.yaml に基づいてお話しします。

WaitForPermissionScalingAbsoluteValueECS: 
  Type: Task
  Resource: arn:aws:states:::lambda:invoke.waitForTaskToken
  Parameters: 
    FunctionName: !Ref PostScalingRequestToSlackLambda
    Payload:
      TaskToken.$: $$.Task.Token
      AWSAccountID.$: $.aws_account_id
      ECSClusterName.$: $.ecs_cluster_name
      ECSServiceName.$: $.ecs_service_name
      Min.$: $.min
      Max.$: $.max
  ResultPath: $.payload
  TimeoutSeconds: 600
  Catch: 
    - ErrorEquals: 
      - RejectedScalingRequest
      Next: Rejected
    - ErrorEquals: 
      - States.ALL
      Next: Error
  Next: ExecScalingEC2OrECS

ステートマシンを待機させる

ここで Resource に指定している arn:aws:states:::lambda:invoke.waitForTaskToken は、コールバックタスクというものです。
これによって Slack 上で承認可否が確定されるまでステートマシンを待機させることができます。
Discover service integration patterns in Step Functions - AWS Step Functions

詳細は上記ドキュメントを確認していただきたいのですが、待機させている間に ParametersFunctionName で指定した Lambda を実行し、 最終的に SendTaskSuccess or SendTaskFailure API のパラメータとして task_token を返すことでステートマシンを再開させます。

「最終的に」と表現したのは、その Lambda 自身が返す必要はなく、誰からだろうと task_token さえ返せば問題ないためです。
実際今回の場合、この Lambda はスケーリングの承認リクエストを Slack に送信するのみで、 task_token は別の Lambda が返しています。

では task_token を返す Lambda はどのように実行しているかというと、Slack での承認 -> API Gateway -> Lambda という流れになっています。
承認結果を受け取らないと SendTaskSuccess or SendTaskFailure API どちらを実行するか決められないので、Step Functions からは外れて Slack での承認がトリガーになっています。

ちなみに Slack には以下のようなメッセージを送信しています。
ここで「Approve」「Reject」どちらかのボタンを押すと最終確認画面を挟み確定されるようにしています。

Slack Payload はこちら

    payload = {
        "blocks": [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": text
                }
            },
            {
                "type": "actions",
                "elements": [
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "Approve"
                        },
                        "confirm": {
                            "title": {
                                "type": "plain_text",
                                "text": "承認してよろしいですか?"
                            },
                            "text": {
                                "type": "mrkdwn",
                                "text": text
                            },
                            "confirm": {
                                "type": "plain_text",
                                "text": "OK"
                            },
                            "deny": {
                                "type": "plain_text",
                                "text": "Cancel"
                            }
                        },
                        "style": "primary",
                        "value": task_token
                    },
                    {
                        "type": "button",
                        "text": {
                            "type": "plain_text",
                            "text": "Reject"
                        },
                        "confirm": {
                            "title": {
                                "type": "plain_text",
                                "text": "却下してよろしいですか?"
                            },
                            "text": {
                                "type": "mrkdwn",
                                "text": text
                            },
                            "confirm": {
                                "type": "plain_text",
                                "text": "OK"
                            },
                            "deny": {
                                "type": "plain_text",
                                "text": "Cancel"
                            }
                        },
                        "style": "danger",
                        "value": task_token
                    }
                ]
            }
        ]
    }

※ Slack Payload の作成時は以下利用
Reference: blocks | Slack

Slack から API Gateway にリクエストする

続いて Slack から API Gateway へのリクエストですが、Slack app 側でボタン押下時のリクエスト先を設定しています。
以下の部分で指定できます。

ここで指定した API Gateway に対し、上述した Slack Payload の valuetask_token を渡しているため、後続の Lambda で受け取ることができるようになります。

あとは長旅から帰ってきた task_token と共に SendTaskSuccess or SendTaskFailure API を実行し、承認結果によって処理を分岐させ、「Approve」なら対象リソースをスケーリング、「Reject」なら終了させるだけです。
この辺りは Lambda でお好きなように実装していただければ良さそうです。

尚、ここまで必要最小限の機能のお話をしてきましたが、弊社ではいくつか工夫を施してあり、以下の機能も追加してあります。

  • 現在の AutoScaling 設定(そもそも今何台動いているのかなど)が分からない場合でも安全にスケーリングできる
    • 絶対値と相対値どちらを指定してもスケーリングできる
      • 絶対値であれば最小何台、最大何台という風に決め打ちする指定法で、相対値であれば現在の台数にプラス何台という指定法
    • Slack 上で承認可否を求めるメッセージには現在の AutoScaling 設定を添える
  • 「Approve」or「Reject」ボタンが押されたらメッセージを修正
    • ボタン部分を消して、ボタンを押した Slack ユーザー名と承認結果で上書きすることで重複して押せない & 誰が押したのか分かるようにする
  • スケーリング結果やエラー発生時には Slack に通知
  • 「Reject」ボタンが押された場合はエラー扱いにはしない

おわりに

これで年末年始のお休み期間でもスマホ一つで障害対応ができるようになりました。(絶対にしたくはない)
とはいえ、障害発生時の対応フローを整備するのも大事ですが、そもそも障害が発生しない構成を作り、ご利用いただいている方々に少しでも良いサービスを提供できるよう取り組んでいこうと思います。

最後に、ユニファでは一緒に働いてくれる仲間を募集中です!
少しでもご興味をお持ちいただけましたら以下をご確認ください。

jobs.unifa-e.com