Skip to content
Guides13 min read

Implantando o Potato em um servidor

Rode o Potato em produção atrás do nginx com TLS, no Docker ou em uma VM, e entenda por que um servidor Potato roda como um único processo e não como vários workers.

Potato Team

Rode um processo do Potato por projeto, coloque o nginx e um certificado TLS na frente dele e, quando precisar de mais concorrência, aumente as threads em vez dos processos worker. Um segundo processo worker perde anotações sem avisar.

O Potato mantém em memória, dentro do processo do servidor, seu pool de itens, sua fila de atribuição e o estado de cada anotador. Esse único fato define a maior parte de uma implantação. É por causa desse estado em processo que um servidor Potato escala com threads e não com workers, que o diretório de saída das anotações é o que você precisa ter em backup, e que o balanceamento de carga entre vários processos do Potato corrompe um estudo em vez de acelerá-lo. Este guia cobre as três formas de rodar um servidor, o proxy reverso e o TLS na frente dele e as chaves de configuração que importam quando anotadores reais estão trabalhando nele.

Por que um servidor Potato roda como um único processo

Um segundo worker do gunicorn recebe sua própria cópia do pool de itens, da fila de atribuição e do estado dos usuários. Ele distribui instâncias que o primeiro worker já atribuiu e, como user_state.json é reescrito por inteiro a cada salvamento, o worker que salva por último descarta as anotações do outro. Nada nos logs registra isso. As anotações somem, e o primeiro sinal costuma ser um anotador dizendo que rotulou um item que agora parece intocado.

A imagem Docker publicada do Potato impõe essa regra. Ela roda um único worker do gunicorn e se recusa a iniciar com mais, e POTATO_ALLOW_MULTIWORKER=1 anula essa recusa para qualquer coisa que não seja um estudo real. A concorrência vem das threads, que compartilham uma única cópia do estado. A imagem usa oito por padrão por meio de GUNICORN_THREADS, o que atende às dezenas de anotadores simultâneos de um estudo típico.

Essa restrição delimita o restante deste guia. Rode um processo por projeto, dê a ele memória suficiente para o dataset e use o proxy reverso para TLS e arquivos estáticos, não para distribuir carga.

Três formas de rodar um servidor

MétodoO que ele ofereceQuando usar
potato deployProvisiona o host, o TLS e a imagem a partir da sua configuraçãoUm estudo que você quer no ar hoje
Imagem DockerOs mesmos bytes toda vez, sem compilar dependênciasUma máquina que você já administra
VM com systemdControle total do host e do ambiente PythonUm servidor institucional com regras próprias

potato deploy para o caminho rápido

potato deploy provisiona um host, instala a imagem e configura o TLS a partir do arquivo de configuração que você já tem:

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

Alguns minutos depois você tem uma URL HTTPS para enviar aos anotadores, e potato deploy pull traz as anotações de volta. Desde a 2.10.0, os destinos são local, potato share e treze hosts na nuvem, entre eles AWS, Jetstream2, Hetzner, Fly e Railway, e eles diferem quanto aos dados sobreviverem ou não a uma reinicialização. Os sete destinos de VM reúnem disco persistente, streaming de logs e SSH, e um host que apaga o disco precisa de --backup, que copia as anotações para um dataset do HuggingFace ou um bucket S3. Executar o Potato localmente ou na nuvem compara todos eles. Comece com local, que roda o mesmo pacote e a mesma imagem sem conta e sem cobrança, e mostra o que seria implantado antes de custar qualquer coisa.

A imagem Docker publicada

O Potato publica uma imagem pré-compilada no GitHub Container Registry. Ela contém o servidor e suas dependências, e o seu projeto é montado em /app em tempo de execução, então uma única imagem serve para qualquer tarefa:

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

A imagem existe porque as dependências principais do Potato incluem numpy, pandas, scipy e scikit-learn. Instalá-las em uma VM pequena é lento, na melhor das hipóteses. Em uma máquina de 1 GB a compilação é encerrada por falta de memória, e em uma de 2 GB leva de cinco a dez minutos a cada nova implantação. Baixar a imagem leva menos de um minuto.

Duas tags importam. latest traz as dependências principais e tem cerca de 840 MB, e latest-all acrescenta os SDKs de IA, formatos de documento, exportação e logins OAuth, com cerca de 1,5 GB. Ambas são compiladas para linux/amd64 e linux/arm64. Fixe uma versão em vez de latest para um estudo que vai durar meses, porque latest muda sem você perceber.

Defina POTATO_SECRET_KEY em tudo o que durar mais que uma única execução. Sem ela, a chave de assinatura das sessões do Flask é aleatória por processo, então cada reinicialização desconecta todos os anotadores. POTATO_CONFIG escolhe um caminho de configuração dentro do contêiner, PORT define a porta de escuta (7860 por padrão) e GUNICORN_TIMEOUT define o tempo limite do worker em segundos.

A imagem roda como uid 1000, e um bind mount mantém a propriedade do host, então no Linux o contêiner muitas vezes não consegue gravar em um diretório que você criou. O servidor verifica isso na inicialização e se recusa a rodar em vez de falhar no meio do boot. Entregue o diretório ao usuário do contêiner com sudo chown -R 1000:1000 myproject, ou rode como você mesmo com docker run --user "$(id -u):$(id -g)", o que também deixa as anotações em seu nome. O Docker Desktop no macOS e no Windows ignora a propriedade em bind mounts, então uma configuração que funciona em um Mac ainda pode falhar em um servidor Linux.

Instalando em uma VM

Em uma máquina que você administra diretamente, instale o Potato em um ambiente virtual e deixe o systemd cuidar do processo.

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

Rode o aplicativo no gunicorn com um worker e várias threads. A factory da aplicação fica em potato.flask_server:create_app e recebe o caminho da configuração:

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')"

Crie /etc/systemd/system/potato.service para que o servidor volte depois de uma reinicialização ou de uma falha:

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

Habilite e inicie o serviço:

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

O EnvironmentFile guarda os segredos que não devem ficar na configuração:

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

Gere a chave secreta uma única vez com openssl rand -hex 32 e mantenha-a durante todo o estudo. Trocá-la invalida todas as sessões e desconecta todos os anotadores.

nginx e TLS

O Potato escuta em localhost e o nginx termina o TLS na frente dele. O limite de upload importa em projetos de imagem e áudio, onde o limite padrão de 1 MB para o corpo da requisição rejeita mídias sem que o anotador veja a falha.

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;
    }
}

Habilite o site e obtenha um certificado:

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

Feche todo o resto no firewall, porque a porta do Potato não deve ficar acessível diretamente depois que há um proxy na frente:

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

Servindo vários projetos sob um único nome de host

Instituições costumam expor vários servidores Potato por meio de um único endpoint HTTPS público, mapeando caminhos para portas locais. A interface de anotação solicita recursos e salva por meio de URLs relativas à raiz, então um servidor montado em /app1 precisa conhecer seu prefixo; caso contrário, o CSS retorna 404 e os salvamentos automáticos falham com "annotations not saved". O Potato lê o prefixo do SCRIPT_NAME do WSGI, e há duas formas de defini-lo.

Use POTATO_PROXY_FIX=1 quando você controla o proxy e ele pode enviar X-Forwarded-Prefix. Use POTATO_URL_PREFIX=/app1 quando a configuração do proxy não está nas suas mãos. O proxy ainda precisa remover o prefixo antes de encaminhar, para que o Flask continue recebendo caminhos sem prefixo:

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;
}

O ProxyFix confia nos cabeçalhos encaminhados, então ative POTATO_PROXY_FIX somente quando o aplicativo for acessível exclusivamente pelo proxy confiável. Se a porta interna também estiver acessível diretamente, um cliente pode falsificar o prefixo e envenenar as URLs que o Potato gera.

A configuração de que um projeto em produção precisa

Quatro ajustes separam uma configuração de produção de uma local: uma chave de assinatura estável, a exigência de senha, uma duração de sessão explícita e um diretório de saída do qual você tenha backup.

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 limita o tempo ocioso, não a duração do turno, porque o relógio recomeça a cada requisição. O padrão de 480 serve para anotadores que trabalham o dia inteiro; reduza-o quando a máquina for compartilhada. Defina allow_all_users: false e liste seus anotadores sempre que o servidor estiver acessível pela internet pública, para que um desconhecido que encontre a URL não possa se registrar e começar a gravar no seu dataset.

Verificações de saúde e logs

/health é uma sonda de disponibilidade sem autenticação para contêineres e balanceadores de carga, e de propósito informa quase nada, porque tudo o que ela diz é público:

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

O healthcheck de um contêiner e a verificação pós-criação de um provedor de implantação se baseiam nessa transição. Nomes de tarefas, contagens de itens e contagens de anotadores vêm de /admin/health, que exige a chave de API de administração.

O log passa pelo processo, então o systemd o captura:

bash
sudo journalctl -u potato -f

Aumente o nível pela configuração com verbose: true, ou ative as partes do log de depuração de que você precisa com debug_log, que aceita all, ui, server ou none. Deixe ambos desligados durante um estudo real, porque o log de depuração é detalhado o suficiente para encher um disco pequeno ao longo de uma execução longa.

O que incluir no backup

Três coisas no diretório do projeto não podem ser regeneradas. A saída das anotações em output_annotation_dir é o estudo. project.sqlite guarda os memos, o livro de códigos, os casos, o índice de busca e o fluxo de revisão. datasets.sqlite guarda os registros do dataset. Todo o resto pode ser reconstruído a partir dos seus arquivos de dados e da configuração.

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

Rode-o toda noite pelo crontab e copie o arquivo para fora da máquina:

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

Dimensionando a máquina

A memória é o recurso que cresce com o dataset, porque o pool de itens fica no processo. Todo o resto passou a ser carregado sob demanda, então a pilha de embeddings e sentence-transformers não é importada na inicialização, a menos que um recurso precise dela. No dataset de referência das notas de versão da v2.6.0, a memória residente caiu de cerca de 750 MB para cerca de 365 MB, e a inicialização com 50.000 itens de cerca de 10 segundos para cerca de 5,7 segundos.

O custo por item é pequeno e mensurável. Um item de visão com uma URL de imagem e dois campos ocupa cerca de 932 bytes, e um item de texto com aproximadamente 540 caracteres únicos, cerca de 1430 bytes. Um milhão de itens de texto equivale, portanto, a cerca de 1,43 GB residentes, e esse é o número para usar no orçamento.

Para corpora em que isso importa, o conteúdo dos itens pode ficar em um arquivo SQLite com um pequeno cache em memória:

yaml
item_store:
  backend: paged
  cache_size: 2048

Adicione este bloco à configuração acima. Leia a medição antes de ativá-lo. A paginação economiza 28% em itens de visão e 51% em texto, não uma ordem de grandeza, e em troca carregar o corpus leva cerca de 2,5 vezes mais tempo. Abaixo de um milhão de itens, ela só traz um servidor mais lento e nenhum benefício.

Prefira uma máquina com mais RAM a dividir um projeto entre servidores, porque dividir é a falha multiprocesso descrita no início deste guia. Quando uma máquina realmente não comporta o corpus, divida o trabalho. A atribuição em lotes dá a cada anotador uma fatia, per_annotator_quota limita quanto é atribuído a uma mesma pessoa, e um data_directory com monitoramento de diretório permite adicionar arquivos enquanto o servidor está rodando.

Solução de problemas

A porta já está em uso. Encontre o processo e pare-o, em vez de iniciar um segundo Potato em outra porta sobre o mesmo diretório de projeto:

bash
sudo lsof -i :8000

Permissão negada no diretório do projeto. O usuário do serviço precisa ser o dono dele, e no Docker esse usuário é o uid 1000:

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

Os anotadores são desconectados a cada reinicialização. Falta POTATO_SECRET_KEY ou o valor de configuração secret_key, então a chave de assinatura é regenerada a cada inicialização.

Os recursos retornam 404 e os salvamentos automáticos falham atrás de um proxy. O servidor não conhece seu prefixo de URL. Defina POTATO_PROXY_FIX=1 ou POTATO_URL_PREFIX e verifique se o proxy remove o prefixo antes de encaminhar.

Leitura adicional