こしあん
2024-04-15

DiscordのbotをTerraform+API Gateway+Lambda(Python)で動かす (1):基本編


932{icon} {views}


DiscordのInterction EndpointをAPI Gateway+Lambdaで実装し、Terraformでデプロイしてみます。LambdaはPythonベースで。Discordとの疎通の部分にどころがあります。基本といいつつ、TerraformがややややこしくなったがAPI Gatewayの部分なんで、その他はわかってしまえば単純です。

やりたいこと

  • DiscordのbotをAWSのAPI Gateway+Lambdaで実現したい
    • Lambdaは無料枠が多く、かつサーバーレスでできたら結構便利なため
  • ネット上の記事を探していると、Node.jsで実装されたものが多いので、PythonベースのLambdaの情報が少ない
  • DiscordのbotはGateway APIやWeb Socket周りの取り回しが難しく、「API Gateway+Lambdaチャレンジしてみたのだがうまくいかなかった」という回答が多い
    • Interction APIでもできることはかなり多いので、ひとまずこれに絞って動かすことを目標とする
    • 最終的には、これとChatGPTを連携させたい
    • (API GatewayでもWeb Socket扱えるので、理論上はできてもおかしくないのだが、ひとまずこれはおいておく)
  • この記事では以下のことを行う

完成図

構成はこのような感じ。

関連記事

考え方はこちらの動画を参考にしている。

こちらの動画では、PythonベースのLambdaで、Flaskを動かしてWsgiToAsgiとMangumを使いhandlerに変換している。DockerベースのLambdaの構成になっている。しかし、これはかなりまどろっこしいやり方で、公式の出しているdiscord-interactions-pythonを見ると、認証周りは非常にシンプルな構成で、正直Flaskを使う必要性がないぐらいである。

なので、認証部分だけdiscord-interactions-pythonにまかせて、あとはPythonの基本ライブラリで実装するのが目標。

これに同じことはNode.jsベースのLambdaだと行われていて、Classmethodの以下の記事が参考になる。

Discord Interaction Endpoint を多段 Lambda 構成にしてタイムアウトを回避する

ディレクトリ構成

+ layer
  - discord-interactions.zip
- lambda_function.py
- main.tf

考え方

基本は前回の記事のTerraformに、API GatewayとLambdaを追加すれば良い。これ自体は、API GatewayをTerraformでデプロイするのとほとんど変わらないのでそれほど難しくはない。

ただ、エンドポイントをDiscordに登録し、疎通テストを走らせるところでかなりハマったのでそこを中心にメモしておく。最後にコードを示す。

ハマった点

Interaction EndpointをDiscordのDiscordのDeveloper Portalに登録するのだが、この疎通テストがかなり癖がある。登録時に認証込みでテストが走り、テストケースが通ると初めてエンドポイントが登録される。「とりあえず認証なしで登録してテスト通ったらあとから認証をかける」というやり方が通用しない。

layerをビルドするときの実行環境(OS / Pythonバージョン)の問題

Lambdaの実行環境では、discord-interactions-pythonがインストールされていないため、これをレイヤーとして登録する必要があるが、LayerのデータはLambdaの実行環境(OS、Pythonバージョン)一致させて作る必要がある

Lambdaのレイヤーデータを作るには、基本的には次のように行う。

  • メインディレクトリ直下にlayerフォルダを作る
  • カレントディレクトリをlayerにうつし、その下にpythonディレクトリを作る
  • コンソールからpip install -t ./python discord_interactionsを実行する
  • pythonフォルダをzipに圧縮し、discord-interactions.zipとしてリネーム

参考記事:pandasをLambdaのLayerとして追加する

しかし、何も考えずにレイヤーデータを作ると、エンドポイントの登録時にDiscord側で以下のようなエラーが出る。

Validation errors:
interactions_endpoint_url: 指定されたインタラクション・エンドポイントURLを認証できませんでした。

LambdaのCloudWatchを見ると以下のようなエラーが出ている。

[ERROR] Runtime.ImportModuleError: Unable to import module ‘lambda_function’: No module named ‘nacl._sodium’
Traceback (most recent call last):

[ERROR] Runtime.ImportModuleError: Unable to import module ‘lambda_function’: No module named ‘_cffi_backend’

これはエラー文通りの、「naclがインストールされていない」「cffiがインストールされていない」という意味ではない。本当の理由は、

  • 前者:Lambdaの実行環境(Amazon Linux 2)と、レイヤーデータの生成環境(自分の場合はWindows)のOS差により、naclがインポートできない
  • 後者:cffiのインストール時に、_cffi_backendというバイナリが落ちてくるが、このバイナリはPythonのバージョン(例:3.8、3.9、…、3.12)に依存する。ローカルのPythonが3.8で、Lambdaの実行環境のPythonが3.12だと、Pythonバージョンに対応したバイナリが存在しないため、cffiがインポートできない

_cffi_backendは以下のようなもの。LambdaのPythonランタイムが3.12の場合、以下は正しいレイヤーデータとなる。

WSL2+Dockerを使った解決法

Windowsの場合、WSL2を使い、対応するPythonのDockerイメージからpip installするとこの問題を解決できる。Dockerが面倒な場合は、EC2を立ち上げて対応する実行環境と揃えて実行するのも良い。

WSL2でlayerがある(ホスト側PC基準)ディレクトリをカレントディレクトリとし、以下のコマンドを実行する。

docker run --rm -it -v $(pwd):/layer python:3.12 /bin/bash

python:3.12は起動時にPythonが立ち上がってしまうが、bashを実行することでシェルコマンドを受け付けられるようになる。

cd layer

pip install -t ./python discord_interactions

Dockerコンテナ内からpip installをし、そのデータをホスト側にマウントすることで実行環境を揃えたレイヤーデータができる。

レイヤーデータがOSに依存することはまあまああるが、Pythonのバージョンまで強く依存するのはなかなか珍しいため注意が必要である。

認証情報のヘッダーのキー

認証情報のキーの大文字・小文字に注意が必要である。正しく認証情報が送信されているのに、なかなか認証通らないなと思ったら、大文字・小文字が間違っていたことがあった。

API GatewayからLambdaに送られたヘッダーを見ると、

{'resource': '/', 
 'path': '/', 
 'httpMethod': 'POST', 
  'headers': {
    'CloudFront-Forwarded-Proto': 'https', 
      :    :
    'x-signature-ed25519': 'b5d8.....', 
    'x-signature-timestamp': '1713063285'
  }
}

のように、x-signature-ed25519x-signature-timestamp小文字で記載されている。ところが、Flaskを使った例だと、ヘッダーが大文字先頭で書かれている。Flaskを使わない場合は、小文字で書くのが正しい

            # Flaskだとうまくいくが、この例では動作しない
            signature = request.headers.get('X-Signature-Ed25519')
            timestamp = request.headers.get('X-Signature-Timestamp')

全体コード

lambda_function.py

Lambdaのソースは以下のようになる。依存ライブラリはdicord_interactionsだけでかなりシンプルに実装できたのではないだろうか。

eventをprintしているのはデバッグ目的なので、いらなくなったら消す。

import json
from discord_interactions import verify_key, InteractionType, InteractionResponseType
import os

def lambda_handler(event, context):
    # for debugging
    # print(event)

    request_body = json.loads(event.get("body", "{}"))
    headers = event.get("headers", {})

    # Verify request
    signature = headers.get("x-signature-ed25519")
    timestamp = headers.get("x-signature-timestamp")
    raw_body = event.get("body", "{}").encode()
    if signature is None or timestamp is None or \
        not verify_key(raw_body, signature, timestamp, os.environ.get("DISCORD_PUBLIC_KEY")): # Authorization
        return {
            'statusCode': 401,
            'body': "Bad request signature"
        }

    # Handle request
    interaction_type = request_body.get("type")

    if interaction_type in [InteractionType.APPLICATION_COMMAND, InteractionType.MESSAGE_COMPONENT]:
        data = request_body.get("data", {})
        command_name = data.get("name")

        if command_name == "hello":
            response_text = "Hello there!"
        elif command_name == "echo":
            response_text = f"Echoing: {data['options'][0]['value']}"
        else:
            raise NotImplementedError(f"Command '{command_name}' not implemented")

        response_data = {
            "type": InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE, 
            "data": {
                "content": response_text
            }
        }
    else:
        response_data = {"type": InteractionResponseType.PONG}

    return {
        'statusCode': 200,
        'body': json.dumps(response_data)
    }

main.tf

事前準備として以下が必要になる。

  • Systems ManagerのParameter Storeに、アプリケーションID、botのトークン、public keyを登録しておく(前回の記事参照
  • AWSのマネジメントコンソールから、AWSLambdaBasicExecutionRoleというポリシーのみアタッチした、LambdaBasicExecutionRoleというロールを作っておく。これはLambda間で使い回せる実行ロールを想定している。

API Gatewayの価格は、RESTよりもHTTPのほうが安いが、自分がHTTPのAPI慣れていないこともあり、パス周りの取り回しが面倒だったためRESTでデプロイしてみた。

API Gatewayに紐づいているLambdaは、本格的に実装したとしても、各コマンドに対応するLambdaをキックする、ルーティングの役割しか担わないため、RESTで実装する意味はほとんどない。ぶっちゃけLambdaを直接公開してもいい説はある。

しかし、API Gatewayと紐づけることでセキュリティを向上させやすくなるメリットがあるため、このへんは好みだろう(CloudFrontでも良さそう)。エンドポイントを「/」直下におかないなどでも、一定のDDoS対策効果はあるそうだ

また、Discordのサーバーはアメリカにあるようなので(リクエストヘッダーのCloudFrontのログを見ているとUSが割り当てられている)、リージョンをそちらにしてしまうのも良いだろう。

このケースでは、Discordに登録するInterctionのエンドポイントは

https://<api-id>.execute-api.ap-northeast-1.amazonaws.com/dev/interaction

となる。

terraform {
  required_providers {
    discord-interactions = {
      source = "roleypoly/discord-interactions"
      version = "0.1.0"
    }
  }

  backend "local" {
    path = ".cache/terraform.tfstate"
  }
}

# AWS Provider
provider "aws" {
  region = "ap-northeast-1"
  profile = "develop"
}

data "aws_caller_identity" "current" {}

# Credentials from AWS
data "aws_ssm_parameter" "discord_application_id" {
  name            = "/discord/shikoan-app/application-id"
}

data "aws_ssm_parameter" "discord_bot_token" {
  name            = "/discord/shikoan-app/token"
  with_decryption = true
}

data "aws_ssm_parameter" "discord_public_key" {
  name            = "/discord/shikoan-app/public-key"
  with_decryption = true
}

# Discord Interaction Provider
provider "discord-interactions" {
  application_id = data.aws_ssm_parameter.discord_application_id.value
  bot_token      = data.aws_ssm_parameter.discord_bot_token.value
}

# Discord bot commands
resource "discord-interactions_global_command" "hello" {
  name               = "hello"
  description        = "Say hello!"
  default_permission = false
}

resource "discord-interactions_global_command" "echo" {
  name               = "echo"
  description        = "Echo message back to sender"
  default_permission = false

  option {
    name        = "message"
    description = "The message to echo back."
    type        = 3
    required    = true
  }
}

# Deploy Lambda
data "archive_file" "lambda_archive" {
  type        = "zip"
  source_file = "lambda_function.py"
  output_path = ".cache/lambda_function.zip"
}

resource "aws_lambda_layer_version" "discord_interactions_layer" {
  filename            = "layer/discord-interactions.zip"
  layer_name          = "discord-interactions"

  compatible_runtimes = ["python3.12"]
}

resource "aws_lambda_function" "discord_lambda" {
  function_name    = "DiscordTestLambda"
  handler          = "lambda_function.lambda_handler"
  runtime          = "python3.12"
  role             = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/LambdaBasicExecutionRole"
  layers           = [ aws_lambda_layer_version.discord_interactions_layer.arn ]

  filename         = data.archive_file.lambda_archive.output_path
  source_code_hash = data.archive_file.lambda_archive.output_base64sha256

  environment {
    variables = {
      DISCORD_PUBLIC_KEY = data.aws_ssm_parameter.discord_public_key.value
    }
  }
}

# API Gateway
resource "aws_api_gateway_rest_api" "discord_api" {
  name        = "DiscordExampleAPI"
  description = "Example Discord API Gateway for a single Lambda"
}

resource "aws_api_gateway_resource" "interaction_resource" {
  rest_api_id = aws_api_gateway_rest_api.discord_api.id
  parent_id   = aws_api_gateway_rest_api.discord_api.root_resource_id
  path_part   = "interaction"
}

resource "aws_api_gateway_method" "interaction_method" {
  rest_api_id   = aws_api_gateway_rest_api.discord_api.id
  resource_id   = aws_api_gateway_resource.interaction_resource.id
  http_method   = "POST"
  authorization = "NONE"
}

resource "aws_api_gateway_integration" "interaction_integration" {
  rest_api_id             = aws_api_gateway_rest_api.discord_api.id
  resource_id             = aws_api_gateway_resource.interaction_resource.id
  http_method             = aws_api_gateway_method.interaction_method.http_method
  integration_http_method = "POST"
  type                    = "AWS_PROXY"
  uri                     = aws_lambda_function.discord_lambda.invoke_arn
}

resource "aws_api_gateway_deployment" "sample_deployment" {
  depends_on = [
    aws_api_gateway_integration.interaction_integration
  ]

  rest_api_id = aws_api_gateway_rest_api.discord_api.id
}

resource "aws_api_gateway_stage" "dev_stage" {
  stage_name    = "dev"
  rest_api_id   = aws_api_gateway_rest_api.discord_api.id
  deployment_id = aws_api_gateway_deployment.sample_deployment.id
}

resource "aws_api_gateway_method_settings" "stage_setting" {
  rest_api_id = aws_api_gateway_rest_api.discord_api.id
  stage_name  = aws_api_gateway_stage.dev_stage.stage_name
  method_path = "*/*"

  settings {
    throttling_rate_limit  = 5
    throttling_burst_limit = 2
  }
}

resource "aws_lambda_permission" "api_gateway_permission" {
  statement_id  = "AllowAPIGatewayInvoke"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.discord_lambda.function_name
  principal     = "apigateway.amazonaws.com"

  source_arn = "${aws_api_gateway_rest_api.discord_api.execution_arn}/*/*/${aws_api_gateway_resource.interaction_resource.path_part}"
}

続き

https://blog.shikoan.com/discord-bot-lambda-2/



Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


2 Comments

Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です