サイトアイコン PEOPLE Engineering Blog

【AWS】 AWS SAMでSlackBotを開発した際にハマったこと

こんにちは。しげぞうです。

みなさん、AWS SAMでアプリ開発はされてますか?
AWS SAMは、AWS Serverless Application Model の略で、サーバーレスなアプリケーションを簡単に構築できるフレームワークになります。アプリケーションに必要なソースコードはもちろんのこと、アプリ上のデータを管理するデータストア(データベース)や認証などの関連リソースもSAMで開発することで一括管理することができます。

SAMについてもう少し詳しく

AWS SAMでできることのメインは次の2つです。

①サーバーレス環境アーキテクチャのリソース管理

SAMは、yamljsonファイル形式でAWSリソースを定義できるCloudFormationというサービスを内包しています。
厳密には一緒ではない(SAMではSAMテンプレートと呼ぶそう)ですが、CloudFormationでAWSリソースの定義をしていく要領でアプリケーションの実行環境を構築していきます。

↓SAMテンプレートの例↓

# template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  slack-golang-app

Globals:
  Function:
    Timeout: 5

Resources:
  # API Gatewayの定義
  SlackGolangAppApi:
    Type: AWS::Serverless::Api
    Properties:
      StageName: !Ref Stage
      OpenApiVersion: 3.0.2
      EndpointConfiguration: 
        Type: REGIONAL
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: openapi.yaml # swaggerファイルのパス指定
  # Lambdaの定義
  GetNewsFunction:
    Type: AWS::Serverless::Function
    Dependson: [
      GetNewsFuncLogGroup,
      LambdaFunctionBaseRole
    ]
    Properties:
      FunctionName: getNews
      CodeUri: get-news/
      Handler: get-news
      Runtime: go1.x
      Architectures:
        - x86_64
      Tracing: PassThrough
      MemorySize: 128
      Timeout: 300
      Role: !GetAtt LambdaFunctionBaseRole.Arn
      Events:
        CatchAll:
          Type: Api
          Properties:
            Path: /get/news
            Method: POST
            RestApiId:
              Ref: SlackGolangAppApi

②サーバーレス環境で実行するプラムの作成(コーディング)

AWSでサーバーレスといえば、Lambdaがまず思い浮かぶと思います。
Lambda:開発者側でのサーバー管理なしにアプリケーションコードを実行できるサービス

SAMでは、そのLambdaの実行コードをローカル(Dockerコンテナ)上でコーディングしたりテストすることが可能で、
実際の現場での開発フローに採用したくなるフレームワークとなっています。

// get-news/main.go
package main

import (
    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

    // アプリケーションコードを記述

    return events.APIGatewayProxyResponse{
        Body: "OK",
        StatusCode: 200,
    }, nil
}

// Lambda関数実行時に呼ばれる
func main() {
    lambda.Start(handler)
}

SAMでアプリ開発してみた

試しにSlack上で動く簡単なBotアプリを作成してみることにしました。
コーディングは、以前から興味のあったGo言語で。

アプリとしては、キーワードを与えてその内容に該当するニュース情報を返却してくれるシンプルなものになります。
実際の成果物としては、次のようなものが完成しました。

SAMアプリ開発における振る舞いSlackとの連携は以下のようなイメージになります。

上記を説明すると、 次のような流れになります。

① まず、アプリで実行するプログラム(ニュースを取得する処理)をローカルで実装/検証する
② ①で作成したものをクラウド上(Lambda,API Gateway)にデプロイする
③ SlackAppにエンドポイント(API Gatewayエンドポイント)を設定し、Slackインターフェースとアプリロジックを連携させる

SAMは、上記①②の開発作業を担います。
使い慣れたローカルの環境(エディタ&CLI)でシームレスに行えるのが強みなサービスです。

開発にあたっての環境構築や実際のソースコード/ビルド方法などはついてはここでは割愛します。
興味のある方は、こちらのリポジトリをご覧ください。

以降は、今回のアプリ開発でハマったことや工夫したことを少し紹介させていただきます。

ハマったこと

Lambda関数とLambda実行ロールの依存関係

AWS SAMLambda関数をデプロイすると、最小の実行権限を持ったLambda実行ロールが合わせて作成されます。
今回、Lambda関数から他のAWSサービスへアクセスする必要があったため、明示的に適したLambda実行ロールを準備し、Lambda関数に紐づけてやる必要がありました。

しかし、以下のように実行ロール作成の定義を追記するだけではダメで、デプロイ時のリソースの依存関係でエラーとなってしまいました。

# template.yaml
GetNewsFunction:
  Type: AWS::Serverless::Function
  Properties:
    FunctionName: getNews
    CodeUri: get-news/
    Handler: get-news
    # ############################################################
    # ↓実行ロールのリソースを指定↓
    # 指定したリソースが既に作成(or 先に作成されている)されていないとエラーになる
    # ############################################################
    Role: !GetAtt LambdaFunctionBaseRole.Arn 
    ~~~

# ############################################
# ↓Lambda実行用ロールの定義を追記↓
# ############################################
LambdaFunctionBaseRole: # lambdaの実行ロール
  Type: AWS::IAM::Role
  Properties:
    RoleName: slack-golang-app-lambda-base-role
    AssumeRolePolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Action: 
            - sts:AssumeRole
          Principal:
            Service: 
              - lambda.amazonaws.com
    ManagedPolicyArns:
     - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
     - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

そこで使えるのが、DependsOn句です。
DependsOnは、SAMCloudFormationでAWSサービスを構築するときに、サービスの構築順序を指定することができます。

# template.yaml
GetNewsFunction:
  Type: AWS::Serverless::Function
  # ###########################################################
  # ↓Lambda関数を作成する前に構築しておくリソースを指定↓
  # GetNewsFunction作成前に、LambdaFunctionBaseRoleを作成する
  # ###########################################################
  DependsOn: [
    LambdaFunctionBaseRole
  ]
  Properties:
    FunctionName: getNews
    CodeUri: get-news/
    Handler: get-news
    Role: !GetAtt LambdaFunctionBaseRole.Arn 
    ~~~

LambdaFunctionBaseRole: # lambdaの実行ロール
  Type: AWS::IAM::Role
  Properties:
    RoleName: slack-golang-app-lambda-base-role
    AssumeRolePolicyDocument:
      Version: "2012-10-17"
      Statement:
        - Effect: Allow
          Action: 
            - sts:AssumeRole
          Principal:
            Service: 
              - lambda.amazonaws.com
    ManagedPolicyArns:
     - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
     - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess

上記のように定義することで、無事デプロイできました。
このリソースの依存関係問題はSAMCloudFormationを利用する際は注意していきたいところです。

SlackAppとアプリ(API)を連携する際のチャレンジ認証

SlackAppをよく使う方にとっては馴染みがあると思いますが、SlackAppと自作のプログラムを連携させるときの関門として、Challenge認証(エンドポイントURLの検証)というものがあります。

まず、SlackAppのエンドポイントにURLを設定すると下記のようなRequestBodyをもつPOSTが送信されます。(↓公式のサンプルです)

{
    "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl",
    "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P",
    "type": "url_verification"
}

上記のうち、challengeキーの値をそのままResponseBodyに返してやることで、そのエンドポイントURLがSlackAppで利用可能と判断され、使用できるようになります。

API Gateway側でRequestBodyをやりくりするスマートな方法がありそうな気もしますが、今回はLambdaでChallenge認証用のロジックを作成して対応しました。

// get-news/main.go
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

  // RequestBodyを構造体形式で取得
  requestBody, err := getRequestBody(request.Body)
  if err != nil {
    return events.APIGatewayProxyResponse{
      Body:       err.Error(),
      StatusCode: 500,
    }, err
  }

  event := requestBody.Event
  challenge := requestBody.Challenge

  // challengeパラメータが渡されている場合、Challenge認証用のレスポンスを返却
  if challenge != "" {
    responseBody, err := json.Marshal(ChallengeResponseBody{
      Challenge: challenge,
    })

    _ = err
    return events.APIGatewayProxyResponse{
      Body:       string(responseBody),
      StatusCode: 200,
    }, nil
  }
  // ~~~
}

工夫したこと

SlackAPIにアクセスするための認証キーを安全に管理する

アプリからSlackにアクセスするためには、Slack側で発行した認証キーが必要になります。
認証キーをソースコードにハードコーディングすると、ソースコードが外部に漏洩した際に不正アクセスの原因となってしまいます。
そのため、認証キーのような機密情報はソースコードとは切り分けて別で管理できるのようにしておくのが理想です。

Lambdaにおける認証キーの取り扱いとして環境変数を利用する方法がよくありますが、今回は認証キーをより安全に管理できるSSMパラメータストアを利用する方法を採用しました。

SAMテンプレート(template.yaml)においてパラメータストア(Key-Value)は下記のように定義します。

# template.yaml
Parameters:
  SsmParamSlackAuthTokenKeyName:
    Type: String
  SsmParamSlackAuthTokenValue:
    Type: String

Resources:
  SsmParamSlackAuthKey:
    Type: AWS::SSM::Parameter
    Properties:
      Description: SlackAPI Auth Key
      Name: !Ref SsmParamSlackAuthTokenKeyName  # Key名を指定
      Type: String
      Value: !Ref SsmParamSlackAuthTokenValue  # 保存する値を指定

SsmParamSlackAuthTokenKeyNameSsmParamSlackAuthTokenValueは、SAMParameters構文でデプロイ時に外から渡せるようにしています。

// get-news/main.go
import (
    "log"
    "github.com/slack-go/slack"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/ssm"
)

/// Slackへメッセージを投稿する
func sendSlackMessage(channel, text string) {
  // パラメータストアより認証キーを取得
  res, err := fetchParameterStore(getEnv("SSM_SLACK_AUTH_KEY_NAME", ""))
  if err != nil {
    log.Print(err)
    return
  }

  client := slack.New(res)
  _, _, err = client.PostMessage(channel, slack.MsgOptionText(text, true))
  if err != nil {
    // エラーログを出力
    log.Print(err.Error())
  }
}

/// パラメータストアから値を取得する
func fetchParameterStore(paramName string) (string, error) {

  sess := session.Must(session.NewSession())
  svc := ssm.New(
    sess,
    aws.NewConfig().WithRegion(getEnv("SSM_REGION", "")),
  )

  res, err := svc.GetParameter(&ssm.GetParameterInput{
    Name:           aws.String(paramName),
    WithDecryption: aws.Bool(true),
  })
  if err != nil {
    return "SSM: パラメータ取得失敗", err
  }

  value := *res.Parameter.Value
  return value, nil
}

上記のようにsession,ssmライブラリを使用して、SSMのパラメータストアに格納した認証キーを取得することができます。
SSMのパラメータストアでは、格納した値の暗号化管理が可能なため、よりセキュアに機密情報が管理できます。

最後に

駆け足になりましたが、AWS SAMを使ったアプリ開発でハマったところなどを書かせてもらいました。
2~3年くらいAWSを触っていますが、サーバーレスなアプリ開発をプロダクションレベルで行えていないのが悔しいとところ。。
AWS Amplifyもだいぶ整備されてきましたし、引き続き、手軽にアプリ開発できる仕組みの検討を進めていきたいです。

モバイルバージョンを終了