Ruby×Sinatraで作ったSlackBotをAWS Lambdaで動かしてみる

## 本記事で目差す構成

![Untitled Diagram(1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/15835a3a-f744-3f00-b6ee-d465bd9a64fc.png)

① Slackで特定のアクションを実行する。（※今回はスラッシュコマンド）
② API Gatewayを介してLambdaを起動。
③ Lambdaに配置した関数を実行し情報を返す。

![slackbot(2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f1600dad-aaa5-b64f-a0dd-e24bcfb2e0ca.gif)

↑動作イメージとしてはこんな感じ。
今回はとある地域の現在気温を返してくれるSlackBotを動かしてみる。

## 対象読者

- 簡単なSlackBotを作ってみたい人
- AWSのLambdaに触れてみたい人

### Lambdaとは？

> AWS Lambda はサーバーをプロビジョニングしたり管理する必要なくコードを実行できるコンピューティングサービスです。 AWS Lambda は必要時にのみてコードを実行し、1 日あたり数個のリクエストから 1 秒あたり数千のリクエストまで自動的にスケーリングします。使用したコンピューティング時間に対してのみお支払いいただきます- コードが実行中でなければ料金はかかりません。AWS Lambda では、管理を全く必要とせずに、任意のアプリケーションやバックエンドサービスで仮想的にコードを実行できます。AWS Lambda は、高度な可用性のコンピューティングインフラストラクチャでコードを実行し、サーバーとオペレーティングシステム、システムのメンテナンス、容量のプロビショニングと自動スケーリング、コードのモニタリングやログ記録など、コンピューティングリソースのすべての管理を実行します。必要な操作は、AWS Lambda がサポートするいずれかの言語でコードを指定するだけです。（引用: Amazon公式ドキュメント）

これだけだとイマイチわかりにくいが、要するに「プログラムを実行するためのサーバーがいらない」という事。

通常、何かしらのプログラムを実行しようと思った場合、サーバーを購入したり、各種ミドルウェアをインストールしたりと色々手間がかかるものだが、Lambdaにおいてそういったものは全てAWSが管理してくれるため、開発者はソースコードの作成にだけ力を注げば良くなるらしい。

### Lambdaを使うメリット

- サーバーや各種ミドルウェアの管理が不要
 - 上述の理由から。
- コスト削減
 - リクエスト数やプログラムの実行時間によって課金される仕組みとなっており、待機時間には課金されないため、使用する局面によっては大幅なコストダウンが可能。（常に課金され続けるEC2とは対照的）
- オートスケーリング
 - アクセス数や負荷に応じて自動的にサーバーの数を増減してくれる。

今回のようなSlackBotの場合、常に稼働させたいというよりは必要な時のみ動いてくれれば構わないため、Lambdaを利用するにはちょうど良いと思った。

SlackBotくらい軽い実装であればHerokuなどを使ったデプロイ方法も定番だが、今時のAWSを使ってみたい感がある。

## 仕様

言語: Ruby2.5
フレームワーク: Sinatra
インフラ: AWS Lambda

完成形: [slack-bot-on-aws-lambda](https://github.com/kazama1209/slack-bot-on-aws-lambda)

## SlackBotを作成

まず、肝心のSlackBotを作成していく。

### ディレクトリを作成

```
$ mkdir slack-bot-on-aws-lambda
$ cd slack-bot-on-aws-lambda
```

### Rubyのバージョンを指定

```
# 2.5系なら何でもOK
$ rbenv local 2.5.1 
```

### Sinatraをインストール

```
$ bundle init
```

↑のコマンドでGemfileを生成し、以下のように編集する。

```ruby:./Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'sinatra'
```

その後、Gemをインストール。

```
$ bundle install --path vendor/bundle
```

動作確認のため、とりあえず「Hello World!」と返すページを実装してみる。

```
$ touch main.rb
```
```ruby:./main.rb
require 'sinatra'

get '/' do
  'Hello World!'
end
```

その後、Sinatraを起動。

```
$ bundle exec ruby main.rb

[2020-09-21 20:47:35] INFO  WEBrick 1.4.2
[2020-09-21 20:47:35] INFO  ruby 2.5.1 (2018-03-29) [x86_64-darwin19]
== Sinatra (v2.1.0) has taken the stage on 4567 for development with backup from WEBrick
[2020-09-21 20:47:35] INFO  WEBrick::HTTPServer#start: pid=50418 port=4567
```

Sinatraはデフォルトだとポート番号「4567」で動くため、「[localhost:4567](localhost:4567)」にアクセス。

![スクリーンショット 2020-09-21 20.50.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/688d5009-996f-e706-22c6-5597de47e7f7.png)

「Hello World!」と表示されれば成功。

### 天気情報を返すプログラムを実装

今回作るSlackBotの主な機能である天気情報を返すプログラムを実装していく。

#### OpenWeatherのAPIキーを取得

[https://openweathermap.org/](https://openweathermap.org/)
![スクリーンショット 2020-09-21 20.54.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/7a6a4e6b-c794-7138-4a28-dd98fa24e1c8.png)

上記サイトに会員登録し、APIキーを取得。

![スクリーンショット 2020-09-21 20.59.28_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/73440049-6923-85ac-641f-f14649ddb6ad.jpeg)

英語で書かれたサービスだが、ある程度は直感的に操作できるので詳しい説明は省略。どうしてもわからなかったらググればいくらでも記事が出てくるはず。

#### 各種Gemをインストール

この先の処理を行う上で必要なGemがいくつかあるため、このタイミングで一気にインストールしておく。

```ruby:./Gemfile
gem 'faraday'
gem 'rack'
gem 'rack-contrib'
gem 'rubysl-base64'
gem 'slack-ruby-bot'
```

「bundle install」も忘れずに。

```
$ bundle install --path vendor/bundle
```

#### src/weather.rbを作成

```
$ mkdir src
$ touch src/weather.rb
```

src/weather.rbを作成し、次のように記述。

```ruby:./src/weather.rb
require 'json'

class Weather
  def current_temp(locate)
    end_point_url = 'http://api.openweathermap.org/data/2.5/weather'
    api_key = # 先ほど取得したOpenWeatherのAPIキー

    res = Faraday.get(end_point_url + "?q=#{locate},jp&APPID=#{api_key}")
    res_body = JSON.parse(res.body)

    temp = res_body['main']['temp']
    celsius = temp - 273.15
    celsius_round = celsius.round

    return "現在の練馬の気温は#{celsius_round.to_s}℃です。"
  end
end
```


#### main.rbを編集

```ruby:./main.rb
require 'slack-ruby-client'
require 'sinatra'
require './src/weather'

Slack.configure do |conf|
  conf.token = # SlackBotのトークン
end

get '/' do
  'This is SlackBot on AWS Lambda'
end

post '/webhook' do
  client = Slack::Web::Client.new

  channel_id = params['channel_id']
  command = params['command']

  case command
    when '/nerima'
    # スラッシュコマンド「/nerima」が実行された場合に以下の処理が走る。
      weather = Weather.new
      client.chat_postMessage channel: channel_id, text: weather.current_temp('Nerima'), as_user: true
      # 'Nerima'の部分は各自変更してOK。「Shinjuku」に変えれば新宿の気温を返すはず。
  end

  return
end
```

SlackBotのトークンを取得する方法については次の記事を参照。

参照: [ワークスペースで利用するボットの作成](https://slack.com/intl/ja-jp/help/articles/115005265703-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%81%A7%E5%88%A9%E7%94%A8%E3%81%99%E3%82%8B%E3%83%9C%E3%83%83%E3%83%88%E3%81%AE%E4%BD%9C%E6%88%90)
参照: [API トークンの生成と再生成](https://slack.com/intl/ja-jp/help/articles/215770388-API-%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3%E3%81%AE%E7%94%9F%E6%88%90%E3%81%A8%E5%86%8D%E7%94%9F%E6%88%90)


#### 実際に動作確認

SlackBotをスラッシュコマンドで呼び出すためにいくつか設定しなければならない事がある。

[https://api.slack.com/apps/](https://api.slack.com/apps/)
![スクリーンショット 2020-09-21 21.33.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/4eb0483f-0658-d4e2-0192-8db482d73287.png)
↑のURLにアクセスし、該当のBotを選択。

![スクリーンショット 2020-09-21 21.37.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/dcb9b4ac-b2bf-6ce4-abb5-e3bb022f5af8.png)
左サイドメニューに「Slash Commands」という項目があるので選択し、「Create New Command」をクリック。
![スクリーンショット 2020-09-21 21.40.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/546089bf-cec2-28e4-73d3-4bdb5cc78537.png)
各項目を入力していく。

- Command: 任意のスラッシュコマンド。
 - 今回は東京度練馬区の現在気温を返す事を想定しているので「/nerima」としているが、たとえば新宿区であれば「/shinjuku」とかでもOK。）
- Request URL: スラッシュコマンドを実行した際にリクエストしたいURL。
  - 「localhost」では動かないため、今回はngrokを使って独自のドメインを割り当てている。
  - 参照: [ngrokの利用方法](https://qiita.com/hirokisoccer/items/7033c1bb9c85bf6789bd)
  - 「bundle exec ruby main.rb」でSinatraを起動した後、別のターミナルで「ngrok http 4567」と叩いて表示されたURLを使用する。
  - postメソッドでリクエストしたいので、「https://********.ngrok.io/webhook」と入力する。
- Short Description: スラッシュコマンドの簡単な説明。

入力が完了したら右下の「Save」をクリック。

![スクリーンショット 2020-09-21 21.53.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/92c14de6-7b61-3add-12f7-c450926ac200.png)
スラッシュコマンドの作成が終わったら、SlackBotを追加したチャンネルで「/nerima」と打ち込んでみる。上手くいけば画像のようにSlackBotから返答が来る。（設定で画像や名前を変えたりする事も可能。）
![スクリーンショット 2020-09-21 22.04.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/0d2252cd-dc11-a485-765a-b95e6c24922b.png)
何か不具合があった場合はターミナルにログが出力されているはずなので、適宜デバッグ。

## AWS Lambdaにデプロイ

正常に動作確認できたら、いよいよAWS Lambdaで本番稼働させる。

### AWS CLIをインストール

今回は「AWS CLI」と呼ばれるツールを使いながらデプロイしていくので、まだインストールできてないない場合はインストールしておく。

```
$ brew install awscli
```

### IAMユーザーを作成

デプロイ作業を行うためのIAMユーザーを作成していく。
![スクリーンショット 2020-09-21 22.11.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f924488a-df8b-b871-598e-bba9d148a806.png)
まずは「IAM」→「ポリシー」→「ポリシーの作成」へと進み、JSONタブから以下の文を貼り付ける。
![スクリーンショット 2020-09-21 22.13.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/4e0fce57-3d4e-bdf8-9c8c-62bdd89f54b2.png)

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "apigateway:*",
                "cloudformation:*",
                "dynamodb:*",
                "events:*",
                "iam:*",
                "lambda:*",
                "logs:*",
                "route53:*",
                "s3:*"
             ],
            "Resource": [
                "*"
            ]
        }
    ]
}
```

参照: [Minimal Deploy IAM Policy](https://rubyonjets.com/docs/extras/minimal-deploy-iam/)

![スクリーンショット 2020-09-21 22.15.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/d78cf088-8020-ec07-84a7-d035ac7dc5e3.png)
適当にポリシー名や説明を記述し、「ポリシーの作成」をクリック。

![スクリーンショット 2020-09-21 22.17.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/c2ddcc5a-f768-4409-b6a3-7c7c3477a3ce.png)
次に「IAM」→「ユーザー」→「ユーザーの作成」へと進み、適当な名前を付けた後「プログラムによるアクセス」にチェックを入れて次へ進む。

![スクリーンショット 2020-09-21 22.17.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/66858ba0-eebb-1342-9bfa-64dba9e4097b.png)
「既存のポリシーを直接アタッチ」から先ほど作成した「MinimalDeployIAMPolicy」を選択し、次へ進む。

![スクリーンショット 2020-09-21 22.17.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f9d0e4cf-ce0c-31d6-d371-e19c0ae36d8a.png)

（タグは任意でOK）最後に確認画面が表示されるので、問題無ければ「ユーザーの作成」をクリック。

![スクリーンショット 2020-09-21 22.18.13_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/86083659-0cb8-be5b-637f-3e0cfccd266f.jpeg)
すると「アクセスキーID」と「シークレットアクセスキー」の2つが発行されるので、csvファイルをダウンロードするなりメモするなり大事に保管しておく。

### AWS CLIの設定

```
$ aws configure

AWS Access Key ID # 先ほど作成したアクセスキーID
AWS Secret Access Key # 先ほど作成したシークレットアクセスキー
Default region name # ap-northeast-1 
Default output format # json 
```

ターミナルで「aws configure」と打ち込むと対話形式で色々聞かれるので、それぞれ必要な情報を入力していく。

#### 各種ファイルを作成

AWS CLIの設定が終わったら、デプロイに必要な各種ファイルの作成を行う。

- config.ru
- lambda.rb
- template.yaml

```ruby:./config.ru
require 'rack'
require 'rack/contrib'
require_relative './main'

set :root, File.dirname(__FILE__)

run Sinatra::Application
```
```ruby:./lambda.rb
require 'json'
require 'rack'
require 'base64'

$app ||= Rack::Builder.parse_file("#{__dir__}/config.ru").first
ENV['RACK_ENV'] ||= 'production'

def handler(event:, context:)
  body = if event['isBase64Encoded']
    Base64.decode64 event['body']
  else
    event['body']
  end || ''

  headers = event.fetch 'headers', {}

  env = {
    'REQUEST_METHOD' => event.fetch('httpMethod'),
    'SCRIPT_NAME' => '',
    'PATH_INFO' => event.fetch('path', ''),
    'QUERY_STRING' => Rack::Utils.build_query(event['queryStringParameters'] || {}),
    'SERVER_NAME' => headers.fetch('Host', 'localhost'),
    'SERVER_PORT' => headers.fetch('X-Forwarded-Port', 443).to_s,

    'rack.version' => Rack::VERSION,
    'rack.url_scheme' => headers.fetch('CloudFront-Forwarded-Proto') { headers.fetch('X-Forwarded-Proto', 'https') },
    'rack.input' => StringIO.new(body),
    'rack.errors' => $stderr,
  }

  headers.each_pair do |key, value|
    name = key.upcase.gsub '-', '_'
    header = case name
      when 'CONTENT_TYPE', 'CONTENT_LENGTH'
        name
      else
        "HTTP_#{name}"
    end
    env[header] = value.to_s
  end

  begin
    status, headers, body = $app.call env

    body_content = ""
    body.each do |item|
      body_content += item.to_s
    end

    response = {
      'statusCode' => status,
      'headers' => headers,
      'body' => body_content
    }
    if event['requestContext'].has_key?('elb')
      response['isBase64Encoded'] = false
    end
  rescue Exception => exception
    response = {
      'statusCode' => 500,
      'body' => exception.message
    }
  end

  response
end
```
```yaml:template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Resources:
  SinatraFunction:
    Type: 'AWS::Serverless::Function'
    Properties:
      FunctionName: SlackBot
      Handler: lambda.handler
      Runtime: ruby2.5
      CodeUri: './'
      MemorySize: 512
      Timeout: 30
      Events:
        SinatraApi:
            Type: Api
            Properties:
                Path: /
                Method: ANY
                RestApiId: !Ref SinatraAPI
  SinatraAPI:
    Type: AWS::Serverless::Api
    Properties:
      Name: SlackBotAPI
      StageName: Prod
      DefinitionBody:
        swagger: '2.0'
        basePath: '/Prod'
        info:
          title: !Ref AWS::StackName
        paths:
          /{proxy+}:
            x-amazon-apigateway-any-method:
              responses: {}
              x-amazon-apigateway-integration:
                uri:
                  !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SinatraFunction.Arn}/invocations'
                passthroughBehavior: 'when_no_match'
                httpMethod: POST
                type: 'aws_proxy'
          /:
            get:
              responses: {}
              x-amazon-apigateway-integration:
                uri:
                  !Sub 'arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${SinatraFunction.Arn}/invocations'
                passthroughBehavior: 'when_no_match'
                httpMethod: POST
                type: 'aws_proxy'
  ConfigLambdaPermission:
    Type: 'AWS::Lambda::Permission'
    DependsOn:
    - SinatraFunction
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref SinatraFunction
      Principal: apigateway.amazonaws.com
Outputs:
  SinatraAppUrl:
    Description: App endpoint URL
    Value: !Sub "https://${SinatraAPI}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
```

それぞれが何を表しているのかについてここでは割愛。（この辺はawsの公式ドキュメントをほぼそのまま使っているため）
参照: [aws-samples/serverless-sinatra-sample](https://github.com/aws-samples/serverless-sinatra-sample)

### S3バケットの作成

事前にAWS S3バケットを準備しておく必要があるため、適当にS3バケットを作成。

参照: [AWS S3のバケットの作り方](https://qiita.com/miriwo/items/41e488b79fb58fa7c952)

### デプロイ

次のコマンドを実行。

```
$ aws cloudformation package \
     --template-file template.yaml \
     --output-template-file serverless-output.yaml \
     --s3-bucket # 先ほど作成したS3バケット名

Uploading to a3a55f6abf5f21a2e1161442e53b27a8  12970487 / 12970487.0  (100.00%)
Successfully packaged artifacts and wrote output template to file serverless-output.yaml.
Execute the following command to deploy the packaged template
aws cloudformation deploy --template-file /Users/ユーザー名/ディレクトリ名/serverless-output.yaml --stack-name <YOUR STACK NAME>
```

すると、ディレクトリ内に「serverless-output.yaml」というファイルが自動生成されているはずなので、こちらを元に次のコマンドを実行。

```
$ aws cloudformation deploy --template-file serverless-output.yaml \
     --stack-name slack-bot \
     --capabilities CAPABILITY_IAM

Waiting for changeset to be created..
Waiting for stack create/update to complete
Successfully created/updated stack - slack-bot
```

「Successfully」と表示されればデプロイ成功。

![スクリーンショット 2020-09-21 22.49.32_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/ae029aee-3123-ab84-d141-c3a1b27d4e09.jpeg)

「Lambda」→「関数」と進むと先ほどデプロイした内容が表示されるので、「API Gateway」内に記載されているAPIエンドポイントにアクセス。

![スクリーンショット 2020-09-21 22.53.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/f646071f-f0c5-4000-11a5-9508c04639d7.png)

```ruby:./main.rb
get '/' do
  'This is SlackBot on AWS Lambda'
end
```

main.rb内のget '/' リクエストの期待通り「This is SlackBot on AWS Lambda」が返ってくれば正常に動作していると判断してOK。

### Slash CommandsのRequest URLを変更

[https://api.slack.com/apps/](https://api.slack.com/apps/)

![スクリーンショット 2020-09-21 23.00.35_censored.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/8939e3ae-bfff-4f82-c36f-476e97c94ab6.jpeg)
再びSlackBotの設定ページにアクセスし、「Slash Commands」からRequest URLを先ほど作成されたエンドポイントに変更する。（https://********.execute-api.ap-northeast-1.amazonaws.com/Prod/webhook」）

![スクリーンショット 2020-09-21 23.06.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/d71e4292-4056-40b1-9d64-d1c73566eec5.png)
最後にもう一度、Slackチャンネルで「/nerima」と打ち込み、ちゃんとレスポンスが返ってくればめでたしめでたし。

![スクリーンショット 2020-09-21 23.07.45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/a3838d2c-34c0-d39e-e7dc-a20d835a8ad1.png)
もし上手く行かなかった場合はCloudWatchにログが出力されているはずなので、適宜デバッグ。

## あとがき

お疲れ様でした！

今回、簡単なSlackBotをLambdaで動かすというテーマでAWS Lambdaに触れてみました。
大した機能は実装できていないのでLamdaの素晴らしさを全て実感というわけにはいきませんでしたが、上手く使えばかなり便利なサービスだと素人ながら感じています。

難しい操作はしていないため、基本的には手順通りに進めていけば動くはずですが、もし詰まるところがあった場合はコメント欄などで指摘していただけると嬉しいです。