まるっとワーク

データ分析・電子工作など気になることを残していきます

Cloud9でコンテナを作成、ECR + App Runnerでデプロイ(Python Flaskアプリ)

今まで、WEBアプリをデプロイするときは、Githubにコードをアップロードして、GCP Cloud RunやAWS App Runnerサービスを用いて、Githubから直接デプロイ(サービス内でコンテナ化)をしていました。ただ、仕事で使うアプリなどは、コード自体もあまり晒したくないし、デプロイも閉じられた環境で実施したいという考えもあり、Githubにコードをアップロードすることは避けて、アプリデプロイまでを実施してみました。
今回は、AWSのサービスを使っており、アプリのコンテナ化はAWS Cloud9/コンテナイメージはAmazon ECRにアップロード/AWS App Runnerでデプロイするという流れで実施をしています。
App Runnerを使う場合、閉じられた環境下でアプリのデプロイができない為、本当は、ECSやFargateを使いたいですが・・・それは次のステップでやります。

↓システム構成は以下のような感じ


↓できたアプリはこんな感じ(Gemini APIを使ったLLMとの会話アプリ)

アプリリンク
https://66updbv3bw.ap-northeast-1.awsapprunner.com/5月中旬までは動かすかも


目次


開発環境

[ローカル]
Microsoft Windows 10 Pro
Python 3.11.1
[AWS]
AWS Cloud9
Amazon ECR
AWS App Runner

進め方

下流れで進めていきます。AWS公式ハンズオンと途中(コンテナイメージをAmazon ECRに保存)まで同じなので、そちらの方が詳しいかもしれません。

  • 作業環境の作成(AWS Cloud9)
  • コード作成
  • dockerでコンテナ化
  • コンテナイメージをAmazon ECRに保存
  • AWS App RunnerでAmazon ECRのコンテナをデプロイ


AWS公式ハンズオン(Amazon ECS 入門ハンズオン)
Amazon ECS 入門ハンズオン

実装

作業環境の作成

AWS公式ハンズオンに詳細が記載されている個所は簡単に説明していきます。

AWS Cloud9 環境設定

名前などは、任意につけて問題ありません。


後ほど説明をしますが、docker ビルドの時に、no space left on deviceが出て困ります・・・Cloud9のディスクボリューム(EBS)を拡張する処理を入れて対処しています。

設定をして、作成ボタンを押すと以下画像のように起動中画面に移ります。

設定確認

一応pythonやdockerのバージョンを確認しておきます。

$ docker version

Client:
Version:           20.10.25
API version:       1.41
Go version:        go1.20.12
Git commit:        b82b9f3
Built:             Fri Dec 29 20:37:18 2023
OS/Arch:           linux/amd64
Context:           default
Experimental:      true

Server:
Engine:
Version:          20.10.25
API version:      1.41 (minimum version 1.12)
Go version:       go1.20.12
Git commit:       5df983c
Built:            Fri Dec 29 20:38:05 2023
OS/Arch:          linux/amd64
Experimental:     false
containerd:
Version:          1.7.11
GitCommit:        64b8a811b07ba6288238eefc14d898ee0b5b99ba
runc:
Version:          1.1.11
GitCommit:        4bccb38cc9cf198d52bebf2b3a90cd14e7af8c06
docker-init:
Version:          0.19.0
GitCommit:        de40ad0

$ python -V

Python 3.8.6

$  curl http://checkip.amazonaws.com

<AWS Cloud9 環境の Public IP アドレス>←環境毎に違う値が出る

python 3.8.6だと、後で出てくるpythonコードのlangchain_google_genaiライブラリが対応していないので、python3.9をインストールします。

pythonバージョンを変える(インストール)

以下記事を参考に実施しています。感謝です
codeseterpie.com

Ckoud9のターミナルを開き、以下コマンドを実行

# インストール先フォルダに移動
cd /opt

# インストールに必要なライブラリをインストール
sudo yum install -y bzip2-devel libffi-devel

# URLからファイルをダウンロード
sudo wget https://www.python.org/ftp/python/3.9.13/Python-3.9.13.tgz

# ファイルを解凍
sudo tar xzf Python-3.9.13.tgz

# パフォーマンスを上げるため、最適化を実施
cd Python-3.9.13
sudo ./configure --enable-optimizations

# 既存のpython、python3を置き換えるシンボリックリンクを作成しないよう、
# altinstallを指定してインストール
sudo make altinstall

# 圧縮ファイルを削除
sudo rm -f /opt/Python-3.9.13.tgz

# 下のコマンドが実行できれば成功
python3.9 -V

必要なライブラリのインストール

一応Cloud9環境で一度動作確認をすることを考えて必要なライブラリをインストールしています。

# プロジェクトフォルダへ移動
cd ~/environment/{プロジェクトフォルダ}

# venvの仮想環境を構築
python3.9 -m venv .venv

# 仮想環境を有効化
source .venv/bin/activate

# 仮想環境のpipをアップデート
python -m pip install --upgrade pip

# 必要なライブラリをインストール
pip install langchain flask flask_socketio langchain_google_genai

これで開発環境が整いました!

コード作成

Google Geimini API及びLangChain、Flaskを用いて、LLMと会話ができるアプリを作りました。
フロントエンド側の言語は、、、知識がなく、Flaskを用いて、何とかおしゃれに仕上がるように調整をしたつもりです。

ファイルの構成

│─ Dockerfile
│─ requirements.txt
│─ main.py (WEBアプリのコード)
└─ config/
   └─ config.py (Google API KEYを保管)
└─ template/
   └─ index.html 
└─ static/css
   └─ style.css 

コードはGithubで公開しています。
github.com

一応それぞれのコードやファイルの中身を以下にも共有しておきます。

pythonコード(WEBアプリのコード)
from flask import Flask, render_template, session
from flask_socketio import SocketIO, emit
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain.chains import ConversationChain
from langchain.memory import ConversationBufferMemory
from langchain.schema import HumanMessage
from langchain.cache import InMemoryCache
import os

app = Flask(__name__)
app.config['SECRET_KEY'] = "your-secret-key"  # このキーはセキュアなものに置き換えてください
app.config.from_object('config.config')
socketio = SocketIO(app)

# LangChain and memory setup
langchain_cache = InMemoryCache()
memory = ConversationBufferMemory(return_messages=True, k=5)
llm = ChatGoogleGenerativeAI(model="gemini-pro", google_api_key=app.config.get('GOOGLE_API_KEY'))

chain = ConversationChain(
    llm=llm,
    memory=memory
)

def init_chat():
    if 'chat_history' not in session:
        session['chat_history'] = []

@app.route('/')
def index():
    init_chat()  # Initialize chat history
    return render_template('index.html')

@socketio.on('message')
def handle_message(data):
    init_chat()
    user_input = data['text']
    user_message = HumanMessage(content=user_input, role="user")
    session['chat_history'].append(user_message)

    # Generate reply using LangChain
    response = chain(user_input)
    reply = response["response"]
    assistant_message = HumanMessage(content=reply, role="assistant")
    session['chat_history'].append(assistant_message)

    # Emit reply to the client
    emit('reply', {'text': reply})

if __name__ == "__main__":
    app.run(debug=True, host="0.0.0.0", port=int(os.environ.get("PORT", 8080)))


LangChainを使ったLLMとのやり取りは他の方もよく使われているコードで、LLMへの入力結果やLLMからの回答が逐次表示されるように、flask_socketioを使っています。

GOOGLE API KEYを保存するためだけのconfig.pyファイルは以下記載になっています。

GOOGLE_API_KEY=自身のKEYを入れる


index.html / style.css
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Chat App - AI Bot</title>
    <script src="https://cdn.socket.io/4.0.0/socket.io.min.js"></script>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.9.4/lottie.min.js"></script>
    <link rel="stylesheet" href="static\css\style.css">
    
</head>
<body>
    <div id="header">
        <div id="lottie-animation"></div>
        <h1>AI Bot</h1>
    </div>
    <div id="chatroom"></div>
    <div id="send_area">
        <input type="text" id="user_input" autocomplete="off" placeholder="Type your message...">
        <button id="send_button" onclick="sendMessage()">Send</button>
        <div id="loading"></div>
    </div>

    <script>
        var mainAnimation = lottie.loadAnimation({
            container: document.getElementById('lottie-animation'),
            renderer: 'svg',
            loop: true,
            autoplay: true,
            path: 'https://lottie.host/dffb1e31-78af-4548-9e7c-30fd1cbbb704/lUvwHha1IZ.json'
        });

        var loadingAnimation = lottie.loadAnimation({
            container: document.getElementById('loading'),
            renderer: 'svg',
            loop: true,
            autoplay: false,
            path: 'https://lottie.host/26b8d518-e6b1-4287-aa67-b99aeafdf886/PPJT5dzezf.json'
        });

        var socket = io();

    socket.on('reply', function(data) {
        $('#chatroom').append($('<div>').text('Assistant: ' + data.text).addClass('message assistant'));
        $('#chatroom').scrollTop($('#chatroom')[0].scrollHeight);
        $('#loading').hide(); // アニメーションを非表示にする
        $('#send_button').show(); // ボタンを再表示
        loadingAnimation.stop(); // アニメーションを停止
    });

    function sendMessage() {
        var text = $('#user_input').val();
        socket.emit('message', { text: text });
        $('#chatroom').append($('<div>').text('You: ' + text).addClass('message you'));
        $('#user_input').val('');
        $('#chatroom').scrollTop($('#chatroom')[0].scrollHeight);
        $('#send_button').hide(); // ボタンを隠す
        $('#loading').show(); // アニメーションを表示
        loadingAnimation.play(); // アニメーションを開始
    }
    </script>
</body>
</html>


body { font-family: Arial, sans-serif; }
        #header { 
            background: #f5f5f5; 
            padding: 10px; 
            text-align: center; 
            border-bottom: 1px solid #ddd; 
            display: flex; 
            align-items: center; 
            justify-content: center; 
        }
        #lottie-animation {
            width: 150px;  /* アニメーションのサイズを調整 */
            height: 150px; /* アニメーションのサイズを調整 */
            margin-right: 50px; /* テキストとの間隔 */
        }
        #chatroom { 
            display: flex;
            flex-direction: column;
            align-items: flex-start; /* 左側にメッセージを表示 */
            height: 300px; 
            overflow-y: scroll; 
            border: 1px solid #ccc; 
            padding: 10px; 
            margin: 20px;
            background-color: #f7f7f7; /* 軽いグレーの背景色 */
            background-image: linear-gradient(to bottom, #ffffff, #f2f2f2); /* 上から下へのグラデーション */
            box-shadow: 0 2px 5px rgba(0,0,0,0.1); /* 箱の影を追加 */
            border-radius: 8px; /* 角を丸くする */
        }
        #send_area {
            display: flex;
            justify-content: space-between;
            padding: 10px;
            background-color: #f0f0f0;
            border-top: 1px solid #ccc;
            border-bottom-left-radius: 8px; /* 下の左角を丸くする */
            border-bottom-right-radius: 8px; /* 下の右角を丸くする */
        }
        #user_input {
            flex-grow: 1;
            margin-right: 10px;
            padding: 10px;
            border: 1px solid #ccc;
            border-radius: 15px;
            outline: none;
        }
        #send_button {
            padding: 10px 20px;
            background-color: #007bff;
            color: white;
            border: none;
            border-radius: 15px;
            cursor: pointer;
        }
        #send_button:hover {
            background-color: #0056b3;
        }
        #loading { 
            display: none; 
            width: 50px;
            height: 50px;
            margin-left: 10px;
        }
        .message {
            padding: 10px 20px;
            border-radius: 20px;
            margin: 10px;
            display: inline-block;
            max-width: 80%;
        }
        .you {
            background-color: #b0e0e6;
            align-self: flex-end;
        }
        .assistant {
            background-color: #f0e68c;
            align-self: flex-start;
        }

アプリの顔になるので、アニメーションを付けたり、ボタンを丸く縁取りしたりなど、、調整をしています。
LLMにメッセージを投げた際は、処理中を示したアニメーションを写したりしています。

Dockerfile
FROM python:3.11.1

WORKDIR /app

RUN python -m ensurepip
RUN python -m pip install --upgrade pip

COPY requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
COPY . .

ENV FLASK_APP main.py

ENV PORT 8080
EXPOSE 8080

CMD ["python", "main.py"]


dockerでコンテナイメージの作成

ローカルファイルをCloud9にアップロード

上で作成したファイル/フォルダ類をCloud9にアップロードします。
FileタブのUpload Local Fileでアップロードができる

dockerでコンテナイメージを作成

Cloud9環境の中で既に存在しているコンテナイメージの一覧を確認する。

$ docker images

REPOSITORY      TAG          IMAGE ID       CREATED         SIZE

もちろん、まだ何も作っていないので何も表示されない。

では、先程のファイル/フォルダ類を格納したディレクトリで、以下を実行する。
→うまくいけば、コンテナイメージが作成される。
>|memo|
$ docker build -t chat-app .
|

詰まったところ

docker ビルドの時に、no space left on deviceが出て困りました。
以下記事を参考にして、Cloud9のディスクボリューム(EBS)を拡張する処理を入れて対処しています。感謝です。
zenn.dev
dev.classmethod.jp

上手くいけば、以下コードを実行すれば作成したコンテナイメージの一覧に追加されているはずです。
>|memo|
$ docker images
|


ECRにアップロード

ECRの作成

AWS Cloud9の環境上で作成したコンテナイメージをECRにアップロードしていく
ECRのサービスを選択


リポジトリ作成ボタンを押して、リポジトリ名(任意の名前)を入力し、リポジトリ作成をクリックします。


リポジトリのURLと、リポジトリ詳細画面に載っているプッシュコマンドを確認する(後で使う)


ECRにアップロード

AWS Cloud9 上に作成したコンテナイメージを、作成したリポジトリにアップロードをしていきます。
AWS Cloud9 上のコンテナイメージを、作成したリポジトリに紐づけるために、リポジトリの名前を使った Tag の付与が必要で、以下のような構文でビルドします。
>|memo|
例: docker build -t ホスト名/イメージ名[:タグ名]
$ docker build -t <12桁数字は適宜変える>.dkr.ecr.ap-northeast-1.amazonaws.com/flask-helloworld .
|

コンテナイメージが追加されていることを確認します。


コンテナイメージをリポジトリにアップロード(push)するために、docker login が必要なので、コピーしてきた認証トークンコマンドを実行します。
その後、AWS Cloud9 上のイメージをアップロード(push)します。docker push コマンドを利用します。
>|memo|
aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin <12桁数字は適宜変える>.dkr.ecr.ap-northeast-1.amazonaws.com
docker push <12桁数字は適宜変える>dkr.ecr.ap-northeast-1.amazonaws.com/flask-helloworld .
|


ECRを確認して、コンテナイメージがアップロードされていれば完了です。

AWS App Runnerでデプロイ

AWS App Runnerの特徴などは以下がよくまとまっている
qiita.com

ECRの対象のコンテナの詳細を見るとイメージのURLがあるのでこれをコピーします。


続いて、AWS App Runnerのデプロイ設定画面で、コンテナレジストリ→コンテナイメージのURLに先程コピーしたURLを張ります。


その他は任意で設定し、ポートはFlask側で8080を指定しているので8080を入力してデプロイをします。


デプロイ完了後、エンドポイントのURLを叩いて、ちゃんと画面が出れば完了です!!

まとめ

今回は、AWS Cloud9で作成したコンテナをAmazon ECRに保存して、AWS App Runnerを使ってデプロイする手順についてまとめました。
次のステップとして、Amazon ECSやAWS Fargateを利用したより高度で閉じられた環境でのデプロイに挑戦したいと考えています。また、アプリケーションの機能を拡充し、よりインタラクティブなLLMチャットボットに発展させていきたいです。

ブログをご覧いただきありがとうございました。