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

# はじめに

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

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

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/d1e4a643-3a09-e845-08a2-9ca74bfd0b0c.png)
参考：https://cloud.google.com/compute/vm-instance-pricing
（Spot VMの利用や確約利用割引については割愛）

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

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

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

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

https://qiita.com/kohei-kuwata/items/5aebb78e13bfd92a8000

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

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

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

## Cloud Functions
Runtime
- Python 3.12 (Ubuntu 22 **Full**)  （Fullに関しては[後述](#slack_boltがインストール出来ない問題)）

エントリーポイント
- 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/9977bc5f-4bac-2cf2-61af-627667ad497b.png)

ショートカットを選択すると、Slack上にビューが表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/9c41196d-7de3-d6bc-b885-2342862b63c1.png)
↓
![スクリーンショット 2024-12-09 15.28.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/357360b6-d97c-d5dc-5a08-3e0dcfd1d90e.png)
↓
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/7042361e-41b7-ded4-19ad-40ef6cd6e28c.png)


ここでもう一度ショートカットを実行してみると、インスタンスが一時停止処理中なため実行出来なくなっていることが確認できます。
![スクリーンショット 2024-12-09 15.29.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/d31c1979-ecce-4828-ddf9-cd7c5c694e2e.png)

少し待ってからもう一度実行することで、今度は「再開」を実行することができるようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/4853e763-4e37-ea84-aea5-8b2391746b6d.png)

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

# 詰まった点
## Slack Appのドキュメントが分かりづらい問題
公式ドキュメントが日本語に対応していない部分が多く、とくにモーダルの扱いについて悩まされていたところ、[公式のQiitaの記事](https://qiita.com/seratch/items/0b1790697281d4cf6ab3)を見つけました。

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

https://qiita.com/seratch/items/0b1790697281d4cf6ab3

## slack_boltがインストール出来ない問題
以前までは `Cloud Functions` だったサービスが2024年8月頃から名称変更され、現在では `Cloud Run Functions` に変わっています。

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

https://cloud.google.com/blog/products/serverless/google-cloud-functions-is-now-cloud-run-functions?hl=en

この変更により、本記事執筆時点でGCPコンソール上での `Cloud Functions` を作成する方法が下記の2通り存在します。
1. `Cloud Run Functions` で作成する (こっちで作成する分には問題無い)
1. `Cloud Run` で作成する

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/0690311b-7aa0-d0b0-90fb-f1ca954a6718.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が軽量化されてしまっており必要なパッケージが足りていないため、下記の設定から `Environment` を `Ubuntu 22 Full` に設定することで解決します。

![スクリーンショット 2024-12-09 16.45.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/6809529d-affd-6d47-e585-56aa315906b9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/a01159b9-31d4-2d73-ca3d-e1b23359014f.png)


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

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

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

以下は `Google Cloud Compute` の `InstancesClient` をimportするのにかかる時間を測定するコードです（tunaに関してはここでは割愛します）。

```bash
$ 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3632701/376253e7-2da9-c42c-895a-fa0330f768ef.png)

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

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

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

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

https://qiita.com/seratch/items/6d142a9128c6831a6718

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

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

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

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

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



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

https://qiita.com/advent-calendar/2024/intimatemerger