5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Intimate MergerAdvent Calendar 2024

Day 10

SlackコマンドでGCEインスタンスを停止・起動する

Last updated at Posted at 2024-12-09

はじめに

こんにちは。株式会社インティメート・マージャーの十文字です。

みなさん、普段使っていない GCE(Google Compute Engine) のVMインスタインス、ちゃんと止めてますか?

もちろん、夜間や土日にも検証用のバッチ処理などを実行しているのであれば、動かしておくのはしょうがないかもしれません。

しかし(本記事執筆時点で)asia-northeast1 にて e2-standard-2 を使用すると $62.75372/月 課金されてしまいます。

これは1ドル150円で計算すると、
9,413円/月
112,957円/年
にもなってしまいます。

image.png
参考:https://cloud.google.com/compute/vm-instance-pricing
(Spot VMの利用や確約利用割引については割愛)

GCEのVMインスタンスは起動していなければ料金はかからないので、不要なインスタンスは停止・一時停止しておくことで課金額を抑えることが出来ます。

ただ、GCPコンソール画面から一時停止や再開を行うにはブラウザを開いて、GCPコンソール画面を開き、インスタンスの詳細画面まで移動し、一時停止ボタンを押さなければなりません。

この動作が面倒な方向けに、本記事ではSlackでコマンドを実行するだけでVMインスタンスの一時停止・再開を行えるようにする方法を紹介します。

設定手順

Slackのスラッシュコマンドでこのようなことを行っている記事はすでに存在します。

ただ、この記事ではSlack Appの設定に加え、Cloud Functionsを2つとPub/Subの設定を行っています。

少し重い処理や時間のかかる処理を行う場合はこの構成でもいいのですが、今回はCloud Functions 1つの設定のみでも実現出来そうだったので、そのような構成で実現していきます。

Slack App

  1. Slack Appから新規Appを作成
  2. Interactivity の設定をONにする
    • Request URL に下記で作成するCloud FunctionsのURLを設定
    • Shortcuts(Global) を追加し、Callback IDに manage-vm-instance と入力
  3. 作成したAppの下記2つをCloud Functionsの環境変数に設定
    • Basic Information ページの Signing Secret
    • OAuth & Permissions ページの ボットトークン (xoxb-で始まる)

Cloud Functions

Runtime

  • Python 3.12 (Ubuntu 22 Full) (Fullに関しては後述

エントリーポイント

  • main

サービスアカウント

  • roles/compute.instanceAdmin.v1 を付与したサービスアカウントを設定(以下の権限が付いていれば他でも可)
    • compute.instances.get
    • compute.instances.suspend
    • compute.instances.resume

環境変数

  • SLACK_SIGNING_SECRET: Slack Appから取得
  • SLACK_BOT_TOKEN: Slack Appから取得
requirements.txt
functions-framework==3.*
urllib3==2.2.3
slack-bolt==1.21.2
main.py
from slack_bolt import App

from utils import make_console_link_accessory, make_error_view

INSTANCE_ID_MAP = {
    # "Slack User ID": "GCE Instance ID",
    "xxx": "aaaa",
    ...
}

# この app に処理を設定していく
app = App(process_before_response=True)


@app.shortcut("manage-vm-instance")
def handle_main_shortcut(ack, body, client):
    """
    メインのショートカットを処理する関数

    3秒以内にレスポンスを返す必要があるため、処理の軽いviewを一枚挟む
    """
    ack()

    # ユーザーに紐づくインスタンスが存在しない場合はエラーを表示
    if body["user"]["id"] not in INSTANCE_ID_MAP:
        client.views_open(
            trigger_id=body["trigger_id"],
            view=make_error_view("このユーザーには操作対象のVMインスタンスが設定されていません"),
        )
        return

    client.views_open(
        trigger_id=body["trigger_id"],
        view={
            "type": "modal",
            "callback_id": "manage-vm-instance",
            "clear_on_close": True,
            "title": {"type": "plain_text", "text": "GCE VM インスタンスの操作"},
            "submit": {"type": "plain_text", "text": "はい"},
            "close": {"type": "plain_text", "text": "いいえ"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "mrkdwn", "text": "VMインスタンスの操作を行いますか?"},
                    "accessory": make_console_link_accessory(),
                },
            ],
        },
    )


@app.view("manage-vm-instance")
def handle_manage_vm_instance(ack, body):
    from utils import get_instance

    user_id = body["user"]["id"]
    instance = get_instance(INSTANCE_ID_MAP[user_id])
    
    instance_name = instance["name"]
    instance_status = instance["status"]

    # インスタンスの状態によってモーダルの内容とコールバックIDを変更
    if instance_status == "RUNNING":
        callback_id = "suspend-vm"
        button_text = "一時停止"
    elif instance_status == "SUSPENDED":
        callback_id = "resume-vm"
        button_text = "再開"
    else:
        # 下記遷移中はエラーを表示
        # RUNNING -(suspend)> SUSPENDING -> SUSPENDED
        # SUSPENDED -(resume)> PROVISIONING -> STAGING -> RUNNING
        # https://cloud.google.com/compute/docs/instances/instance-life-cycle
        ack(
            response_action="update",
            view=make_error_view(
                f"この状態(`{instance_status}`)のインスタンスは操作できません\n"
                "処理が完了するまで少々お待ちください",
            ),
        )
        return

    # response_action="update" でのack()でモーダルの内容を書き換える
    ack(
        response_action="update",
        view={
            "type": "modal",
            "callback_id": callback_id,
            "title": {"type": "plain_text", "text": "GCP VM インスタンスの操作"},
            "submit": {"type": "plain_text", "text": button_text},
            "close": {"type": "plain_text", "text": "キャンセル"},
            "blocks": [
                {
                    "type": "section",
                    "fields": [
                        {"type": "mrkdwn", "text": "*Instance*"},
                        {"type": "mrkdwn", "text": "*Status*"},
                        {"type": "mrkdwn", "text": f"`{instance_name}`"},
                        {"type": "mrkdwn", "text": f"`{instance_status}`"},
                    ],
                },
                {"type": "divider"},
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": f"インスタンス: *{instance_name}* を{button_text}しますか?",
                    },
                    "accessory": make_console_link_accessory(),
                }
            ],
        },
    )


@app.view("suspend-vm")
def handle_suspend_vm(ack, body):
    from utils import suspend_instance

    user_id = body["user"]["id"]
    user_name = body["user"]["username"]
    target_instance = INSTANCE_ID_MAP[user_id]

    # インスタンスを一時停止
    suspend_instance(target_instance)
    print(f"Instance: {target_instance} is suspended by user: {user_name}")

    ack(
        response_action="update",
        view={
            "type": "modal",
            "title": {"type": "plain_text", "text": "完了"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "インスタンスを一時停止しました"},
                    "accessory": make_console_link_accessory(),
                }
            ],
        },
    )


@app.view("resume-vm")
def handle_resume_vm(ack, body):
    from utils import resume_instance

    user_id = body["user"]["id"]
    user_name = body["user"]["username"]
    target_instance = INSTANCE_ID_MAP[user_id]

    # インスタンスを再開
    resume_instance(target_instance)
    print(f"Instance: {target_instance} is resumed by user: {user_name}")

    ack(
        response_action="update",
        view={
            "type": "modal",
            "title": {"type": "plain_text", "text": "完了"},
            "close": {"type": "plain_text", "text": "閉じる"},
            "blocks": [
                {
                    "type": "section",
                    "text": {"type": "plain_text", "text": "インスタンスを再開しました"},
                    "accessory": make_console_link_accessory(),
                }
            ],
        },
    )


@app.action("console-link")
def handle_console_link(ack):
    """コンソールへのリンクをクリックしたときの処理"""
    ack()


def main(request):
    """エントリーポイント"""
    from slack_bolt.adapter.google_cloud_functions import SlackRequestHandler

    handler = SlackRequestHandler(app)

    return handler.handle(request)

utils.py
import json

import urllib3

PROJECT = "my-project"
ZONE = "asia-northeast1-b"

http = urllib3.PoolManager()


def _get_access_token():
    """
    GCPの Compute Engine API を叩くためのトークンを取得する

    メタデータサーバがキャッシュしてくれているのでそれを利用する
    """
    response = http.request(
        "GET",
        f"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token",
        fields={"scopes": ",".join([
            "https://www.googleapis.com/auth/compute",
            "https://www.googleapis.com/auth/cloud-platform"
        ])},
        headers={"Metadata-Flavor": "Google"},
    )
    return json.loads(response.data.decode(encoding="utf-8"))["access_token"]


def get_instance(instance_name: str, project_id: str = PROJECT, zone: str = ZONE) -> dict:
    """
    https://cloud.google.com/compute/docs/reference/rest/v1/instances/get
    """
    response = http.request(
        "GET",
        f"https://compute.googleapis.com/compute/v1/projects/{project_id}/zones/{zone}/instances/{instance_name}",
        headers={"Authorization": f"Bearer {_get_access_token()}"},
    )
    return json.loads(response.data.decode(encoding="utf-8"))


def suspend_instance(instance_name: str, project_id: str = PROJECT, zone: str = ZONE) -> dict:
    """
    https://cloud.google.com/compute/docs/reference/rest/v1/instances/suspend
    """
    response = http.request(
        "POST",
        f"https://compute.googleapis.com/compute/v1/projects/{project_id}/zones/{zone}/instances/{instance_name}/suspend",
        headers={"Authorization": f"Bearer {_get_access_token()}"},
    )
    return json.loads(response.data.decode(encoding="utf-8"))


def resume_instance(instance_name: str, project_id: str = PROJECT, zone: str = ZONE) -> dict:
    """
    https://cloud.google.com/compute/docs/reference/rest/v1/instances/resume
    """
    response = http.request(
        "POST",
        f"https://compute.googleapis.com/compute/v1/projects/{project_id}/zones/{zone}/instances/{instance_name}/resume",
        headers={"Authorization": f"Bearer {_get_access_token()}"},
    )
    return json.loads(response.data.decode(encoding="utf-8"))


def make_console_link_accessory(
    action_id: str = "console-link",
    link: str = f"https://console.cloud.google.com/compute/instances?project={PROJECT}",
) -> dict:
    """コンソールへのリンクのアクセサリを作成する"""
    return {
        "type": "button",
        "action_id": action_id,
        "text": {"type": "plain_text", "text": "GCP Consoleで確認"},
        "url": link,
    }


def make_error_view(error_message: str) -> dict:
    """エラーメッセージを表示するためのビューを作成する"""
    return {
        "type": "modal",
        "title": {"type": "plain_text", "text": "エラー"},
        "close": {"type": "plain_text", "text": "閉じる"},
        "blocks": [
            {
                "type": "section",
                "text": {"type": "mrkdwn", "text": error_message},
                "accessory": make_console_link_accessory(),
            },
        ],
    }

動作確認

以上の設定をすることで、↓このようにSlack上でショートカットを実行することができるようになります。

image.png

ショートカットを選択すると、Slack上にビューが表示されます
image.png

スクリーンショット 2024-12-09 15.28.32.png

image.png

ここでもう一度ショートカットを実行してみると、インスタンスが一時停止処理中なため実行出来なくなっていることが確認できます。
スクリーンショット 2024-12-09 15.29.11.png

少し待ってからもう一度実行することで、今度は「再開」を実行することができるようになります。
image.png

これでSlack上からVMインスタンスの一時停止・起動を簡単に行うことができるようになりました。

詰まった点

Slack Appのドキュメントが分かりづらい問題

公式ドキュメントが日本語に対応していない部分が多く、とくにモーダルの扱いについて悩まされていたところ、公式のQiitaの記事を見つけました。

自分はここに書いてあることを一通り試すことでモーダルに関してはある程度扱うことができるようになったので、ぜひモーダルの扱いに困った方はこちらも参考にしてみてください!

slack_boltがインストール出来ない問題

以前までは Cloud Functions だったサービスが2024年8月頃から名称変更され、現在では Cloud Run Functions に変わっています。

Cloud Run Functions で作成されたリソースは Cloud Run の一覧ページに表示され、今後は Cloud Run に統合されるそうです。

この変更により、本記事執筆時点でGCPコンソール上での Cloud Functions を作成する方法が下記の2通り存在します。

  1. Cloud Run Functions で作成する (こっちで作成する分には問題無い)
  2. Cloud Run で作成する

この2つ目の Cloud Run のコンソール画面から、Runtime をPythonの3.12に設定すると↓Base imageがこのような表記になります。

image.png

簡単なものであればこれでも問題はないのですが、この状態で slack_bolt をrequirements.txtに追加した上でmain.pyファイルに from slack_bolt import App というimport文を記述してデプロイしようとすると、下記のようなエラーになってしまいます。

...

    File "/layers/google.python.pip/pip/lib/python3.12/site-packages/slack_sdk/oauth/installation_store/sqlite3/__init__.py", line 2, in <module>
        import sqlite3
    File "/layers/google.python.runtime/python/lib/python3.12/sqlite3/__init__.py", line 57, in <module>
        from sqlite3.dbapi2 import *
    File "/layers/google.python.runtime/python/lib/python3.12/sqlite3/dbapi2.py", line 27, in <module>
        from _sqlite3 import *
Container called exit(1).
ImportError: libsqlite3.so.0: cannot open shared object file: No such file or directory

こうなってしまった場合、Base imageが軽量化されてしまっており必要なパッケージが足りていないため、下記の設定から EnvironmentUbuntu 22 Full に設定することで解決します。

スクリーンショット 2024-12-09 16.45.41.png

image.png

Slack Appの3秒ルール

今回この Cloud Functions を設定する上で一番ハマった点がこの「Slack Appの3秒ルール」です。

Cloud Functions などでSlack botを作成すると、Slack Appからリクエストに対して3秒以内にレスポンスを返さないとタイムアウトしてしまいます。

今回自分が作成したものに関してはそこまで複雑にしていないので、この問題に引っかからないだろうと思っていました。
しかし、GCPのAPIをPython SDKで叩こうとするとこの問題に引っかかってしまいます。

以下は Google Cloud ComputeInstancesClient をimportするのにかかる時間を測定するコードです(tunaに関してはここでは割愛します)。

$ docker run -it --rm -p 8000:8000 python:3.12-slim /bin/bash
/# pip install google-cloud-compute tuna
/# python -X importtime -c "from google.cloud.compute_v1 import InstancesClient" 2> import.txt
/# tuna import.txt

これを実行して http://localhost:8000 にアクセスすると、↓このような画面が表示されます。

image.png

ただ単に InstancesClient をimportしただけなのに、余計なコードがたくさんimportされてしまいimport文だけで2秒もかかってしまっています。

これでは Cloud Functions の起動やコードの実行時間も合わせると、あっという間に3秒を超えてタイムアウトしてしまいます。

もちろん Cloud Functions の最小インスタンス数を1に設定しておくことでもこの問題は解決できます。しかし、こんな単純なものにわざわざリソースを割いていてはもったいない気もします。

こういった問題を解決するためにSlackの公式からも対応策は提案されており、slack_boltには Lazy Listeners という機能が実装されています。

この記事執筆時点(v1.0.1)では、スレッド、asyncio、AWS Lambda に対応しています。将来のバージョンで Google Cloud Functions にも対応予定です。

また、この機能に関して Cloud Functions にも対応するとは名言されていますが、それから4年経った現在でもいまだに実装されておりません。

ですので、今回は直接APIを叩くことにしました。
こうすることで、余計なimport処理がなくなり3秒以内にレスポンスを返せるようになりました。

まとめ

ということで、本記事では Slack AppCloud Functions を用いてVMインスタンスの一時停止・再開を行う方法を紹介しました。

色々詰まる部分があったので、同じような悩みを抱えた人の手助けに少しでもなれば幸いです。

あす以降の記事もぜひお楽しみに!

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?