Flask 관리자 서버를 Docker 한 장에 담기
작성
업로드된 이미지를 AI 로 분류한 결과를 사람이 검수하는 관리자 화면이다. Flask 가 화면을 그리고, Node 스크립트가 Pub/Sub 을 받아 처리하고, MongoDB 가 세션과 데이터를 담는다. 셋을 컨테이너 하나에 넣어 서버 한 대에서 돌렸다. 비밀값은 $$$ 로 지워 두었다.
구조
server/
Dockerfile.base # ubuntu 22.04 + Python 3.12 + Node 20 + MongoDB 7
Dockerfile # base 위에 앱만 얹는다
run.sh run-dev.sh # 컨테이너 안에서 mongod, gunicorn, node 를 차례로 띄운다
run-local.sh # 컨테이너 없이 venv 로 띄운다
redeploy.sh # 서버에서 빌드하고 교체한다
requirements.txt # flask flask-session pymongo gunicorn certifi
package.json # Node 쪽: mongodb, firebase-admin, pubsub, LLM SDK
flask_app/
__init__.py # 앱 생성, 세션 설정, Jinja 필터 등록
app.py # 라우트
db_service.py # Mongo 조회
html_utils.py # nl2br, escape_html, js_escape
templates/ # login.html, admin.html, macros.html
static/index.js
src/app.js # Node: Controller.init() 뒤 Pub/Sub 구독
Flask 쪽
앱 만들기와 세션
from flask import Flask
from flask_session import Session
from pymongo import MongoClient
from .html_utils import nl2br, escape_html, js_escape
app = Flask(__name__)
app.secret_key = '$$$'
app.config['SESSION_TYPE'] = 'mongodb'
app.config['SESSION_MONGODB'] = MongoClient('mongodb://localhost:27017/')
app.config['SESSION_MONGODB_DB'] = '$$$'
app.config['SESSION_MONGODB_COLLECT'] = 'users'
app.jinja_env.filters['nl2br'] = nl2br
app.jinja_env.filters['escape_html'] = escape_html
app.jinja_env.filters['js_escape'] = js_escape
Session(app)
from .app import * # 라우트를 뒤에서 불러온다
세션은 flask-session 으로 같은 컨테이너의 MongoDB 에 둔다. 템플릿 안에서 본문을 JS 문자열에 끼워 넣어야 해서 js_escape 필터를 직접 만들었다.
로그인과 데코레이터
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
user = authenticate_user(request.form['username'], request.form['password'])
if user:
session['user_id'] = str(user['_id'])
return redirect(url_for('admin'))
return 'Invalid username or password'
return render_template('login.html')
필터를 Mongo 쿼리로 바꾸기
URL 의 filter_type 과 모달에서 고른 체크박스를 합쳐 쿼리 하나를 만든다.
def build_query(filter_type, selected_filters):
query = {'updatedAt': {'$exists': True}, 'content': {'$exists': True}}
filter_type_queries = {
'female': {'gender': 'female', 'score': {"$gte": 60}},
'possibly': {'processed': 'possibly'},
'retry': {'processed': 'retry'},
'search': {'fbUid': request.args.get('fbUid')},
}
if filter_type in filter_type_queries:
query.update(filter_type_queries[filter_type])
if selected_filters:
gender, good_score, age_filters, processed = selected_filters
if gender:
query['gender'] = {"$in": gender}
if good_score:
query['score'] = {"$gte": int(good_score)}
if age_filters:
if 'etc' in age_filters:
top_10 = [item['_id'] for item in get_age_fields()]
query['age'] = {"$nin": top_10}
else:
query['age'] = {"$in": age_filters}
if processed:
query['processed'] = {"$in": processed}
return query
나이 필터의 "etc" 는 상위 10개 나이를 뺀 나머지다. 상위 10개는 집계로 구한다.
def get_age_fields():
top_10 = list(store_face.aggregate([
{'$group': {'_id': '$age', 'count': {'$sum': 1}}},
{'$sort': {'count': -1}},
{'$limit': 10}
]))
other_ages_count = list(store_face.aggregate([
{'$group': {'_id': '$age', 'count': {'$sum': 1}}},
{'$match': {'_id': {'$nin': [item['_id'] for item in top_10]}}},
{'$group': {'_id': 'etc', 'count': {'$sum': '$count'}}}
]))
if other_ages_count:
top_10.append(other_ages_count[-1])
return top_10
화면
admin.html 은 서버에서 데이터를 Jinja 로 JS 배열에 찍어 두고 grid.js 로 표를 그린다. 검색과 정렬, 100개 단위 페이지는 grid.js 가 맡는다. 썸네일에 마우스를 올리면 원본을 가운데에 띄우고, 본문 셀에 올리면 커서를 따라다니는 팝업을 띄운다. 커서가 화면 아래 절반에 있으면 팝업을 위로 올린다.
이미지 한 장에 다 넣기
base 이미지
OS 패키지 설치는 오래 걸리므로 base 이미지를 따로 만들어 두고 앱은 그 위에 얹는다.
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y \
software-properties-common curl gnupg lsb-release
# tzdata 를 KST 로
RUN echo 'tzdata tzdata/Areas select Asia' | debconf-set-selections && \
echo 'tzdata tzdata/Zones/Asia select Seoul' | debconf-set-selections && \
apt-get install -y tzdata
RUN add-apt-repository ppa:deadsnakes/ppa && \
apt-get update && \
apt-get install -y python3.12 python3.12-venv python3.12-dev python3-pip
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
apt-get install -y nodejs
RUN curl -fsSL https://www.mongodb.org/static/pgp/server-7.0.asc | apt-key add - && \
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu $(lsb_release -cs)/mongodb-org/7.0 multiverse" \
| tee /etc/apt/sources.list.d/mongodb-org-7.0.list && \
apt-get update && \
apt-get install -y mongodb-org
RUN mkdir -p /data/db
docker build -t base_ubuntu_python312_node20_mongodb7 -f Dockerfile.base .
앱 이미지
FROM base_ubuntu_python312_node20_mongodb7:latest
WORKDIR /work
COPY package.json requirements.txt /work/
RUN npm install \
&& pip install -r requirements.txt
COPY flask_app /work/flask_app
COPY src /work/src
COPY run.sh /work/
RUN chmod +x run.sh
CMD ["/work/run.sh"]
.dockerignore 에 node_modules 와 Dockerfile 두 개를 적어 둔다.
컨테이너 안에서 셋 띄우기
#!/bin/bash
export FLASK_ENV=prod
mongod --fork --logpath /var/log/mongodb.log
sleep 5
gunicorn -D -w 4 -b 0.0.0.0:8001 --log-file /var/log/gunicorn.error.log 'flask_app:app'
npm run start
mongod 와 gunicorn 은 데몬으로 띄우고, 마지막 npm run start 가 포그라운드로 남아 컨테이너를 붙잡는다. dev 는 FLASK_ENV=dev, 80 포트, npm run dev 만 다르다.
서버에서 교체하기
IMAGE_NAME="ai_watcher_image"
DATE_TAG=$(date +"%Y%m%d_%H%M%S")
DB_VOLUME="/home/$$$/work/db:/data/db"
LOG_VOLUME="/home/$$$/work/log:/var/log"
docker stop $IMAGE_NAME || true
docker build -t $IMAGE_NAME:$DATE_TAG .
if [ $? -ne 0 ]; then
echo "Docker build failed. Exiting..."
exit 1
fi
docker run -d --rm --name $IMAGE_NAME --network host \
-v $DB_VOLUME -v $LOG_VOLUME $IMAGE_NAME:$DATE_TAG
echo "Build and run at: $(date)" >> logs/build.log
echo "Image tag: $DATE_TAG" >> logs/build.log
docker logs -f $IMAGE_NAME &> logs/containers/${DATE_TAG}.log &
docker ps -a
- 이미지 태그를 날짜로 붙여 되돌릴 수 있게 한다.
--network host라 포트 매핑이 없다.- DB 와 로그는 호스트 볼륨에 두어 컨테이너를 갈아 끼워도 남는다.
docker logs -f를 백그라운드로 파일에 받아 둔다.
컨테이너 없이 로컬에서
run-local.sh 는 venv 를 만들고 mongod 를 띄운 뒤 관리자 계정을 한 번 넣고 gunicorn 을 8001 포트에 띄운다. trap cleanup_services EXIT 로 스크립트가 끝나면 gunicorn 을 죽이고 mongosh 로 db.shutdownServer() 를 부른다.
insert_document() {
sleep 2
mongosh "localhost:27017/$DB_NAME" --eval '
if (!db.users.findOne({"username": "$$$"})) {
db.users.insert({"username": "$$$", "password": "$$$"});
print("✅ Successfully inserted user");
} else {
print("✅ User already exists");
}
'
}
처음 세팅할 때 적어 둔 것
# 인스턴스
sudo apt-get update
sudo apt-get install git mongodb mongodb-clients docker.io
# docker 를 sudo 없이
sudo groupadd docker
sudo usermod -aG docker $USER
newgrp docker
# 컨테이너 안에서 포트 확인
docker exec -it ai_watcher_image bash
sudo apt install net-tools
netstat -tulpn | grep :80
kill -9 `pgrep -f gunicorn`
gunicorn 명령 자체는 gunicorn 노트에 있다.