0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本記事では、基本的な起動・停止の仕様から、EventBridgeスケジュールによる自動化、さらにはSlackから対話的に起動・停止を行うサーバレス構成を整理とSlackとLambdaの認証の仕組みのメモを残しておきます。
途中個人のアプリケーションのため省略している部分もあります。認証方法の参考などになれば良いかなと思います。
今回はAuroraではなく素のRDSとし、LambdaはJava21で今回実装しました。

背景

常時起動しておく必要がないAmazonのRDS(Relational Database Service)インスタンスを、自動スケジュールやSlackから管理(起動・停止)することで、不要なコンピューティング費用を大幅に削減できるかと思い調べてまとめました。

RDSの起動・停止の基本仕様

RDSは一時的に停止させることによって料金は以下のようになります。

停止中の料金について

課金が止まるもの: データベースのコンピューティング(インスタンス)料金。
課金が継続するもの: プロビジョニングされたストレージ(EBSボリューム)料金、およびバックアップ(スナップショット)ストレージ料金。

最長7日間の自動起動ルール(最重要)

RDSインスタンスを停止状態のままにできる期間は、最大7日間です。
7日を経過すると、メンテナンスやパッチ適用などの整合性を維持するために、AWS側で自動的にインスタンスが起動されます。
再び停止したい場合は、自動起動後に再度停止処理を行う必要があります。永続的に停止させたい場合は、スナップショットを作成した上でインスタンスを削除することを検討が必要になります。

起動停止操作

停止手順

方法A AWS マネジメントコンソールから停止する

1.Amazon RDS コンソールを開きます。
2.ナビゲーションペインで [データベース] を選択します。
3.停止したいDBインスタンスのチェックボックスを選択します。
4.[アクション] メニューから [一時的に停止] を選択します。
5.確認画面が表示されるため、必要に応じてスナップショットの作成有無などを選択し、[一時的に停止] をクリックします。
ステータスが Stopping(停止中)から Stopped(停止済み)に変われば完了です。

方法B AWS CLIから停止する

・通常RDS
aws rds stop-db-instance \
    --db-instance-identifier <対象のDBインスタンス識別子>
・Aurora
aws rds stop-db-cluster \
    --db-cluster-identifier <対象のDBクラスター識別子>

方法C AWS Lambdaから停止する(今回の主題)

DBのインスタンスのIDは環境変数に定義し、今回は起動状況を管理するための設定をDynamoDBへ保存するようにしました。(別処理でRDSの起動状況で処理を分けるためのデータを保存するために保存しています。)

package (パッケージ).rds;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import (パッケージ).RdsClientFactory;
import (パッケージ).SystemStatusRepository;
import (パッケージ).RdsStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.rds.RdsClient;
import software.amazon.awssdk.services.rds.model.DBInstance;
import software.amazon.awssdk.services.rds.model.DescribeDbInstancesResponse;

import java.util.Map;

public class RdsStopHandler implements RequestHandler<Map<String, Object>, Void> {

    private static final Logger log = LoggerFactory.getLogger(RdsStopHandler.class);

    private final RdsClient rdsClient;
    private final SystemStatusRepository systemStatusRepository;
    private final String instanceId;

    public RdsStopHandler() {
        this(RdsClientFactory.get(), new SystemStatusRepository(),
                System.getenv("RDS_INSTANCE_ID"));
    }

    public RdsStopHandler(RdsClient rdsClient, SystemStatusRepository systemStatusRepository,
                           String instanceId) {
        this.rdsClient = rdsClient;
        this.systemStatusRepository = systemStatusRepository;
        this.instanceId = instanceId;
    }

    @Override
    public Void handleRequest(Map<String, Object> event, Context context) {
        log.info("Stopping RDS instance: {}", instanceId);

        RdsStatus currentStatus = getCurrentStatus();
        if (currentStatus == RdsStatus.STOPPED) {
            log.info("RDS is already stopped");
            return null;
        }

        systemStatusRepository.update(false, RdsStatus.STOPPING);

        if (currentStatus == RdsStatus.AVAILABLE) {
            rdsClient.stopDBInstance(r -> r.dbInstanceIdentifier(instanceId));
            log.info("RDS stop initiated");
        }

        return null;
    }

    private RdsStatus getCurrentStatus() {
        DescribeDbInstancesResponse response = rdsClient.describeDBInstances(r ->
                r.dbInstanceIdentifier(instanceId));
        return response.dbInstances().stream()
                .findFirst()
                .map(DBInstance::dbInstanceStatus)
                .map(RdsStatus::from)
                .orElse(RdsStatus.UNKNOWN);
    }
}

起動手順

方法A:AWS マネジメントコンソールから起動する

1.Amazon RDS コンソールを開きます。
2.ナビゲーションペインで [データベース] を選択します。
3.起動したいDBインスタンスを選択します。
4.[アクション] メニューから [起動] を選択します。
ステータスが Starting(起動中)から Available(利用可能)に変われば完了です。

方法B:AWS CLIから起動する

・通常RDS
aws rds start-db-instance \
    --db-instance-identifier <対象のDBインスタンス識別子>
・Aurora
aws rds start-db-cluster \
    --db-cluster-identifier <対象 of DBクラスター識別子>

方法C:AWS Lambdaから起動する

package (パッケージ).rds;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;
import (パッケージ).RdsClientFactory;
import (パッケージ).SystemStatusRepository;
import (パッケージ).RdsStatus;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import software.amazon.awssdk.services.rds.RdsClient;
import software.amazon.awssdk.services.rds.model.DBInstance;
import software.amazon.awssdk.services.rds.model.DescribeDbInstancesResponse;

import java.util.Map;

public class RdsStartHandler implements RequestHandler<Map<String, Object>, Void> {

    private static final Logger log = LoggerFactory.getLogger(RdsStartHandler.class);

    private final RdsClient rdsClient;
    private final SystemStatusRepository systemStatusRepository;
    private final String instanceId;

    public RdsStartHandler() {
        this(RdsClientFactory.get(), new SystemStatusRepository(),
                System.getenv("RDS_INSTANCE_ID"));
    }

    public RdsStartHandler(RdsClient rdsClient, SystemStatusRepository systemStatusRepository,
                           String instanceId) {
        this.rdsClient = rdsClient;
        this.systemStatusRepository = systemStatusRepository;
        this.instanceId = instanceId;
    }

    @Override
    public Void handleRequest(Map<String, Object> event, Context context) {
        log.info("Starting RDS instance: {}", instanceId);

        RdsStatus currentStatus = getCurrentStatus();
        if (currentStatus == RdsStatus.AVAILABLE) {
            log.info("RDS is already available, skipping start");
            return null;
        }

        if (currentStatus == RdsStatus.STOPPED) {
            rdsClient.startDBInstance(r -> r.dbInstanceIdentifier(instanceId));
            log.info("RDS start initiated");
        }

        // 起動完了はRDSイベント経由でRdsSyncTriggerHandlerが検知する
        systemStatusRepository.update(false, RdsStatus.STARTING);
        return null;
    }

    private RdsStatus getCurrentStatus() {
        DescribeDbInstancesResponse response = rdsClient.describeDBInstances(r ->
                r.dbInstanceIdentifier(instanceId));
        return response.dbInstances().stream()
                .findFirst()
                .map(DBInstance::dbInstanceStatus)
                .map(RdsStatus::from)
                .orElse(RdsStatus.UNKNOWN);
    }
}

運用自動化による夜間・休日の自動停止

毎回手動で起動・停止を行うのは手間がかかるのと停止忘れなどがあると、余計なコストが積み重なってしまう可能性があるかと思います。
そのため今回上で作成したLambdaをEventBridgeでの起動停止させる方法を今回実装した。

構成図
image.png

RDSの起動をした場合には起動後にデータ連携を考慮して実装したので、起動が完了すると状態変化イベントを受け取りデータ同期トリガーを実行するようにしました。

最終的に通知Lambdaを別で準備し、全てが完了すると起動や停止の終了の通知がSlackに行く仕組みとなっています。

手動による夜間・休日の自動停止

自動以外にも手動で外部から簡単に起動・停止する方法についても構築し以下の構成で構築できることを確認しました。

image.png

認証について

事前準備としてSlackアプリを作成し、操作するワークスペースに追加しています。
さらに受けているLambda側で以下のような実装を行い、リクエストの正当性をチェックしています。

package (パッケージ);
 
import com.slack.api.bolt.App;
import com.slack.api.bolt.AppConfig;
import com.slack.api.bolt.aws_lambda.SlackApiLambdaHandler;
import com.slack.api.bolt.aws_lambda.request.ApiGatewayRequest;
import com.slack.api.model.event.MessageEvent;
import (パッケージ).SlackCommandRouter;
import (パッケージ).SlackSessionRepository;Slackのスレッドとのやり取りのためのデータを保存しているDynamoDBのテーブル
import (パッケージ).SlackSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Optional;
 
public class SlackHandler extends SlackApiLambdaHandler {
    private static final Logger log = LoggerFactory.getLogger(SlackHandler.class);
    private static final SlackSessionRepository sessionRepository = new SlackSessionRepository();
 
    public SlackHandler() {
        super(buildApp());
    }
 
    @Override
    public boolean isWarmupRequest(ApiGatewayRequest request) {
        return false;
    }
 
    private static App buildApp() {
        App app = new App(AppConfig.builder()
                .signingSecret(System.getenv("SLACK_SIGNING_SECRET"))
                .singleTeamBotToken(System.getenv("SLACK_BOT_TOKEN"))
                .build());
 
        // Slackからのリトライリクエストを無視(2重・3重のメッセージ送信を防止)
        app.use((req, resp, chain) -> {
            String retryNum = req.getHeaders().getFirstValue("X-Slack-Retry-Num");
            if (retryNum != null) {
                log.warn("Ignoring Slack retry request (retry num: {})", retryNum);
                return resp; // 200 OK を返して後続の処理をスキップ
            }
            return chain.next(req);
        });
 
        SlackCommandRouter router = new SlackCommandRouter();
 
        // DMメッセージおよびBotメンション以外のチャンネルメッセージを処理
        app.event(MessageEvent.class, (payload, ctx) -> {
            MessageEvent event = payload.getEvent();
 
            // Bot自身のメッセージは無視
            if (event.getBotId() != null) {
                return ctx.ack();
            }
 
            String userId = event.getUser();
            String text = event.getText();
 
            if (userId == null || text == null) {
                return ctx.ack();
            }
 
            // スレッド返信の場合、アクティブセッションのthreadTsと一致するか確認
            if (event.getThreadTs() != null) {
                Optional<SlackSession> session = sessionRepository.find(userId);
                if (session.isEmpty() || session.get().threadTs() == null || !session.get().threadTs().equals(event.getThreadTs())) {
                    // 一致しない場合は無関係なスレッドなので無視
                    log.warn("SlackHandler threadTs filter - skipped: userId={}, eventThreadTs={}, sessionPresent={}, sessionThreadTs={}, sessionState={}",
                            userId, event.getThreadTs(),
                            session.isPresent(),
                            session.map(SlackSession::threadTs).orElse(null),
                            session.map(SlackSession::state).orElse(null));
                    return ctx.ack();
                }
            }
 
            String threadTs = event.getThreadTs() != null ? event.getThreadTs() : event.getTs();
            String response = router.route(userId, text, threadTs);
            if (response != null) {
                try {
                    ctx.client().chatPostMessage(r -> r
                            .channel(event.getChannel())
                            .threadTs(threadTs)
                            .text(response));
                } catch (Exception e) {
                    log.error("Failed to send threaded response via client", e);
                    ctx.say(response);
                }
            }
 
            return ctx.ack();
        });
 
        return app;
    }
}

以下の部分で認証に必要な情報をLambdaの起動時に設定する。

App app = new App(AppConfig.builder()
                .signingSecret(System.getenv("SLACK_SIGNING_SECRET"))
                .singleTeamBotToken(System.getenv("SLACK_BOT_TOKEN"))
                .build());

他にもSlackアプリは一定時間内にリクエストのレスポンスがないと数回リトライを行うのでリトライ処理の無視や、スレッドの入力の場合は以前会話しているスレッドと一致しているかなどをチェックする仕組みを準備しています。

今回はCloudFormationでLambdaを管理しているので、ヘッダーとして定義を追加して呼び出されるようにしました。
サンプルはこちらです。

SlackFunction:
    Type: AWS::Serverless::Function
    Metadata:
      SkipBuild: True
    Properties:
      FunctionName: 名称
      CodeUri: 起動するJarファイル
      Handler: (Package).SlackHandler::handleRequest
・
・
・

流れとしては以下の通りです。

Slack Bolt SDKライブラリ内部において、署名検証は以下のクラスおよびメソッドの順で処理が渡され、実行されています。

1. エントリポイント

クラス: com.slack.api.bolt.aws_lambda.SlackApiLambdaHandler
メソッド: handleRequest(ApiGatewayRequest awsRequest, Context context)
処理: AWS Lambdaがトリガーされた際、まずこのメソッドが呼ばれます。ここでは、API Gateway特有のリクエスト形式(ApiGatewayRequest)を、Bolt SDK共通の Request オブジェクトに変換し、app.run(request) を呼び出します。

2. ミドルウェアチェーンによる署名検証の実行

クラス: com.slack.api.bolt.middleware.builtin.RequestVerification (デフォルトで有効な組み込みミドルウェア)
メソッド: apply(Request req, Response resp, MiddlewareChain chain)
処理: app.event() などの個別処理が実行される前に、このミドルウェアの apply メソッドがインターセプトします。
リクエストヘッダーから X-Slack-Signature と X-Slack-Request-Timestamp を取得します。
リクエストボディ(生テキスト)を取得します。
次のユーティリティを内部で呼び出します。

3. 暗号署名の計算と照合(実処理)

クラス: com.slack.api.app_backend.SlackSignature.Verifier
メソッド: isValid(String timestamp, String requestBody, String signature)
処理: SLACK_SIGNING_SECRET を鍵とし、タイムスタンプとリクエストボディを結合して HMAC-SHA256 署名を生成します。受信した signature ヘッダーの値と一致するかどうかを比較(MessageDigest.isEqual による安全な比較)し、真偽値(boolean)を返します。
💡 検証が失敗した場合の挙動
RequestVerification.apply 内で署名検証が失敗(isValid が false)と判定された場合、後続の処理チェーン(chain.next(req))へは進まず、即座に HTTP 401 (Unauthorized) のレスポンスオブジェクトが生成されてクライアント(Slack)へ返却されます。

SLACK_BOT_TOKENやSLACK_SIGNING_SECRETについては以下のページを参考にしていただければと思います。

・SLACK_SIGNING_SECRET

・SLACK_BOT_TOKEN

上記の流れで認証が行われ安全に特定のアプリからのみのリクエストを受けとることができるようになります。

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?