いろいろなサービスを使ってLINEbotを作ろう【Heroku編】

# 概要

LINEbotのバックエンドをさまざまなサービスを利用して作ってみようというシリーズです。
第2弾はHerokuを利用します。

そのほかのサービスを利用した構築については、以下を参照ください。

- 第１弾：[ngrok](https://qiita.com/mintak21/items/fe9234d4b6a98bfc841a)
- 第２弾：Heroku ※本記事
- 第３弾：[Google CloudFunctions]() ※近日更新
- 第４弾：Google Cloud Run
- 第５弾：Google Kubernetes Engine
- 第６弾：AWS Lambda (予定)

# 前提

- [heroku](https://dashboard.heroku.com/)および[LINE Developers](https://developers.line.biz/ja/)へのユーザー登録およびチャンネル作成が済んでいる
- [herokuCLI](https://devcenter.heroku.com/articles/heroku-cli)をインストールしている

# 全体像

![Flask_Nginx_unicorn_diagramdrawio-Linebot_Heroku.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/552343/9037134b-3883-c2db-939b-4b79386aea83.png)


## ハンドラ実装

<details><summary>コード全容はこちら</summary><div>

```python:main.py
import logging
import os

from flask import Flask, abort, request
from linebot import LineBotApi, WebhookHandler
from linebot.exceptions import InvalidSignatureError
from linebot.models import MessageEvent, TextMessage, TextSendMessage

app = Flask(__name__)
app.logger.setLevel(logging.INFO)
# 変更点1
handler = WebhookHandler(os.getenv('CHANNEL_SECRET'))
line_bot_api = LineBotApi(os.getenv('CHANNEL_ACCESS_TOKEN'))


@app.route('/callback', methods=['POST'])
def callback():
    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)
    app.logger.debug("Request body: " + body)
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        # 変更点3
        app.logger.warn('Invalid signature. Please check your channel access token/channel secret.')
        abort(400)

    return 'OK'


@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    # 変更点2
    if (event.reply_token == '00000000000000000000000000000000' or
            event.reply_token == 'ffffffffffffffffffffffffffffffff'):
        app.logger.info('Verify Event Received')
        return
    # オウム返し
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))


if __name__ == "__main__":
    app.logger.setLevel(logging.DEBUG)
    app.run()
```

</div></details>

ソースコードは[Python SDK](https://github.com/line/line-bot-sdk-python#synopsis)のサンプルをベースに以下の修正を加えています。

### 1. シークレットをコードに埋め込まない

`チャンネルシークレット`と`アクセストークン`は秘匿情報の部類となります。仮に流出してしまった場合は、他人に自分のアカウントをのっとられてしまいます。
サンプルでは`チャンネルシークレット`と`アクセストークン`を直接コードに記述するような書き方になっていますが、この状態でだれもが見られるリモートリポジトリにコードをpushすることはできませんね。（プライベートリポジトリであれば多少話は別ですが）
そこで、実行時のサーバ・コンテナの特定の環境変数に値を設定し、コード側はその環境変数から値を読み込むように記載します。（万全ではないですが、対応前に比べれば段違いにセキュアなつくりとなっています）

```diff
- handler = WebhookHandler('YOUR_CHANNEL_SECRET')
- line_bot_api = LineBotApi('YOUR_CHANNEL_ACCESS_TOKEN')
+ handler = WebhookHandler(os.getenv('CHANNEL_SECRET'))
+ line_bot_api = LineBotApi(os.getenv('CHANNEL_ACCESS_TOKEN'))
```

### 2. LINE Developersからの`Verify`を成功させる

**本対応は必須ではありません。**
LINE Developersの`Webhook URL`を設定する画面に`Verify`ボタンが存在し、ここから設定したバックエンドへの疎通を取るような形になっています。
しかし、サンプルをそのまま利用すると、この`Verify`のレスポンスコードが200になることはありません。
これは`Verify`リクエストの`reply_token`が`00000000000000000000000000000000`または`ffffffffffffffffffffffffffffffff`となっており、どうもWebAPI側でこの2つのトークンは不正であると判別していることによるようです。
そこで、この2種類のトークンは特別扱いで200のステータスコードを返すようにhandlerへ分岐を追加します。

```diff
+ if (event.reply_token == '00000000000000000000000000000000' or
+             event.reply_token == 'ffffffffffffffffffffffffffffffff'):
+         return
```

### 3. print → logger

本対応は必須ではありませんが、推奨設定です。
ローカルでのprintデバッグというのは有用な方法ですが、通常ソースコードに`print`は埋め込まない方がよいでしょう。情報を出力したい場合は通常ロギング（ライブラリ）を使用します。今回は`Flask`のLoggerを利用するのが手っ取り早いと思います。

```diff
- print("Invalid signature. Please check your channel access token/channel secret.")
+ app.logger.warn('Invalid signature. Please check your channel access token/channel secret.')
```

## Webサーバ設定ファイルの記述

上記実装で使用したFlaskフレームワークには、Webサーバとして起動することのできる機能も備わっていますが、デバッグ向けの機能となります。サービスとして公開する場合には、安定性や速度を鑑みてWSGIサーバを使用するのがよいでしょう。（このあたりの詳細は[こちら](https://qiita.com/mintak21/items/eeba4654a0db21abcb1c)も参照いただけると🙏）
今回は[gunicorn](https://gunicorn.org/)を利用します。

<details><summary>設定ファイル</summary><div>

```python:config.py
import os

host = '0.0.0.0'
port = os.getenv('PORT', 5000)

bind = str(host) + ':' + str(port)

# Debugging
reload = True

# Logging
accesslog = '-'
loglevel = 'info'

# Proc Name
proc_name = 'Line-Bot-Practice'

# Worker Processes
workers = 1
worker_class = 'sync'
```

</div></details>

ポイントは1点のみです。

### ポートはheroku側で自動設定される

herokuの仕様で、デプロイ後にポートが自動設定されるため、ポートに固定値を使用することはできません。
環境変数`PORT`にこの値は設定されているため、`os.getenv('PORT')`で取得します。

## ライブラリバージョンの指定

外部ライブラリを使用する際は、利用バージョンを明示・固定するのがよいでしょう。
ローカルにインストールしている場合は、`pip freeze > requirements.txt`コマンドで作成することも可能です。

```text:requirements.txt
Flask==1.1.2
line-bot-sdk==1.17.0
gunicorn==20.0.4
```

# herokuデプロイ設定

[Deploy With Git](https://devcenter.heroku.com/articles/git) / [Deploy With Docker](https://devcenter.heroku.com/categories/deploying-with-docker) の2パターンを選ぶことが可能です。

## 共通手順

どちらを選択する場合でも共通する手順です。

```bash:Terminal
# Git初期設定
git init

# ログイン / ログイン用ページへ遷移するので必要情報を入力
heroku login

# アプリケーション作成
heroku create ${APP_NAME}

# 環境変数のセット
heroku config:set CHANNEL_SECRET=${YOUR_CHANNEL_SECRET} --app ${APP_NAME}
heroku config:set CHANNEL_ACCESS_TOKEN=${YOUR_ACCESS_TOKEN} --app ${APP_NAME}
```

## Gitデプロイ

### ディレクトリ構成

ルートディレクトリを起点に処理がなされるため、すべてルートに配置します。

```bash
./
├── config.py
├── Procfile
├── requirements.txt
├── runtime.txt
└── main.py
```

また、2つのファイルを追加します。

### ランタイムの指定ファイル

Python固有の設定です。
`runtime.txt`を作成することで、実行するPythonバージョンを指定することができます。
サポートされているバージョンは[こちら](https://devcenter.heroku.com/articles/python-support#specifying-a-python-version)を参照ください。

```text:runtime.txt
python-3.8.6
```

### 起動設定ファイル

ルートディレクトリに`Procfile`が存在する場合は、記載されている内容でプロセスが起動されます。
ファイルの記述内容は[こちら](https://devcenter.heroku.com/articles/procfile)に詳細が書かれていますが、プロセス種別はほとんどのケースで`web`でしょうから`web: {コマンド}`と書くことが多いでしょう。

```text:Procfile
web: gunicorn main:app -c config.py
```

### デプロイ

herokuリポジトリのmaster（またはmain）ブランチに`push`することでアプリケーションがデプロイされます。

```bash:Terminal
git add .
git commit -m "first commit"
git push heroku master
```

### 動作確認

LINE Developersで`Webhook URL`に`https://${YOUR_HEROKU_APP}.herokuapp.com/`を設定して`Verify`、または実際にLINEからメッセージを送信してオウム返しされてくれば問題なく稼働しています。

***トラブルシューティング***

- `git push heroku master`でエラー

  gitのリモートブランチ設定がされていない可能性があります。
  `heroku git:remote --app ${APP_NAME}`コマンドを実行してみてください。
  設定が正しい場合は、以下のように表示されます。

  ```bash
  $ git remote -v
  heroku  https://git.heroku.com/mintak-heroku-linebot-practice.git (fetch)
  heroku  https://git.heroku.com/mintak-heroku-linebot-practice.git (push)
  ```

- 送信したメッセージが返ってこない

  まずは`heroku logs --app ${APP_NAME}`でどのようなログが出ているかを確認しましょう。
  ディレクトリ構成の誤り、または環境変数が未設定の可能性が高いと思われます。

## コンテナデプロイ

自身で作成したDockerfileをビルド、コンテナデプロイする方法です。

### ディレクトリ構成

herokuは`docker build`を実行する際のコンテキストが、自動的にDockerfileの存在するディレクトリに固定されてしまい、[外部から指定することができない](https://devcenter.heroku.com/articles/build-docker-images-heroku-yml#known-issues-and-limitations)ため、Dockerfileはルートディレクトリに配置します。

```
./
├── example/
│  └── python/
│     ├── config.py
│     ├── requirements.txt
│     └── main.py
├── heroku.yml
└── herokuDockerfile
```

こちらも2つのファイルを追加します。

### Dockerfile

ライブラリのインストールと、起動コマンドの設定を行います。
herokuアプリに設定した環境変数をよしなにコンテナに設定してくれるようです。
`PYTHONDONTWRITEBYTECODE`の意義については、[こちら](https://docs.python.org/ja/3/using/cmdline.html#envvar-PYTHONDONTWRITEBYTECODE)を参照ください。
なお、`PYTHONUNBUFFERED`の設定を行っているDockerfileをみかけますが、Python3.7以降デフォルト設定となっているため、3.7以降であれば明示する必要性はありません。

```docker:herokuDockerfile
FROM python:3.8-alpine

ENV PYTHONDONTWRITEBYTECODE 1
COPY example/python /usr/local/application
RUN pip3 install -r /usr/local/application/requirements.txt && \
  rm -f /usr/local/application/requirements.txt

WORKDIR /usr/local/application
CMD ["gunicorn", "main:app", "-c", "config.py"]
```

### デプロイ設定ファイル

Git側での`Procfile`と同様です。
`setup` `build` `release` `run`の4ステップを設定することが可能ですが、`build`ステップで対象のDockerfileを指定することができていれば起動するでしょう。
ちなみに`run`ステップを記述すると、CMDを上書きすることができます。

```yaml:heroku.yml
build:
  docker:
    web: herokuDockerfile
```

### デプロイ/動作確認

Gitデプロイと同様です。

# 参考

- [Heroku CLIコマンド](https://devcenter.heroku.com/articles/heroku-cli-commands)
- [ymlを使用しデプロイ](https://devcenter.heroku.com/articles/build-docker-images-heroku-yml)
- [環境変数の設定](https://devcenter.heroku.com/articles/config-vars)