シンクロ・フード エンジニアブログ

飲食店ドットコムを運営する、株式会社シンクロ・フードの技術ブログです

Lambda(Ruby) + Slack スラッシュコマンドで社内ジョブをクラウド化

初めまして、SREチームの柴山です。
今回は 社内サーバーで動いていたデプロイ補助ツールを、 Lambda+Slack でクラウド化した際の作業を備忘録として共有します。

背景

このツールはオンプレミスの社内サーバー上の Jenkins のジョブとして用意され、都度ウェブブラウザからジョブを実行して実行結果をジョブのログとして確認していました。
この度オンプレミスの社内サーバーを廃しクラウドに移行するにあたり、 Slack からも見れるようにしたいとの要望があり、本対応を行いました。

構成概要

処理の流れは以下のようになっています。

  1. ユーザーがスラッシュコマンドを実行
  2. Slack から 認証用(一時応答用) Lambda を起動
  3. リクエスト認証後、本処理用 Lambda を非同期実行
  4. 一時応答を Slack へ返す
  5. 本処理での実行結果を投稿

Slack App の作成

Your Apps から Create New App を押下して Slack App を作成します。
スコープ・設定の構成方法について選択項目がありますが、From scratch で問題ありません。
ワークスペースの指定、Slack App名を設定します。※ワークスペースは通知先を指定してください。

Slack App からチャンネルへの投稿に必要な権限を設定します。
Your Apps -> <作成した App> -> OAuth & Permissions -> Scopes -> Bot Token Scopes

項目の下部にある Add as OAuth Scope を押下し、以下の権限を付与します。

  • chat:write
    • 必須
    • チャンネルへの投稿に必要
  • chat:write.customize
    • 必要に応じて追加
    • Slack API 経由での投稿時、アイコンやBot名をカスタマイズできます。
  • chat:write.public
    • 必要に応じて追加
    • 特定のチャンネルへ App 追加をしていなくても、投稿が可能になります。
  • commands
    • 手動での設定は必要ありません。
    • 後の手順で自動的に設定されます。

Lambda の作成

以下のLambda関数を作成します。

  • 前段の認証・一時応答用 Lambda
  • 本処理用の Lambda

Lambda側のランタイムやライブラリに依存しないように、いずれもコンテナイメージを作成・使用します。
今回紹介するコードの実行に最低限必要な Dockerfile、Gemfile を共有します。
必要に応じて記述の修正・バージョンの固定などをしてください。

FROM public.ecr.aws/lambda/ruby:2.7

COPY Gemfile ${LAMBDA_TASK_ROOT}/

# Gem のビルドに必要なライブラリをインストール
RUN yum -y install gcc-c++ make \
    && gem install bundler:2.4.22 \
    && bundle config set --local path 'vendor/bundle' \
    && bundle install \
    && yum -y remove gcc-c++ make \
    && yum clean all

COPY lambda_function.rb ${LAMBDA_TASK_ROOT}/

CMD [ "lambda_function.LambdaFunction::Handler.process" ]
source 'https://rubygems.org'

gem 'json'
gem 'net'
gem 'uri'
gem 'json'
gem 'set'
gem 'pp'
gem 'date'
gem 'time'
gem 'rack'
gem 'aws-sdk-lambda'
gem 'base64'
gem 'slack-ruby-client'
gem 'jwt'
gem 'faraday-retry'

前段のLambda

Lambda の IAM に lambda:InvokeFunction 権限が必要

先に全体のコードを共有します。

require 'json'
require 'pp'
require 'date'
require 'time'
require 'rack/utils'
require 'aws-sdk-lambda'
require 'base64'

module LambdaFunction
  class Handler
    def self.verify_slack_request(event)
      # Slackのリクエストを検証する
      timestamp = event['headers']['x-slack-request-timestamp']
      slack_signature = event['headers']['x-slack-signature']

      # リクエストがエンコードされている場合はデコードする
      if event['isBase64Encoded']
        body = Base64.decode64(event['body'])
      else
        body = event['body']
      end

      # 5分以上経過したリクエストは無効とする
      if (Time.now.to_i - timestamp.to_i).abs > 60 * 5
        return false
      end

      # リクエストの署名を検証する
      req_signature = "v0:#{timestamp}:#{body}"
      my_signature = "v0=" + OpenSSL::HMAC.hexdigest('sha256', ENV['SLACK_SIGNING_SECRET'], req_signature)
      Rack::Utils.secure_compare(my_signature, slack_signature)
    end

    def self.process(event:, context:)
      if verify_slack_request(event)
        # 本処理用lambda の呼び出し
        lambda = Aws::Lambda::Client.new
        lambda.invoke(function_name: '<function_name>',invocation_type: 'Event', payload: JSON.generate({event: event, context: context}))
        # 一時的に応答
        return { text: '処理中です...' }
      else
        # 認証に失敗した場合は 401 を返す
        return { statusCode: 401, body: 'Unauthorized' }
      end
    end
  end
end

こちらの Slack公式ドキュメントを参考に実装内容を解説していきます。 https://api.slack.com/authentication/verifying-requests-from-slack

timestamp = event['headers']['x-slack-request-timestamp']
slack_signature = event['headers']['x-slack-signature']

Slack からのリクエスト HTTPヘッダー に 署名シークレット が含まれているため、変数へ格納します。

require 'base64'

body = Base64.decode64(event['body'])

Slack からのリクエスト本文は base64 でエンコードされており、デコード処理を入れています。

require 'time'

if (Time.now.to_i - timestamp.to_i).abs > 60 * 5
  return false
end

The signature depends on the timestamp to protect against replay attacks.
引用: https://api.slack.com/authentication/verifying-requests-from-slack

リクエスト時のタイムスタンプを必須とし、5分以内のリクエストのみを通すようにします。

require 'rack/utils'

req_signature = "v0:#{timestamp}:#{body}"
my_signature = "v0=" + OpenSSL::HMAC.hexdigest('sha256', ENV['SLACK_SIGNING_SECRET'], req_signature)
Rack::Utils.secure_compare(my_signature, slack_signature)

3.Concatenate the version number, the timestamp, and the request body together, using a colon (:) as a delimiter.
4.Hash the resulting string, using the signing secret as a key, and taking the hex digest of the hash.
5.Compare the resulting signature to the header on the request.
引用: https://api.slack.com/authentication/verifying-requests-from-slack

リクエストに使用されている署名シークレットを検証します。

  1. バージョン番号(v0)、タイムスタンプ、リクエスト本文(token=hogefuga...) の三つを連結します。
  2. 連結した文字列をハッシュ化し、16進数のダイジェスト値を取得します。
  3. リクエストの署名とSlack App側の署名を比較します。
    • タイミングアタック防止のため、署名の比較は Rack ライブラリを利用します

ENV['SLACK_SIGNING_SECRET'] には Slack App の署名シークレットを格納しています。
署名シークレットは以下の項目で確認ができます。
Your Apps -> <作成した App> -> Basic Information -> App Credentials

require 'aws-sdk-lambda'

lambda = Aws::Lambda::Client.new
lambda.invoke(function_name: '<function_name>',invocation_type: 'Event', payload: JSON.generate({event: event, context: context}))
return { text: '処理中です...' } # 一時的な応答として表示される文言です。

AWS API 用のライブラリを使用し、後続の Lambda を呼び出します。
invocation_typeEvent にすることで、非同期での呼び出しが可能になります。

最後に 一時応答用のテキストを返します。Slack 側からは以下のように表示されます。

operation_timeout が返ってくる場合、Lambda のメモリを512 MB 以上に上げてみてください。

本処理用のLambda

Slack から呼び出された Lambda とは切り離されているため、後続での処理に関しては基本自由になります。 今回は メッセージ送信処理の例を紹介します。

require 'slack-ruby-client'

Slack.configure do |config|
  config.token = ENV['SLACK_ACCESS_TOKEN']
end
slack_client = Slack::Web::Client.new

channel = '#通知先チャンネル'
response = slack_client.chat_postMessage(channel: channel, text: body, mrkdwn: false)

ENV['SLACK_ACCESS_TOKEN'] には Slack App の署名シークレットを格納しています。
署名シークレットは以下の項目で確認ができます。
Your Apps -> <作成した App> -> OAuth & Permissions -> OAuth Tokens -> Bot User OAuth Token
Bot User OAuth Token は Slack App がワークスペースにインストールされたタイミングで発行されます。

関数URLの設定

以下の内容で 前段側 Lambda に関数URLを発行します。

  • 認証タイプ: NONE
  • 呼び出しモード: BUFFERED (デフォルト)

発行した URL はスラッシュコマンドの設定に使用します。

Slack App での スラッシュコマンド設定

Slack App の設定ページから、スラッシュコマンドの設定を行います。
Your Apps -> <作成した App> -> Slash Commands -> Create New Command

以下の項目を設定します。

  • Command: 自由に記述
    • 必須
  • Request URL: 前段 Lambda の関数URL
    • 必須
  • Short Description: 自由に記述
  • Usage Hint: 自由に記述

※変更が反映されない場合は、ワークスペースへの再インストールを実施してください。

Slackへ App の追加

※以下の作業は スラッシュコマンドを実行したいチャンネルで実施してください。

Slackのチャンネル名を押下すると、チャンネル詳細が表示されます。

インテグレーション -> App -> アプリを追加する
ワークスペースに存在する Slack App の一覧が表示されるため、作成した App を追加してください。

以上で、全ての設定が完了しました。
対象のチャンネルから作成したスラッシュコマンドが実行可能になります。

Lambdaの処理を二つに分けている理由

Slack の スラッシュコマンドの仕様として、リクエストを受けてから3秒以内に応答を返す必要があります。
少し複雑な処理を行おうとすると、応答までに3秒以上かかりリクエストがタイムアウトしてしまいます。
参考: https://api.slack.com/interactivity/slash-commands#responding_basic_receipt

また、分けることによってパブリック状態にある Lambda と 本処理を切り分ける意図もあります。
構成は一つ増えますが、基本的には分けることをオススメします。

まとめ

今回は 社内サーバー上のジョブを Lambda + Slack スラッシュコマンドへ移行した際の設定内容を共有させていただきました。
スラッシュコマンドを使用することで、簡易的なスクリプトの実行が容易になります。
対象となったジョブは デプロイ対象を一覧表示してくれる便利系のツールで、誰でも・いつでも 実行と確認が可能でした。
Slack へ移行したことで スマートフォンなどからも操作が可能になり、利便性も上がったと思います。

当記事が、同様の実装をされる方の役に立てれば幸いです。