Skip to content
Guides10 min read

서버에 Potato 배포하기

nginx와 TLS 뒤에서 Docker 또는 VM으로 Potato를 프로덕션에서 운영하는 방법과, Potato 서버를 여러 워커가 아닌 단일 프로세스로 실행하는 이유를 설명합니다.

Potato Team

프로젝트마다 Potato 프로세스를 하나만 실행하고, 그 앞에 nginx와 TLS 인증서를 두고, 동시 처리량이 더 필요하면 워커 프로세스가 아니라 스레드를 늘리십시오. 워커 프로세스를 하나 더 두면 주석이 아무 경고 없이 사라집니다.

Potato는 항목 풀, 할당 큐, 모든 주석자의 상태를 서버 프로세스 안의 메모리에 보관합니다. 배포에 관한 결정은 대부분 이 한 가지 사실로 정해집니다. 상태가 프로세스 안에 있기 때문에 Potato 서버는 워커가 아닌 스레드로 확장되고, 백업해야 할 대상은 주석 출력 디렉터리이며, 여러 Potato 프로세스에 부하를 분산하면 작업이 빨라지기는커녕 연구 데이터가 손상됩니다. 이 가이드는 서버를 실행하는 세 가지 방법, 그 앞에 두는 리버스 프록시와 TLS, 그리고 실제 주석자가 들어온 뒤에 중요해지는 구성 키를 다룹니다.

Potato 서버가 단일 프로세스로 실행되는 이유

두 번째 gunicorn 워커는 항목 풀, 할당 큐, 사용자 상태의 사본을 따로 갖습니다. 이 워커는 첫 번째 워커가 이미 할당한 인스턴스를 다시 나눠 줍니다. 또한 user_state.json은 저장할 때마다 전체가 다시 쓰이므로, 마지막으로 저장한 워커가 다른 워커의 주석을 지워 버립니다. 로그에는 이 사실이 전혀 기록되지 않습니다. 주석은 사라지고, 대개 처음 알게 되는 계기는 라벨을 붙였던 항목이 손대지 않은 것처럼 보인다는 주석자의 말입니다.

Potato가 배포하는 Docker 이미지는 이 제약을 강제합니다. gunicorn 워커를 하나만 실행하고 그보다 많으면 시작을 거부하며, POTATO_ALLOW_MULTIWORKER=1은 실제 연구가 아닌 용도에 한해 이 거부를 해제합니다. 동시 처리는 대신 스레드로 확보하며, 스레드는 상태의 단일 사본을 공유합니다. 이미지는 GUNICORN_THREADS를 통해 기본적으로 8개의 스레드를 사용하며, 일반적인 연구에서 동시에 작업하는 수십 명의 주석자를 감당합니다.

이 제약이 이 가이드의 나머지 내용을 규정합니다. 프로젝트마다 프로세스 하나를 실행하고, 데이터셋에 충분한 메모리를 주고, 리버스 프록시는 부하 분산이 아니라 TLS와 정적 파일 제공에 사용하십시오.

서버를 실행하는 세 가지 방법

방법제공하는 것사용 시점
potato deploy구성 파일로부터 호스트, TLS, 이미지를 준비오늘 바로 실행하려는 연구
Docker 이미지매번 동일한 바이트, 의존성 빌드 불필요이미 관리하고 있는 머신
VM과 systemd호스트와 Python 환경을 완전히 제어자체 규정이 있는 기관 서버

빠른 경로인 potato deploy

potato deploy는 이미 가지고 있는 구성 파일로 호스트를 준비하고, 이미지를 설치하고, TLS를 구성합니다.

bash
potato deploy up myproject/config.yaml --provider digitalocean

몇 분 뒤면 주석자에게 보낼 HTTPS URL이 생기고, potato deploy pull로 주석을 내려받을 수 있습니다. 2.10.0부터 대상은 local, potato share, 그리고 AWS, Jetstream2, Hetzner, Fly, Railway를 포함한 13개의 클라우드 호스트이며, 재시작 후에도 데이터가 남는지가 서로 다릅니다. 일곱 개의 VM 대상은 영구 디스크, 로그 스트리밍, SSH를 모두 갖추고 있으며, 디스크를 지우는 호스트에는 주석을 HuggingFace 데이터셋이나 S3 버킷에 복사하는 --backup이 필요합니다. Potato를 로컬 또는 클라우드에서 실행하기에서 이들을 비교합니다. local부터 시작하십시오. 계정도 요금도 없이 같은 번들과 같은 이미지를 실행하며, 비용이 들기 전에 무엇이 배포될지 알려 줍니다.

배포된 Docker 이미지

Potato는 미리 빌드한 이미지를 GitHub Container Registry에 배포합니다. 이미지에는 서버와 그 의존성이 들어 있고 프로젝트는 실행 시점에 /app에 마운트되므로, 하나의 이미지로 모든 작업을 처리할 수 있습니다.

bash
docker run -p 8000:7860 \
  -v "$PWD/myproject:/app" \
  -e POTATO_SECRET_KEY="$(openssl rand -hex 32)" \
  ghcr.io/davidjurgens/potato:latest

이 이미지가 존재하는 이유는 Potato의 핵심 의존성에 numpy, pandas, scipy, scikit-learn이 포함되기 때문입니다. 이것들을 작은 VM에 설치하면 잘 되어도 느립니다. 1 GB 머신에서는 메모리 부족으로 빌드가 강제 종료되고, 2 GB에서는 재배포할 때마다 5~10분이 걸립니다. 이미지를 pull하는 데는 1분도 걸리지 않습니다.

중요한 태그는 두 가지입니다. latest는 핵심 의존성을 담고 약 840 MB이며, latest-all은 AI SDK, 문서 형식, 내보내기, OAuth 로그인을 추가해 약 1.5 GB입니다. 둘 다 linux/amd64와 linux/arm64용으로 빌드됩니다. latest는 모르는 사이에 바뀌므로, 몇 달간 진행될 연구에서는 latest 대신 버전을 고정하십시오.

한 번 실행하고 끝나지 않는 환경에는 POTATO_SECRET_KEY를 설정하십시오. 설정하지 않으면 Flask 세션 서명 키가 프로세스마다 무작위로 정해져, 재시작할 때마다 모든 주석자가 로그아웃됩니다. POTATO_CONFIG는 컨테이너 안의 구성 파일 경로를 지정하고, PORT는 수신 포트(기본값 7860)를, GUNICORN_TIMEOUT은 워커 타임아웃을 초 단위로 설정합니다.

이미지는 uid 1000으로 실행되고 바인드 마운트는 호스트의 소유권을 그대로 유지하므로, Linux에서는 직접 만든 디렉터리에 컨테이너가 쓰지 못하는 경우가 많습니다. 서버는 시작할 때 이를 확인하고, 부팅 도중에 멈추는 대신 실행을 거부합니다. sudo chown -R 1000:1000 myproject로 디렉터리를 컨테이너 사용자에게 넘기거나, docker run --user "$(id -u):$(id -g)"로 본인 계정으로 실행하십시오. 후자는 주석 파일의 소유자도 본인으로 유지합니다. macOS와 Windows의 Docker Desktop은 바인드 마운트의 소유권을 무시하므로, Mac에서 동작하는 구성이 Linux 서버에서는 실패할 수 있습니다.

VM에 설치하기

직접 관리하는 머신에서는 Potato를 가상 환경에 설치하고 프로세스 관리는 systemd에 맡깁니다.

bash
sudo apt update
sudo apt install python3 python3-venv python3-pip nginx certbot python3-certbot-nginx
 
sudo mkdir -p /opt/potato/projects
cd /opt/potato
python3 -m venv venv
source venv/bin/activate
pip install potato-annotation gunicorn
potato --version

gunicorn에서 워커 하나와 여러 스레드로 앱을 실행합니다. 애플리케이션 팩토리는 potato.flask_server:create_app에 있으며 구성 파일 경로를 인자로 받습니다.

bash
gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
  --chdir /opt/potato/projects/my_project \
  "potato.flask_server:create_app('config.yaml')"

재부팅이나 충돌 후에 서버가 다시 올라오도록 /etc/systemd/system/potato.service를 만듭니다.

ini
[Unit]
Description=Potato Annotation Server
After=network.target
 
[Service]
Type=simple
User=potato
Group=potato
WorkingDirectory=/opt/potato/projects/my_project
Environment="PATH=/opt/potato/venv/bin"
EnvironmentFile=/opt/potato/.env
ExecStart=/opt/potato/venv/bin/gunicorn -w 1 --threads 8 -b 127.0.0.1:8000 \
  "potato.flask_server:create_app('config.yaml')"
Restart=always
RestartSec=10
 
[Install]
WantedBy=multi-user.target

활성화하고 시작합니다.

bash
sudo systemctl enable potato
sudo systemctl start potato
sudo systemctl status potato

EnvironmentFile에는 구성 파일에 넣으면 안 되는 비밀 값을 둡니다.

bash
# /opt/potato/.env
POTATO_SECRET_KEY=a-long-random-value
OPENAI_API_KEY=sk-...

비밀 키는 openssl rand -hex 32로 한 번만 생성하고 연구 기간 내내 유지하십시오. 키를 바꾸면 모든 세션이 무효화되어 주석자 전원이 로그아웃됩니다.

nginx와 TLS

Potato는 localhost에 바인딩하고, 그 앞에서 nginx가 TLS를 종료합니다. 이미지와 오디오 프로젝트에서는 업로드 크기 제한이 중요합니다. 기본값인 1 MB 본문 크기 제한은 미디어를 거부하며, 주석자는 그 실패를 보지 못합니다.

nginx
server {
    listen 443 ssl;
    http2 on;
    server_name annotation.example.com;
 
    ssl_certificate /etc/letsencrypt/live/annotation.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/annotation.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers off;
 
    add_header Strict-Transport-Security "max-age=63072000" always;
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
 
    client_max_body_size 100M;
 
    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

사이트를 활성화하고 인증서를 발급받습니다.

bash
sudo ln -s /etc/nginx/sites-available/potato /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
sudo certbot --nginx -d annotation.example.com
sudo systemctl enable certbot.timer

프록시를 앞에 둔 뒤에는 Potato 포트에 직접 접근할 수 없어야 하므로, 방화벽에서 나머지를 모두 닫습니다.

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw allow 'Nginx Full'
sudo ufw enable

하나의 호스트 이름으로 여러 프로젝트 제공하기

기관에서는 경로를 로컬 포트에 매핑해 여러 Potato 서버를 하나의 공개 HTTPS 엔드포인트로 노출하는 경우가 많습니다. 주석 UI는 루트 기준 상대 URL로 에셋을 요청하고 저장하므로, /app1에 마운트된 서버는 자신의 접두사를 알아야 합니다. 그렇지 않으면 CSS가 404가 되고 자동 저장이 "annotations not saved"와 함께 실패합니다. Potato는 WSGI SCRIPT_NAME에서 접두사를 읽으며, 이를 설정하는 방법은 두 가지입니다.

프록시를 직접 관리하고 X-Forwarded-Prefix를 보낼 수 있다면 POTATO_PROXY_FIX=1을 사용합니다. 프록시 구성을 바꿀 권한이 없다면 POTATO_URL_PREFIX=/app1을 사용합니다. 어느 경우든 Flask가 계속 접두사 없는 경로를 받도록, 프록시는 전달하기 전에 접두사를 제거해야 합니다.

nginx
location /app1/ {
    proxy_pass         http://127.0.0.1:8000/;
    proxy_set_header   Host              $host;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   X-Forwarded-Prefix /app1;
}

ProxyFix는 전달된 헤더를 신뢰하므로, 앱이 신뢰할 수 있는 프록시를 통해서만 접근 가능할 때에만 POTATO_PROXY_FIX를 켜십시오. 내부 포트에도 직접 접근할 수 있으면 클라이언트가 접두사를 위조해 Potato가 생성하는 URL을 오염시킬 수 있습니다.

프로덕션 프로젝트에 필요한 구성

프로덕션 구성과 로컬 구성을 가르는 설정은 네 가지입니다. 안정적인 서명 키, 비밀번호 요구, 명시적인 세션 유지 시간, 그리고 백업이 있는 출력 디렉터리입니다.

yaml
annotation_task_name: "Production Annotation Task"
task_description: "Classify each passage."
 
host: 127.0.0.1
port: 8000
 
# Sessions survive a restart only when the signing key is stable.
# Set it here, or leave it out and supply POTATO_SECRET_KEY in the environment.
require_password: true
persist_sessions: true
session_timeout_minutes: 480
 
data_files:
  - data/items.jsonl
 
item_properties:
  id_key: id
  text_key: text
 
output_annotation_dir: annotation_output/production/
export_annotation_format: jsonl
 
user_config:
  allow_all_users: false
  users:
    - annotator1
    - annotator2
 
annotation_schemes:
  - annotation_type: radio
    name: sentiment
    description: "What is the sentiment expressed in this text?"
    labels:
      - name: positive
      - name: neutral
      - name: negative
    label_requirement:
      required: true

session_timeout_minutes는 요청이 있을 때마다 시간이 다시 시작되므로, 근무 시간의 길이가 아니라 유휴 시간의 한도를 정합니다. 기본값 480은 하루 종일 작업하는 주석자에게 맞으며, 머신을 여럿이 함께 쓴다면 낮추십시오. 서버가 공용 인터넷에서 접근 가능할 때는 언제나 allow_all_users: false를 설정하고 주석자 목록을 적어 두십시오. 그래야 URL을 발견한 외부인이 가입해 데이터셋에 쓰기 시작할 수 없습니다.

헬스 체크와 로그

/health는 컨테이너와 로드 밸런서를 위한 인증 없는 활성 상태 확인 엔드포인트입니다. 여기서 알려 주는 내용은 모두 공개되므로, 의도적으로 거의 아무것도 보고하지 않습니다.

bash
curl http://localhost:8000/health
# {"status": "ok"}          once the state managers exist
# {"status": "starting"}    503 while the server is still coming up

컨테이너 헬스 체크와 배포 제공자의 생성 후 폴링은 모두 이 상태 전환을 기준으로 삼습니다. 작업 이름, 항목 수, 주석자 수는 대신 /admin/health에서 얻으며, 여기에는 관리자 API 키가 필요합니다.

로그는 프로세스를 통해 출력되므로 systemd가 이를 수집합니다.

bash
sudo journalctl -u potato -f

구성에서 verbose: true로 로그 수준을 높이거나, debug_log로 필요한 부분의 디버그 로그만 켤 수 있으며, 여기에는 all, ui, server, none 중 하나를 지정합니다. 디버그 로그는 오래 운영하면 작은 디스크를 채울 만큼 양이 많으므로, 실제 연구 중에는 둘 다 꺼 두십시오.

백업할 대상

프로젝트 디렉터리에는 다시 생성할 수 없는 것이 세 가지 있습니다. output_annotation_dir 아래의 주석 출력은 연구 그 자체입니다. project.sqlite에는 메모, 코드북, 케이스, 검색 인덱스, 검토 워크플로가 들어 있습니다. datasets.sqlite에는 데이터셋 레코드가 들어 있습니다. 나머지는 모두 데이터 파일과 구성 파일로 다시 만들 수 있습니다.

bash
#!/bin/bash
# /opt/potato/backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR=/opt/potato/backups
 
tar -czf $BACKUP_DIR/annotations_$DATE.tar.gz \
  /opt/potato/projects/*/annotation_output/ \
  /opt/potato/projects/*/project.sqlite \
  /opt/potato/projects/*/datasets.sqlite \
  /opt/potato/projects/*/config.yaml
 
find $BACKUP_DIR -mtime +7 -delete

crontab으로 매일 밤 실행하고, 아카이브는 머신 밖으로 복사해 두십시오.

bash
0 2 * * * /opt/potato/backup.sh

머신 크기 정하기

항목 풀이 프로세스 안에 보관되므로, 데이터셋에 비례해 늘어나는 자원은 메모리입니다. 나머지는 모두 지연 로딩으로 바뀌어서, 임베딩과 sentence-transformers 스택은 어떤 기능이 필요로 하지 않는 한 부팅 시 임포트되지 않습니다. v2.6.0 릴리스 노트의 참조 데이터셋에서 상주 메모리는 약 750 MB에서 약 365 MB로 줄었고, 50,000개 항목의 부팅 시간은 약 10초에서 약 5.7초로 줄었습니다.

항목당 비용은 작고 측정할 수 있습니다. 이미지 URL과 필드 두 개를 가진 비전 항목은 약 932바이트, 고유 문자가 대략 540자인 텍스트 항목은 약 1430바이트를 차지합니다. 따라서 텍스트 항목 백만 개는 상주 메모리로 약 1.43 GB이며, 예산은 이 수치를 기준으로 잡으면 됩니다.

이것이 문제가 되는 코퍼스라면, 항목 페이로드를 작은 메모리 캐시를 둔 SQLite 파일에 보관할 수 있습니다.

yaml
item_store:
  backend: paged
  cache_size: 2048

이 블록을 위의 구성에 추가합니다. 켜기 전에 측정 결과를 읽어 보십시오. 페이징으로 절약되는 양은 비전 항목에서 28%, 텍스트에서 51%로, 10배 수준의 절감에는 미치지 못합니다. 그 대가로 코퍼스를 불러오는 데 약 2.5배의 시간이 걸립니다. 항목이 백만 개 미만이면 서버만 느려지고 얻는 것은 없습니다.

프로젝트를 여러 서버에 나누기보다는 RAM이 더 많은 머신을 선택하십시오. 나누는 것이 곧 이 가이드 첫머리에서 설명한 다중 프로세스 실패이기 때문입니다. 한 머신이 정말로 코퍼스를 담을 수 없다면 대신 작업을 나누십시오. 배치 할당은 주석자마다 일부를 나눠 주고, per_annotator_quota는 한 사람에게 할당되는 양의 상한을 정하며, 디렉터리 감시를 켠 data_directory를 쓰면 서버가 실행 중일 때 파일을 추가할 수 있습니다.

문제 해결

포트가 이미 사용 중입니다. 같은 프로젝트 디렉터리를 대상으로 다른 포트에서 두 번째 Potato를 시작하지 말고, 해당 프로세스를 찾아 중지하십시오.

bash
sudo lsof -i :8000

프로젝트 디렉터리에서 권한이 거부됩니다. 서비스 사용자가 디렉터리를 소유해야 하며, Docker에서는 그 사용자가 uid 1000입니다.

bash
sudo chown -R potato:potato /opt/potato

재시작할 때마다 주석자가 로그아웃됩니다. POTATO_SECRET_KEY 또는 구성 값 secret_key가 없어서, 부팅할 때마다 서명 키가 새로 생성됩니다.

프록시 뒤에서 에셋이 404가 되고 자동 저장이 실패합니다. 서버가 자신의 URL 접두사를 모르는 상태입니다. POTATO_PROXY_FIX=1 또는 POTATO_URL_PREFIX를 설정하고, 프록시가 전달하기 전에 접두사를 제거하는지 확인하십시오.

더 읽어 보기