Streamlit + Cloud Run + IAPで自分だけのChatGPTアプリをデプロイしてみた

ChatGPT APIを使ってちょっと便利なアプリケーションを開発すると、それを自分（またはクローズドな組織）用に使えるよう認証を掛けてデプロイしたくなります。今回はPythonで手軽にWebアプリケーションのデモを作れるStreamlitをCloud Runにデプロイし、Identity-Aware ProxyでGoogle認証を掛けてみました。

## 要件と利用技術
- 手軽に作れること
    - **[Streamlit](https://streamlit.io/)** を利用
    - Streamlitは特定のバージョン以降Cloud Runで動作しない問題がありましたが、[2023年1月ごろに対処されました](https://github.com/streamlit/streamlit/issues/3028)
- サーバレスで動作すること
    - **Google Cloud [Cloud Run](https://cloud.google.com/run?hl=ja)** を利用
- アクセス制限ができること
    - **Google Cloud [Identity\-Aware Proxy（IAP）](https://cloud.google.com/iap?hl=ja)** を利用
    - Cloud RunへのIAP対応は[2023年4月7日にGA](https://cloud.google.com/run/docs/release-notes#April_07_2023)しました
    - 予め、AまたはAAAAレコードを設定できるドメインを用意しておく必要があります

## 環境
- python = "^3.10"
- streamlit = "^1.21.0"
- openai = "^0.27.4"

細かいバージョンなどを示すすべてのコードは[GitHubにあります](https://github.com/s2terminal/chatgpt_summarizer)。

### Poetry + Docker Composeによる環境構築
開発環境はDocker Composeを用いてPoetryを使いました。以下のような`Dockerfile`と`compose.yml`で構築しました。マルチステージビルドの最適化などは割と中途半端なので、お好みで調整してください。

```Dockerfile:Dockerfile
FROM python:3.10-slim AS builder
WORKDIR /app
ENV PYTHONPATH="/app:$PYTHONPATH"

RUN apt-get update && apt-get install -y \
    build-essential \
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*

RUN pip install poetry
RUN poetry config virtualenvs.create false

COPY pyproject.toml ./
COPY poetry.lock ./
RUN poetry install --without dev

CMD ["poetry", "run", "streamlit", "run", "src/chatgpt_summarizer/main.py", "--server.port", "8080"]

FROM builder AS dev

RUN apt-get update && apt-get install -y \
    git \
    curl
RUN git config --global --add safe.directory /app

RUN poetry install

FROM builder AS prod
COPY ./src /app/src
```

```yml:compose.yml
services:
  app:
    build:
      context: .
      target: dev
    volumes:
      - .:/app
    env_file:
      - .env
    ports:
      - 8080:8080
```

OpenAIのAPIキーを発行して`$ echo 'OPENAI_API_KEY=XXXXXXXX' > .env`しておきます。

## 実装
### StreamlitでChatGPTのインタラクティブなチャットUIを実装する
まずは自分用に使うChatGPTアプリケーションを実装します。ここでは例として普通のチャットを実装しますが、お好みで様々な機能を実装してください。

![2023-04-23_chatgpt_streamlit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/5237d08b-be6f-4670-3032-cac35907b68b.gif)

StreamlitでChatGPTへの入力+出力を1回ずつ行うだけのアプリであれば簡単です。ですが普通のChatGPTのように可変長複数回のやりとりからなるチャットは、Streamlit上で無限ループを掛けながらUIを良い感じに生成する必要があるのと、ループ時に過去実行した箇所でChatGPT APIを毎回叩かないようにする点に注意が必要です。
[session_stateを使う方法](https://levelup.gitconnected.com/create-your-chat-gpt-3-web-app-with-streamlit-in-python-f0c6e6aede0a)はよく見かけましたが、ここではStreamlitのCache機能を使って実装してみました。

`stream_write()`関数については拙稿の[StreamlitでChatGPTのStreamを表示する](https://qiita.com/suzuki_sh/items/64d84c417cba43cd6351)も参考にしてください。

```python
import openai
import streamlit

# @see: https://qiita.com/suzuki_sh/items/64d84c417cba43cd6351
def stream_write(chunks, key=None):
    result_area = streamlit.empty()
    text = ''
    for chunk in chunks:
        next: str = chunk['choices'][0]['delta'].get('content', '') # type: ignore
        text += next
        if "。" in next:
            text += "\n"
        result_area.write(text, key=key)
    return text

@streamlit.cache_data
def cached_chat(messages):
    completion = openai.ChatCompletion.create(
        model='gpt-3.5-turbo',
        messages=messages,
        stream=True,
    )
    text = stream_write(completion, key=f'output_{messages}')
    return text

messages = []
chat_widget = streamlit.empty()

while True:
    with chat_widget.container():
        for message in messages:
            streamlit.write(message['content'])
        input_text = streamlit.text_input('入力', key=f'input_{messages}')
        if len(input_text) == 0:
            streamlit.stop()
        messages.append({"role": "user", "content": input_text})
        text = cached_chat(messages)
        messages.append({"role": "assistant", "content": text})
```

### StreamlitアプリをCloud Runにデプロイする
Google Cloudの利用準備はできているものとします。開発環境にDockerを使っていれば、Cloud Runにデプロイすることは簡単です。ビルドしたイメージを指定して`gcloud run deploy`するだけです。

```bash
$ gcloud run deploy $SERVICE \
      --image $IMAGE \
      --project $PROJECT \
      --region asia-northeast1 \
      --ingress internal-and-cloud-load-balancing \
      --max-instances=1 \
      --allow-unauthenticated \
      --set-env-vars "OPENAI_API_KEY=$OPENAI_API_KEY" \
      --platform=managed
```

IAPを使う上で、以下が必要な点に注意してください。他はお好みで設定してください。

- 上り（内向き）を内部および Cloud Load Balancing に設定するために`--ingress internal-and-cloud-load-balancing`
- allUsers に起動元ロールを付与するために`--allow-unauthenticated`

デプロイが無事に完了し、発行されたURLを直接アクセスしてみて403 Forbiddenが表示されたら成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/478b3a2b-9f5c-11c8-0da2-873088437719.png)


### Cloud RunでデプロイしたアプリにIAPを付ける
基本的には[Google Cloudの公式ドキュメント](https://cloud.google.com/iap/docs/enabling-cloud-run?hl=ja)に沿って設定していきますが、既にGAしたはずなのに2023-04-23時点ではまだ「プレビュー」と書いてあったりとやや頼りないので、要点に絞って説明します。

Cloud RunでデプロイしたアプリにIAPを付けるには、HTTPS ロードバランサが必要です。以下のように設定します。

- ロードバランサ
    - 「HTTP(S) ロード バランシング」を構成します
- フロントエンド
    - プロトコルはHTTPS
    - 今回IPv6を選択しましたが、IPv4でも行けると思います。後にDNSにレコードを登録する時、IPv4にしたならAレコード、IPv6ならAAAAレコードを設定することになります。
- 証明書
    - ここでドメインを設定してください
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/c77163da-aaad-c8c6-33ab-4c431eeff298.png)
- バックエンドサービス
    - CDNを有効にしていると後にIAPを有効にするときにエラーが出るため、無効にしてください
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/2191e843-24ac-80fe-6349-9a4d4f3b50bd.png)
- エンドポイントグループ
    - デプロイしたCloud Runサービスをここで設定します
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/c17beb5d-c763-6572-3224-136b4ae5538e.png)

ロードバランサがデプロイされるまでは結構時間がかかるので、待ちます。IPアドレスが発行されたら、DNSにAまたはAAAAレコードを設定しておきます。

ロードバランサが構成されたらIAP画面に移動し、先ほどのロードバランサを選択してIAPを有効にします。設定に不備があるとここでエラーが出ます。

IAPを有効にしたら、画面右側の「プリンシパルを追加」から、許可したいユーザを設定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/2584a8e4-47d8-02e6-1ecd-0096500aa5f0.png)

今回私は自分用のGoogle Workspace ドメインを持っているのでそちらを指定しましたが、個人利用であればGmailアカウントを指定する感じが一般的だと思います。

ロールには「IAP-secured Web App User」を設定します。

設定したドメインにアクセスし、以下のような感じのGoogleログイン画面が表示されたら成功です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50391/dd369399-9fa6-e9af-7b31-d5953f4efb29.png)

ログインして、構築したStreamlitアプリが使えるか確かめてみましょう。

## おわりに
Streamlitを使ってChatGPTアプリを開発し、Cloud Run IAPで自分だけがアクセスできるようにしてみました。Cloud Run自体は自分だけのアプリをデプロイして使うなら料金も安くて手軽に使える仕組みですが、IAPのためにロードバランサを用意しなければいけない点が惜しいなと思いました。

## 参考
- [Cloud Run での IAP の有効化  \|  Identity\-Aware Proxy  \|  Google Cloud](https://cloud.google.com/iap/docs/enabling-cloud-run?hl=ja)
- [Cloud Run で Identity\-Aware Proxy \(IAP\) を使う](https://zenn.dev/ww24/articles/19099c85febe0d)