はじめに
こんにちは。株式会社インティメート・マージャーの十文字です。
みなさん、普段使っていない GCE(Google Compute Engine)
のVMインスタインス、ちゃんと止めてますか?
もちろん、夜間や土日にも検証用のバッチ処理などを実行しているのであれば、動かしておくのはしょうがないかもしれません。
しかし(本記事執筆時点で)asia-northeast1
にて e2-standard-2
を使用すると $62.75372/月 課金されてしまいます。
これは1ドル150円で計算すると、
・9,413
円/月
・112,957
円/年
にもなってしまいます。
参考: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
- Slack Appから新規Appを作成
-
Interactivity
の設定をONにする-
Request URL
に下記で作成するCloud FunctionsのURLを設定 -
Shortcuts(Global)
を追加し、Callback IDにmanage-vm-instance
と入力
-
- 作成した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から取得
functions-framework==3.*
urllib3==2.2.3
slack-bolt==1.21.2
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)
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上でショートカットを実行することができるようになります。
ショートカットを選択すると、Slack上にビューが表示されます
↓
↓
ここでもう一度ショートカットを実行してみると、インスタンスが一時停止処理中なため実行出来なくなっていることが確認できます。
少し待ってからもう一度実行することで、今度は「再開」を実行することができるようになります。
これで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通り存在します。
-
Cloud Run Functions
で作成する (こっちで作成する分には問題無い) -
Cloud Run
で作成する
この2つ目の Cloud Run
のコンソール画面から、Runtime
をPythonの3.12に設定すると↓Base image
がこのような表記になります。
簡単なものであればこれでも問題はないのですが、この状態で 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
に設定することで解決します。
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に関してはここでは割愛します)。
$ 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
にアクセスすると、↓このような画面が表示されます。
ただ単に 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 App
と Cloud Functions
を用いてVMインスタンスの一時停止・再開を行う方法を紹介しました。
色々詰まる部分があったので、同じような悩みを抱えた人の手助けに少しでもなれば幸いです。
あす以降の記事もぜひお楽しみに!