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月中旬までは動かすかも
目次
進め方
以下流れで進めていきます。AWS公式ハンズオンと途中(コンテナイメージを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
もちろん、まだ何も作っていないので何も表示されない。
では、先程のファイル/フォルダ類を格納したディレクトリで、以下を実行する。
→うまくいけば、コンテナイメージが作成される。
$ docker build -t chat-app .
詰まったところ
docker ビルドの時に、no space left on deviceが出て困りました。
以下記事を参考にして、Cloud9のディスクボリューム(EBS)を拡張する処理を入れて対処しています。感謝です。
zenn.dev
dev.classmethod.jp
上手くいけば、以下コードを実行すれば作成したコンテナイメージの一覧に追加されているはずです。
$ docker images
ECRにアップロード
ECRの作成
AWS Cloud9の環境上で作成したコンテナイメージをECRにアップロードしていく
ECRのサービスを選択

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

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


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

コンテナイメージをリポジトリにアップロード(push)するために、docker login が必要なので、コピーしてきた認証トークンコマンドを実行します。
その後、AWS Cloud9 上のイメージをアップロード(push)します。docker push コマンドを利用します。
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を確認して、コンテナイメージがアップロードされていれば完了です。



