노트/Python

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 노트에 있다.