6
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?

HULFT Squareの開発環境 起動停止自動化を​Slack Bot・Outlookスケジュールから実現

Last updated at Posted at 2025-12-20

はじめに

こんにちは!
セゾンテクノロジー Advent Calendar 2025 シリーズ1の21日目の記事を書かせていただきます

HULFT Squareには、主に3つの開発環境があります。
開発環境の起動停止について、主に手動対応で設定していました。

しかし、手動対応では不便な点が多く、運用課題がありました。

今回は各環境の起動停止自動化対応について、

  • 背景
  • 自動化の手段・実現に用いたアーキテクチャ等
  • 手動化->自動化できてよかったこと

をお伝えできればと思います。


HULFT Squareの開発環境について

HULFT Squareでは、開発環境として

①dev環境
②integration環境
③preprod環境

の3つを利用して開発・検証を進めております。

スクリーンショット 2025-11-30 11.11.25.png

①dev環境
開発者が自由に構築・運用できる検証・動作確認用環境

②integration環境
dev環境で検証したアプリケーションの追加・更新・修正等を反映

③preprod環境
本番環境とほぼ同じ構成
リリース前のリハーサルや本番準拠の検証等で利用

本番環境
リハーサルを経て追加機能・修正内容がデプロイされる

HULFT Squareでは、

Common (全体のマイクロサービスを管轄)
Customer (ご契約中のお客様用HULFT Square環境)
App Square (App Square用マイクロサービスを管轄)

といった3種類のAWS環境で起動しております。
HULFT Square起動に必要なAWSリソースとして、

  • EKS Pod (EKS Fargate)
  • EC2サーバー
  • RDS

があり、HULFT Squareの起動中は常時立ち上げている必要があります。

スクリーンショット 2025-12-06 14.14.44.png


手動起動停止の流れ

EKS Pod・EC2・RDSは起動中に常時コストがかかる為、
夜間帯や休日には停止するような仕組みが導入されています。

[EKS Pod]

EC2内に設置したcrontabを用いて起動停止スケジュールを予定時刻に沿ってシェル・Pythonスクリプト等を実行

◾️停止時
シェルを用いてPodを停止して、現在のレプリカ数を記録

◾️起動時
記録されたレプリカ数を元にPodを起動

[EC2・RDS]
対象アカウントのリソースをEventBridgeを用いて
定めたスケジュール通りに起動・停止

といったことを行っています

手動対応の流れとしては、以下の①②③を行います


①利用したい方から起動設定依頼を受ける。起動停止用の予定表で日時を確認する。

②EC2・RDS起動用のEventBridgeを起動時刻に設定

③EC2のcrontabを編集 (Common・Customer・App Square環境分)


この対応の課題として、

  • EventBridgeの日時設定追加・修正
  • crontabの日時設定追加・修正

を都度コンソール等から行う必要があります。
その結果、以下のデメリットが露呈していました。

  1. 起動/停止設定に手間がかかる​
  2. 設定ミスをする可能性がある​
  3. 操作手順が複雑で属人化しやすい

これらの課題を解決するために、自動化への挑戦を決意して設計・実装を開始しました


自動化への挑戦 (SlackBot)

自動化の設計をするにあたり、次の要望は叶えたいと考えました

  • 誰でも簡単に操作できるようにしたい

 →現状AWSの知識がある人しか操作できない
 →緊急の休日対応等で開発者が咄嗟に使えないことが起こり得る

  • 共通のプラットフォームで管理したい
  • 実行履歴が簡単に確認できるようにしたい

ここで思いついたのが、Slackチャンネル内でコマンドから
Slack APIを呼び出してBotのように利用する
方法です。

選択メニューを表示して、選んだ環境ごとに
起動・停止できれば課題が解決できるのではないかと考えました。


SlackBotによる自動化のアーキテクチャ

以下の構成で実装しました

[構成図]

スクリーンショット 2025-12-18 15.16.57.png

[Step Functions Flow]

スクリーンショット 2025-11-30 14.25.38.png

[使用リソース]

  • Slack SDK・Slack API
  • API gateway
  • Step Functions
  • Lambda × 7

<処理の流れ>

①Slack Bot

slackチャンネルからコマンド実行して入力する

→利用者がチャンネル上で専用のコマンド(/hsq)を実行


②API Gateway / Lambda

API Gatewayエンドポイント宛にリクエストが送信される

→Slack Bolt起動用のLambdaが稼働


③Slack Bolt

Lambdaのコード内でSlack Boltを呼び出す

→Slack Boltのモーダルからチャンネル上に入力フォームが表示される


設定したい内容をフォームに入力 (環境名・起動 or 停止)

→パラメータをStep Functionsに渡す


④Step Functions / Lambda

Step Functions起動用のLambdaが稼働する

→Step Functionsから各Lambdaを呼び出し実行する


Podの起動/停止

→SSMコマンドでEC2内のスクリプトを実行

※コマンドの実行結果が出るまでループするLambdaを実行


Podの自動削除 (停止時のみ)

→SSMコマンドでEC2内のスクリプトを実行

※コマンドの実行結果が出るまでループするLambdaを実行


EC2 /RDSの起動停止

※RDSのステータスをチェックして停止が確認できてから次のステップへ進める


⑤Slack Bot

完了通知が実行ユーザー宛に投稿される

※SSMコマンドの実行でエラーがあった場合もSlackチャンネルに通知される


作成したリソース・実装の紹介 (Slack Bot)

[API Gateway]

◾️リソースを作成

 ・POSTメソッドを作成

 ・リクエストバリデータを作成

 ・HTTPリクエストヘッダーにX-Slack-Request-Timestamp, X-Slack-Signatureを設定

◾️ステージを作成

 ・レート制限を設定

◾️オーソライザー

 ・呼び出し先のLambdaで認証


[Slack API]

◾️Slash Commands

 ・起動停止用slackチャンネルで利用するコマンドの登録
 ・API Gatewayで作成したURLをRequest URLに設定


◾️App-Level Tokens

 ・トークンを作成してScopeにconnections:writeを設定


[Slack Bolt & Step Functions呼び出し用Lambda]

環境変数として以下を登録

 ・起動停止を実行するslackチャンネルのID
 ・Signing Secret
 ・Step FunctionsのARN


◾️Slackリクエストの署名検証

<役割>

 ・なりすまし防止
 ・改ざん検出
 ・タイムスタンプ検証

def verify_***(event):
    """Verify the signature of a Slack request"""
    logger.info(f"Event: {json.dumps(event)}")

    headers = event.get('headers', {})
    body = event.get('body', '')

    logger.info(f"Headers: {json.dumps(headers)}")
    logger.info(f"Body: {body}")

    logger.info(f"X-Slack-Request-Timestamp: {timestamp}")
    logger.info(f"X-Slack-Signature: {slack_signature}")

# リクエスト認証に失敗したらエラー
    if not timestamp or not slack_signature:
        logger.error("Timestamp or Slack signature is missing.")
        return False

# タイムスタンプの検証
    current_time = int(time.time())
    if abs(current_time - int(timestamp)) > 60 * 5:
        logger.error("Request timestamp is too old.")
        return False

#署名の比較・検証
    basestring = f"v0:{timestamp}:{body}".encode('utf-8')
    logger.info(f"Basestring: {basestring}")

    signature = 'v0=' + hmac.new(
        SIGNING_SECRET.encode('utf-8'),
        basestring,
        hashlib.sha256
    ).hexdigest()

    logger.info(f"Generated Signature: {signature}")

    if hmac.compare_digest(signature, slack_signature):
        logger.info("Signatures match.")
        return True
    else:
        logger.error("Signatures do not match.")
        return False

※実装の詳細は以下の記事で詳しく解説されてます
https://zenn.dev/t_kakei/articles/f61196a47f9b14
https://zenn.dev/nijigen_plot/articles/create_slack_app


◾️リクエストによる処理の分岐

2種類のリクエストを確認

①Slackコマンドからの初回呼び出し (API Gateway経由)
②Slackのmodalから選択されたリクエスト (環境名・起動or停止)

# リクエストがSlackのモーダルから送られた場合
if body.get('type') == 'view_submission':
    logger.info("Processing view submission")
    return handle_view_submission(body)

# リクエストがSlack Commandから送られた場合(初回)
elif body.get('command'):
    logger.info("Processing slash command")
    return handle_slack_command(body)
else:
    logger.error(f"Unknown request type: {body}")
    return {
        'statusCode': 400,
        'body': json.dumps("Unknown request type"),
        'headers': {'Content-Type': 'application/json'}
    }

◾️Slack Modalを呼び出して起動/停止したい環境を指定する

    trigger_id = body.get('trigger_id')
    channel_id = body.get('channel_id')

# 起動停止用slackチャンネル以外からだとエラー
    if channel_id not in ALLOWED_CHANNELS:
        logger.error(f"Unauthorized channel access: {channel_id}")
        return {
            'statusCode': 403,
            'body': json.dumps("Unauthorized channel access."),
            'headers': {'Content-Type': 'application/json'}
        }

    try:
        # Creating a modal view and adding private_metadata
        view = build_modal_view()
        view["private_metadata"] = json.dumps({"channel_id": channel_id})

        response = client.views_open(
            trigger_id=trigger_id,
            view=view
        )
        logger.info(f"Modal response: {response}")
        return {
            'statusCode': 200,
            'body': json.dumps("Request verified and modal opened successfully."),
            'headers': {'Content-Type': 'application/json'}
        }
    except Exception as e:
        logger.error(f"Error opening modal: {e}")
        return {
            'statusCode': 500,
            'body': json.dumps(f"Error opening modal: {str(e)}"),
            'headers': {'Content-Type': 'application/json'}
        }

Slackコマンドによるモーダルの呼び出し

スクリーンショット 2025-12-06 17.10.15.png

入力フォームから
 ・起動or停止
 ・環境
を選択して送信

送信すると内容が通知されて投稿される

スクリーンショット 2025-12-13 13.45.07.png


◾️モーダル送信処理を受けてStep Functionsを実行

def ***_submission(body):
    # ユーザー選択の取得
    action = state_values["action_block"]["action_select"]["selected_option"]["value"]
    env = state_values["env_block"]["env_select"]["selected_option"]["value"]
    
    # AWS設定の取得
    common_id = aws_id[env]['common_id']
    appsquare_id = aws_id[env]['appsquare_id']
    
    # Parameter Storeから値取得
    common_ec2 = ***_value(f"/{env}/common_ec2")
    common_rds = ***_value(f"/{env}/common_rds")
    appsquare_ec2 =***_value(f"/{env}/appsquare_ec2")
    appsquare_rds = ***_value(f"/{env}/appsquare_rds")
    
    # ペイロード作成
    ## user_idで実行者のidを渡す
    payload = {
        'action': action,
        'environment': env,
        'common_ec2': [common_ec2],
        'appsquare_ec2': [appsquare_ec2],
        'common_rds': common_rds,
        'appsquare_rds': appsquare_rds,
        'common_id': common_id,
        'appsquare_id': appsquare_id,
        'user_id': user_id
    }
    
    # Step Functions実行
    response = sfn_client.start_execution(
        stateMachineArn=***_ARN,
        input=json.dumps(payload)
    )

◾️パラメータ取得
ユーザーの選択(アクション・環境)を取得

◾️AWS設定取得
Parameter Storeから必要な設定値を取得

◾️Step Functions実行
実際の起動/停止処理を開始

◾️通知送信
Slackチャンネルへの結果通知
リクエストを受けてStep Functionsを実行


以降は、Step FunctionsでこれらのLambdaを順次実行

 ・Podの起動停止
 ・EC2・RDSの起動停止 (+ステータスチェック)
 ・Podの自動削除 (停止時のみ)
 ・完了通知

全ての動作が終了したら、完了通知が送られる

スクリーンショット 2025-12-13 13.55.29.png


SlackBotによる自動化の改善効果

自動化による成果です


①起動/停止設定に手間がかかる​

→対応速度UP
1回の設定に約9~10分 -> 最短 数10秒〜1,2分程度に短縮


②設定ミスをする可能性がある​

→品質向上​
極力ミスが起きない仕組み化、設定不備も確認しやすい状態に


③操作手順が複雑で属人化しやすい​

→属人化解消​

操作方法が簡単でSlack・Outlookから誰でも起動/停止できる仕組みに


既存のPod起動停止用スクリプト等で細かい課題があるものの、
自動化が達成できて作業性が大きく向上しました!

しかし、新たな課題が出てきます..

"スケジュール登録して​起動できないのか??"
"利用予定がスケジュール上で​みれるといいよね"
"当日に利用予定を​お知らせしてくれると助かる"

これらの課題を解決すべく考えた結果、多くの社員が共通で利用してるOutlookスケジュール上の予定を読み取って、起動停止が自動化できればよいのではと考えました。


自動化への挑戦 (Outlookのスケジュール起動・停止)

Outlookを利用するメリットは以下の点があります

  • みんなが使えるプラットフォーム
  • スケジュール管理ができる

HULFT Squareの起動停止自動化の更なる改善に向けて、Outlookのスケジュールを読み取って作成していた起動停止処理を自動化できないか、試行錯誤が始まりました。

Outlookのスケジュール読み取りには、Microsoft Graph APIのカレンダー読み取り権限が必要なので作成します。
(Microsoft Graph API.Calendars.Read)
https://learn.microsoft.com/ja-jp/graph/api/calendar-get?view=graph-rest-1.0&tabs=http

SlackBotによる自動化のアーキテクチャ

以下の構成で作成しました

[使用リソース]

  • Microsoft Graph API
  • Lambda × 3
  • EventBridge (自動生成)
  • Step Functions (SlackBot用を呼び出し)

スクリーンショット 2025-12-18 15.36.49.png


①Outlook
対象環境の専用メールボックスを出席者登録・日時設定してスケジュール登録

②Lambda (Outlookスケジュール読み込み・登録用)
1時間ごとに起動させて
Outlookの予定を読み取って以下の処理を実行

 ・予定時刻に起動/停止させる用のEventBridgeを作成
 ・EventBridgeには起動/停止環境用のパラメータを付与
 ・Step Function呼び出し用Lambdaの起動用ポリシーを生成


③EventBridge
Step Functions起動用のLambdaを実行


④Lambda (Step Functions呼び出し用)
EventBridgeから呼び出されてStep Functionsを呼び出す


⑤Step Functions
実行に必要なパラメータを元に各起動停止用のLambdaを起動する


作成したリソース・実装の紹介 (Outlook)

[Outlook Microsoft Graph API.Calendars.Read]

Microsoft Entraからアプリの登録画面で"新規登録"から権限を付与するアプリケーションを作成

作成したアプリケーションから、"APIのアクセス許可"を選択して"アクセス許可"の追加を選択

"Microsoft Graph"を追加

"Calendars.Read"と"User.Read" (運用で他者作成の予定編集用)を追加

スクリーンショット 2025-12-13 15.35.56.png

登録されたスケジュールを取得

スクリーンショット 2025-12-14 17.35.42.png

スクリーンショット 2025-12-14 17.39.21.png

スクリーンショット 2025-12-14 17.48.33.png

[Outlookスケジュール管理用Lambda]

スケジュール登録用のLambdaが実行される

環境変数の設定
①Microsoft Graph API用の環境変数を設定
②AWS・Slackチャンネルの環境変数を設定
③Outlookメールアドレスを設定
④EventBridgeルールの作成数上限値設定

# ①Microsoft Graph API Settings
CLIENT_ID = os.environ['CLIENT_ID']
CLIENT_SECRET = os.environ['CLIENT_SECRET']
TENANT_ID = os.environ['TENANT_ID']
AUTHORITY = f'https://login.microsoftonline.com/{TENANT_ID}'
RESOURCE = 'https://graph.microsoft.com/'
API_VERSION = 'v1.0'

# ②AWS Settings
EXECUTOR_ARN = 'arn:aws:lambda:ap-northeast-1:****:function:****'
SLACK_TOKEN = os.environ['SLACK_TOKEN']
CHANNEL_ID = '****'

# ③Environment configuration
OUTLOOK_EMAILS = {
    'preprod': '***preprod.com',
    'integration': '***integration.com',
    'dev1': '***dev1.com'
}

# ④Rule creation limit
MAX_RULES = 40  # Maximum rules to create per execution
TOTAL_RULES = 270  # Maximum total rules in AWS environment

(Outlookスケジュール管理用のLambdaは1時間ごとに実行されている)
Outlookの予定追加・変更・削除等があると、応じた処理が実行される
追加・変更がない場合はLambdaの動作はスキップされる

スクリーンショット 2025-12-14 18.10.48.png

<主な処理>

Outlookカレンダーから処理を読み取ってEventBridgeルール作成


Microsoft Graph API認証

def get_access_token():
    # OAuth 2.0 Client Credentials Flowでアクセストークンを取得
    url = f'{AUTHORITY}/oauth2/v2.0/token'
    payload = {
        'client_id': CLIENT_ID,
        'client_secret': CLIENT_SECRET,
        'grant_type': 'client_credentials',
        'scope': 'https://graph.microsoft.com/.default'
    }

Outlookカレンダー読み取り

def get_week_events(access_token, env):
    # 環境別のOutlookメール取得
    outlook_email = OUTLOOK_EMAILS.get(env)
    
    # 7日前から60日先までのイベントを取得
    range_start = today_start - datetime.timedelta(days=7)
    range_end = today_start + datetime.timedelta(days=60)
    
    # calendarViewで繰り返しイベントも展開して取得
    url = f'https://graph.microsoft.com/{API_VERSION}/users/{outlook_email}/calendar/calendarView'

イベントの検証とフィルタリング

def is_cancelled(event):
    # キャンセルされたイベントをチェック
    subject = event.get('subject', '')
    return (event.get('isCancelled', False) or 
            'キャンセル済み' in subject or 
            'cancelled' in subject.lower())

def parse_time(event):
    # 日本時間(JST)に変換
    jst = zoneinfo.ZoneInfo("Asia/Tokyo")
    start_dt = datetime.datetime.fromisoformat(start_time.replace('Z', '+00:00'))
    return start_dt.astimezone(jst), end_dt.astimezone(jst)

EventBridgeルール名の生成

def create_pattern(env, action, date_jst, time_jst):
    date_str = date_jst.strftime('%Y%m%d')
    time_str = time_jst.strftime('%H%M')
    return f"{env}-{action}-{date_str}-{time_str}"

既存のルールと比較して新規ルールを作成

for env in environments:
    # Outlook カレンダーからイベント取得
    events = get_week_events(access_token, env)
    
    # 既存ルールとの比較・不整合検出
    mismatched_rules = compare_rules(access_token, env, existing_rules)
    
    # 新規EventBridgeルール作成
    created_rules = setting_schedule(access_token, env, existing_rules, today_str, today)

Step Functions用ペイロード作成

def send_payload(action, env):
    # Parameter Storeから設定値を取得
    common_ec2 = ***_value(f"/{env}/common_ec2")
    common_rds = ***_value(f"/{env}/common_rds")
    
    payload = {
        'action': action,
        'environment': env,
        'common_ec2': [common_ec2],
        'appsquare_ec2': [appsquare_ec2],
        'common_rds': common_rds,
        'appsquare_rds': appsquare_rds,
        'user_id': 'outlook-automation'
    }

EventBridgeルールとLambda権限の作成

def eventbridge(rule_name, schedule_expression, input_data, existing_rules):
    # EventBridgeルールの作成
    events_client.put_rule(
        Name=rule_name,
        ScheduleExpression=schedule_expression,  # cron式
        State='ENABLED',
        Description=f'Automation schedule for {input_data["action"]} - {input_data["env"]}'
    )
    
    # Lambdaターゲットの設定
    events_client.put_targets(
        Rule=rule_name,
        Targets=[{
            'Id': '1',
            'Arn': ***LAMBDA_ARN,
            'Input': json.dumps(input_data)
        }]
    )
    
    # Lambda実行権限の付与
    stmt_id = f'{rule_parts}'  # 例: preprodstart202512211000
    lambda_client.add_permission(
        FunctionName=***LAMBDA_ARN,
        StatementId=stmt_id,
        Action='lambda:InvokeFunction',
        Principal='events.amazonaws.com'
    )

Lambda実行後

予定したスケジュールに起動するEventBridgeが作成される

登録された予定がslackで通知される

スクリーンショット 2025-12-14 18.03.52.png

EventBridge

設定した時刻でスケジュールされるよう自動生成されたEventBridge

スクリーンショット 2025-12-17 22.53.50.png

Step Fucntionsに引き渡すパラメータ

スクリーンショット 2025-12-14 18.41.30.png

EventBridgeから呼び出されるStep Functions起動用のLambda

Slack通知用の処理

def post_slack(message, action, env, user_id='outlook-automation'):
    """SLACK_BOT_TOKENによる認証"""
    if not SLACK_TOKEN:
        logger.info("SLACK_TOKEN not configured, skipping Slack notification")
        return

    action_text = "Start / 起動" if action == "start" else "Stop / 停止"
    environment_text = env

    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {SLACK_TOKEN}'
    }

  #slack通知
    payload = {
        'channel': SLACK_CHANNEL_ID,
        'text': f"Process has been started / 処理が開始されました: Environment / 環境: {environment_text}, Action / アクション: {action_text}",
        'blocks': [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": ":gear: *HULFT Square スケジュール実行開始 / Automated Process Started*"
                }
            },
            {
                "type": "section",
                "fields": [
                    {
                        "type": "mrkdwn",
                        "text": f"*Action / アクション:*\n{action_text}"
                    },
                    {
                        "type": "mrkdwn",
                        "text": f"*Environment / 環境:*\n{environment_text}"
                    }
                ]
            }
        ]
    }

    try:
        response = requests.post('https://slack.com/api/chat.postMessage', headers=headers, json=payload)
        logger.info(f"Slack post response status: {response.status_code}")

        if response.status_code == 200:
            response_data = response.json()
            if response_data.get('ok'):
                logger.info("Slack notification sent successfully")
            else:
                logger.error(f"Slack API error: {response_data.get('error', 'Unknown error')}")
        else:
            logger.error(f"Slack HTTP error: {response.status_code}")
    except Exception as e:
        logger.error(f"Error sending Slack notification: {str(e)}")

Step Functionsの呼び出し処理

def **_step_functions(payload, execution_name):
    logger.info(f"Step Functions execution started: {payload.get('action', 'unknown')}")

    try:
        client = boto3.client('stepfunctions')

     # EventBridgeの定数をpayloadとしてStep Functionsに渡す
        response = client.start_execution(
            stateMachineArn=STEP_FUNCTIONS_ARN,
            name=execution_name,
            input=json.dumps(payload)
        )

        logger.info(f"Step Functions execution success: {response['executionArn']}")
        return response['executionArn']

    except Exception as e:
        logger.error(f"Step Functions execution error: {str(e)}")
        return None

予定時刻になると、以下のようにslackチャンネルに投稿されて、
Step Fucntionsの処理が開始される

スクリーンショット 2025-12-17 23.12.54.png

以上がOutlookの予定を読み取った起動停止の自動化処理になります
事前に時間帯を決めて、起動停止設定をすることがとても簡単・手軽になりました


Outlookスケジュール自動化の改善効果

以下の点が改善されました

◾️Outlookの予定から簡単に起動停止予定を登録できるようになった

今まで行っていたEventBridge・Cronの手動スケジュール設定から完全脱却
誰でも簡単に起動停止予定を設定できるようになった

◾️起動停止スケジュールの見える化

カレンダーから起動停止予定を可視化できるようになった

◾️通知機能

slackの自動投稿機能を利用して、当日の利用予定が毎朝確認できる

自動化のススメ

当初思っていたよりも遥かに壮大なAWSリソースの構成になってしまいましたが、
すごく便利になりました。運用面でも時短で対応できてとても助かっています。

しかしながら、運用ルールの整備やPod起動停止処理の改善など、まだまだ課題が残っています。引き続き改善を進めていこうと思います。

皆様も是非自動化を推進して、注力したい業務に集中できるようにしていきましょう!

6
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
6
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?