📆

Google Calendar × AWS Step Functions で トレーニング用環境 を “必要な日だけ” 自動起動してみた

に公開

はじめに・・・

こんにちは!@maijun2です。
AWS のインストラクターとして、AWS のトレーニング時に EC2 にインストールした RocketChat を用いて質疑対応とかしているのですが、ちょっとコスト削減するために 必要な日だけ EC2 を起動する ってのを自動化してみました。

以下、詳細をまとめてみます。

事前状況と調査

実は EC2 の自動起動は以前から出来ていたんですね。
のんぴさんが書かれた以下ブログ記事を参考に私も運用していました。

EventBridge SchedulerとStep Functionsで指定したタグが付与されているEC2インスタンスを定期的に起動・停止させてみた

うむ!良きかな!
でもこれだと AWS トレーニングが無い日も起動しちゃう。 AWS トレーニングがある日だけ起動したいんだよなってなりました。

項目 状況
AWS 側 EventBridge Scheduler → Step Functions → EC2 起動フローは既に稼働(平日 08:00 JST)
要件追加 Step Functions 実行前に「Google カレンダーに “トレーニング” を含む予定があるか?」を判定したい
調査ポイント 1. Google Calendar の予定をプログラムから取得できる?
2. AWS でラクに実装する方法は?

調べてみると、 Google Calendar API を使えばやりたいことが出来そうです。


調査結果と実装

とりあえず Google Cloud を使えばできるのが判ったので、即時設定していきます。

1. Google Cloud で Calendar API を有効化

  1. GCP プロジェクト作成 → Google Calendar API有効化

  2. サービスアカウント (SA) を発行し、キーを JSON でダウンロード

  3. トレーニング用カレンダーに サービスアカウント (SA) のメールアドレスを 閲覧権限 で招待

ちなみに使ってみたらほとんど課金は無い状況でした>Google Calender API

Cloud Storageの利用料金だけしか課金されてない・・

2. AWS Secrets Manager に SA キーを保管

Google Calender API を利用するために発行した サービスアカウント (SA) ですが、Lambda関数から利用します。よってダウンロードした JSON を AWS Secrets Manager に登録しました。

ダウンロードした JSON ファイルをプレーンテキストとしてそのまま、 Secrets Manager に保存です。すると秘密鍵やメールアドレス、クライアントIDなど情報が Secrets Manager に登録されます。

これでLambda関数から呼び出して使えますね。

3. Google Calender 判定する Lambda 関数を作成

サービスアカウントの情報も使えるようになったので、Lambda 関数を準備していきます。

今回は、Python3.12 で関数の作成をしていくのですが、ライブラリとして Google Calender をそのまま扱えません。よってカスタムレイヤーの作成を CloudShell で行いました。

#作業用のディレクトリを作成
mkdir gcal-layer && cd gcal-layer

#ライブラリを準備していきます
pip install \
  "urllib3<1.27,>=1.26.15" \
  google-api-python-client \
  google-auth \
  google-auth-httplib2 \
  google-auth-oauthlib \
  -t python

#Zipにまとめてカスタムレイヤー化します。
zip -r gcal-layer.zip python
aws lambda publish-layer-version \
  --layer-name gcal-pylibs \
  --zip-file fileb://gcal-layer.zip \
  --compatible-runtimes python3.12 \
  --description "Google Calendar libs (urllib3 1.26.x pinned)"

レイヤー化出来た!

カスタムレイヤー準備出来たので、Lambda 関数も作っていきます。

カレンダー情報や Secret Managerに登録したサービスアカウントの情報、探し出したいカレンダーの登録内容などは環境変数から取得していきます。

import os
import json
import datetime
import zoneinfo
import boto3

from google.oauth2 import service_account
from googleapiclient.discovery import build

# --- 環境変数 -----------------------------------------------------------
SECRET_ID        = os.environ["GC_SECRET_ID"]
CALENDAR_ID      = os.environ["GC_CAL_ID"]
TRAINING_KEYWORD = os.getenv("TRAINING_KEYWORD", "トレーニング")
TZ_NAME          = os.getenv("TIMEZONE", "Asia/Tokyo")
TIMEZONE         = zoneinfo.ZoneInfo(TZ_NAME)

# --- AWS クライアント(Secrets Manager のみ) --------------------------
sm = boto3.client("secretsmanager")

def lambda_handler(event, context):
    """
    1. 当日 (JST) 0:00〜23:59 の Google カレンダーイベントを取得
    2. summary に TRAINING_KEYWORD を含むイベントがあれば shouldStart=True
    3. 判定結果をステートマシンに返却
    """
    # -------------- 1) 期間計算 ----------------------------------------
    today = datetime.datetime.now(TIMEZONE).date()
    start = datetime.datetime.combine(today, datetime.time.min, TIMEZONE).isoformat()
    end   = datetime.datetime.combine(today, datetime.time.max, TIMEZONE).isoformat()

    # -------------- 2) Google 認証 -------------------------------------
    secret_str = sm.get_secret_value(SecretId=SECRET_ID)["SecretString"]
    creds_info = json.loads(secret_str)
    creds = service_account.Credentials.from_service_account_info(
        creds_info,
        scopes=["https://www.googleapis.com/auth/calendar.readonly"]
    )
    service = build("calendar", "v3", credentials=creds, cache_discovery=False)

    # -------------- 3) イベント取得 ------------------------------------
    events_resp = service.events().list(
        calendarId=CALENDAR_ID,
        timeMin=start,
        timeMax=end,
        singleEvents=True,
        maxResults=2500
    ).execute()

    events = events_resp.get("items", [])

    # -------------- 4) 判定ロジック ------------------------------------
    matched = [
        e for e in events
        if TRAINING_KEYWORD in (e.get("summary") or "")
    ]

    should_start = len(matched) > 0

    # -------------- 5) レスポンス --------------------------------------
    return {
        "shouldStart": should_start,
        "matchedCount": len(matched),
        "checkedDate": today.isoformat(),
        # 必要なら matched イベントの概要や開始時刻を返すことも可能
    }

環境変数でキーワード指定できるので、あとで探し出したいカレンダー情報も変更できますね。

レイヤーもカスタムレイヤーを指定します。

Lambda 関数で用いる IAM ポリシーに Secret Manager を付与しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "ReadSecret",
            "Effect": "Allow",
            "Action": "secretsmanager:GetSecretValue",
            "Resource": "arn:aws:secretsmanager:ap-northeast-1:xxxxxxxxxxxx:secret:シークレット名"
        }
    ]
}

4. 既存の Step Functions を改修

カレンダー判定の Lambda 関数が出来たので、既存の Step Functions に追加していきます。

{
  "StartAt": "CheckCalendar",
  "States": {
    "CheckCalendar": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:ap-northeast-1:xxxxxxxxxxxx:function:Lambda関数名",
      "ResultPath": "$.calendar",
      "Next": "ShouldStart?"
    },
    "ShouldStart?": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.calendar.shouldStart",
          "BooleanEquals": true,
          "Next": "ChoiceTag"
        }
      ],
      "Default": "Noop"
    },
    "Noop": {
      "Type": "Succeed"
    },
    "ChoiceTag": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.Tags.or",
          "IsPresent": true,
          "Next": "MapTags"
        },
        {
          "Variable": "$.Tags.and",
          "IsPresent": true,
          "Next": "DescribeInstancesTagProduct"
        }
      ]
    },
    "MapTags": {
      "Type": "Map",
      "Next": "ChoiceAction",
      "ResultSelector": {
        "InstanceIds.$": "States.ArrayUnique($[*][*][*])",
        "length.$": "States.ArrayLength(States.ArrayUnique($[*][*][*]))"
      },
      "Iterator": {
        "StartAt": "DescribeInstancesTagSummation",
        "States": {
          "DescribeInstancesTagSummation": {
            "End": true,
            "Type": "Task",
            "ResultSelector": {
              "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId"
            },
            "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
            "Parameters": {
              "Filters": [
                {
                  "Name.$": "States.Format('tag:{}', $.Key)",
                  "Values.$": "$.Values"
                }
              ]
            }
          }
        }
      },
      "ItemsPath": "$.Tags.or"
    },
    "ChoiceAction": {
      "Type": "Choice",
      "Choices": [
        {
          "Variable": "$.length",
          "NumericEquals": 0,
          "Next": "Pass"
        },
        {
          "Variable": "$$.Execution.Input.Action",
          "StringEquals": "Stop",
          "Next": "StopInstances"
        },
        {
          "Variable": "$$.Execution.Input.Action",
          "StringEquals": "Start",
          "Next": "StartInstances"
        }
      ]
    },
    "DescribeInstancesTagProduct": {
      "Next": "ChoiceAction",
      "Type": "Task",
      "ResultSelector": {
        "InstanceIds.$": "$.Reservations[*].Instances[*].InstanceId",
        "length.$": "States.ArrayLength($.Reservations[*].Instances[*].InstanceId)"
      },
      "Resource": "arn:aws:states:::aws-sdk:ec2:describeInstances",
      "Parameters": {
        "Filters.$": "$.Tags.and"
      }
    },
    "Pass": {
      "Type": "Pass",
      "End": true
    },
    "StopInstances": {
      "End": true,
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:stopInstances",
      "Parameters": {
        "InstanceIds.$": "$.InstanceIds"
      }
    },
    "StartInstances": {
      "End": true,
      "Type": "Task",
      "Resource": "arn:aws:states:::aws-sdk:ec2:startInstances",
      "Parameters": {
        "InstanceIds.$": "$.InstanceIds"
      }
    }
  },
  "TimeoutSeconds": 300
}

ステートマシン実行ロールに lambda:InvokeFunction を追記しました。

作成後の動作確認と利用額

あとはEventBridgeで朝になったら起動、夜になったら停止を勝手にやってくれます。

AWS トレーニングが無い日も勝手に立ち上がっていた環境がこれで抑制されます。コストもいい感じになってます!

ちなみに私は OCI のインストラクターもしているので、OCI のトレーニングの際には別の環境を OCI 側で立ち上げてます。(だから空白の日があって、そこは OCI を教えているって感じです)

まとめ

実はトレーニング情報は別の仕組みで管理されており、そこから API 経由で取得できないか? とも考えていたのですが、諸事情により取得が難しそうでしたので、今回は Google Calender に登録しているスケジュールから取得してみました。

ちょうど 8/1(金)には AWS Jam を私対応しますので、興味があるかたはぜひお申し込みしてみてね(PRすぎるwww)

Discussion