← Voltar para o Blog

Do nohup ao systemd: escrevendo uma unit para a sua aplicação

A aplicação sobe com nohup, morre quando o terminal fecha e ninguém sabe se está no ar. Uma unit do systemd tem quinze linhas e resolve reinício, log, ordem de boot e limites.

Equipe EasyOps Cloud · · 7 min de leitura

A aplicação está no ar porque alguém rodou nohup node server.js & numa sessão SSH há três meses. Funciona. Até a máquina reiniciar, ou o processo morrer, ou alguém precisar descobrir se ele está mesmo rodando.

O systemd resolve isso com um arquivo de quinze linhas, e resolve várias coisas de uma vez: sobe no boot, reinicia ao morrer, registra log estruturado, declara dependências e aplica limites de recurso. Não há motivo para manter nohup, screen ou gerenciador de processo específico de linguagem em servidor Linux.

A unit mínima que já vale

# /etc/systemd/system/meuapp.service
[Unit]
Description=API do MeuApp
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=meuapp
Group=meuapp
WorkingDirectory=/var/www/meuapp
ExecStart=/usr/bin/node /var/www/meuapp/server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now meuapp
systemctl status meuapp

Três detalhes valem atenção já nesse mínimo.

O ExecStart exige caminho absoluto. O systemd não usa o seu PATH, pelo mesmo motivo que o cron não usa — é um ambiente próprio e enxuto. Se o binário vem de gerenciador de versão como nvm, aponte o caminho real, que which node revela.

O User evita rodar como root. Serviço de aplicação não precisa de root, e rodar como root transforma qualquer falha da aplicação em comprometimento da máquina.

O enable --now faz duas coisas: habilita no boot e inicia agora. Habilitar sem iniciar é a causa clássica do "configurei e não subiu".

Escolher o Type certo

Errar aqui produz um sintoma específico e confuso: o systemctl start trava, ou retorna sucesso com o serviço fora do ar.

TypeQuando usar
simpleO processo fica em primeiro plano. Padrão da maioria
execComo simple, mas espera o binário executar de fato
forkingO processo se duplica e o pai sai — daemon tradicional
oneshotRoda, termina e pronto. Para tarefa, não serviço
notifyA aplicação avisa o systemd quando está pronta

A regra prática: se a sua aplicação não vira daemon sozinha — e a maioria das aplicações modernas não vira —, use simple. Se ela tem opção de rodar em primeiro plano, use essa opção; é sempre preferível a forking.

Se você usa forking sem PIDFile, o systemd perde a referência do processo e passa a achar que o serviço caiu.

Para o serviço só ser considerado ativo quando estiver realmente pronto a receber tráfego, notify é o certo — mas exige suporte na aplicação. Uma alternativa prática é um teste de disponibilidade em ExecStartPost.

Reinício: as opções e a armadilha

Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5

Restart=on-failure reinicia quando o processo termina com erro, e não quando ele sai com sucesso. É o comportamento desejado quase sempre. Restart=always reinicia até quando você para o serviço de propósito por outro caminho — use com consciência.

As duas últimas linhas são a proteção contra laço de reinício. Sem elas, uma aplicação que falha na inicialização por configuração errada entra em ciclo: reinicia, falha, reinicia, para sempre, enchendo o log e consumindo CPU.

Com elas, após 5 tentativas em 300 segundos o systemd desiste e deixa o serviço em falha — que é o estado correto, porque assim o problema fica visível em vez de mascarado.

Se cair nesse estado depois de corrigir, é preciso limpar o contador:

sudo systemctl reset-failed meuapp
sudo systemctl start meuapp

Variáveis de ambiente e segredos

Não coloque credencial dentro da unit — o arquivo é legível por qualquer usuário do sistema.

EnvironmentFile=/etc/meuapp/env
Environment=NODE_ENV=production
sudo install -o root -g meuapp -m 640 /dev/null /etc/meuapp/env

O arquivo aceita CHAVE=valor, um por linha, sem export e sem aspas — o systemd não interpreta sintaxe de shell, e aspas viram parte do valor.

Se o arquivo não existir, o serviço nem sobe. Para torná-lo opcional, prefixe com hífen: EnvironmentFile=-/etc/meuapp/env.

Para conferir o ambiente que o serviço realmente recebeu:

sudo systemctl show meuapp -p Environment
sudo cat /proc/$(systemctl show -p MainPID --value meuapp)/environ | tr '\0' '\n'

Ordem e dependências

After= define ordem; Requires= define dependência real. São coisas diferentes e confundi-las causa falha intermitente no boot.

[Unit]
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service

After sozinho apenas ordena: se o Postgres não subir, a sua aplicação sobe do mesmo jeito e falha ao conectar. Requires faz o systemd parar a sua aplicação se a dependência falhar.

Um detalhe frequente: network.target significa que a rede foi configurada, não que ela está funcionando. Para aplicação que precisa de conectividade real na partida, o correto é network-online.target com o Wants correspondente.

Vale evitar Requires para banco em outra máquina — o systemd local não sabe nada sobre ele. Nesse caso, a resiliência tem que estar na aplicação: tentar reconectar em vez de morrer.

Limites e isolamento de graça

Diretivas que custam uma linha e reduzem bastante o estrago potencial:

# Recursos
MemoryMax=2G
CPUQuota=150%
LimitNOFILE=65535

# Isolamento
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/meuapp/storage

ProtectSystem=strict monta todo o sistema de arquivos como somente leitura para esse serviço, exceto o que você liberar em ReadWritePaths. Uma falha na aplicação deixa de conseguir alterar binário do sistema ou o próprio código.

PrivateTmp=true dá ao serviço um /tmp isolado, o que elimina uma classe inteira de ataque por arquivo temporário previsível.

O MemoryMax é a proteção descrita no artigo sobre o OOM killer: um vazamento derruba o próprio serviço em vez de derrubar o banco de dados que estava usando mais memória.

Depois de aplicar, vale conferir o quanto o serviço ficou exposto:

systemd-analyze security meuapp

Ele lista cada diretiva de proteção e aponta o que falta. Não persiga a nota perfeita, mas as três ou quatro primeiras sugestões costumam ser fáceis e valiosas.

Log, que passa a ser de graça

Com a unit, stdout e stderr vão para o journal automaticamente. Some o redirecionamento manual para arquivo:

journalctl -u meuapp -f              # acompanhar ao vivo
journalctl -u meuapp -n 100          # últimas 100 linhas
journalctl -u meuapp --since '1 hour ago' -p err
journalctl -u meuapp -o json-pretty  # com todos os campos

Vale garantir que o journal tenha retenção definida, senão ele cresce até incomodar — o artigo sobre disco cheio traz os parâmetros.

Quando não é serviço, é tarefa

Nem tudo que precisa rodar é um processo permanente. Para o que executa e termina — backup, importação, limpeza —, a unit é do tipo oneshot, acompanhada de um timer.

# /etc/systemd/system/importacao.service
[Unit]
Description=Importação diária de pedidos

[Service]
Type=oneshot
User=meuapp
EnvironmentFile=/etc/meuapp/env
ExecStart=/var/www/meuapp/bin/importar
# /etc/systemd/system/importacao.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
sudo systemctl enable --now importacao.timer
systemctl list-timers importacao.timer

Três diretivas justificam a troca em relação ao cron. Persistent=true executa a tarefa perdida quando a máquina estava desligada no horário — o cron simplesmente pula. RandomizedDelaySec espalha a partida, evitando que dez tarefas marcadas para 3h disputem disco no mesmo instante. E o log vai para o journal, com journalctl -u importacao, sem redirecionamento manual.

Some a isso a proteção contra sobreposição: se a execução anterior ainda estiver rodando, o systemd não inicia outra. É a diferença entre um processamento lento e duas cópias concorrentes corrompendo o mesmo dado — problema que o cron não resolve sozinho e que costuma aparecer no pior momento, quando o volume cresce.

Vale dizer que o cron continua adequado para tarefa simples e sem estado. A troca compensa quando o horário importa, quando a duplicidade é perigosa ou quando o log precisa estar em algum lugar. O artigo sobre por que o cron não roda cobre os casos em que manter o cron faz sentido.

Migrando do que já existe

Se hoje a aplicação roda com um gerenciador de processo próprio da linguagem, o caminho é direto: uma unit por processo, ou uma unit que chama o gerenciador em modo primeiro plano. A segunda opção mantém a compatibilidade, mas empilha dois supervisores — e quando algo der errado, você vai depurar os dois.

Depois de subir, teste o que realmente importa antes de considerar pronto:

sudo systemctl restart meuapp && systemctl is-active meuapp
sudo kill -9 $(systemctl show -p MainPID --value meuapp)
sleep 8 && systemctl status meuapp     # tem que ter voltado sozinho
sudo reboot                            # e voltar depois do boot

Esses três testes cobrem o que o nohup nunca ofereceu. Se algo falhar, o diagnóstico de serviço que não sobe cobre a leitura de exit code e as causas mais comuns.

Vale versionar a unit junto com o código da aplicação, em vez de deixá-la existindo apenas no servidor. Ela descreve como o serviço roda — usuário, ambiente, dependências, limites — e essa informação é tão parte do sistema quanto o código. Um servidor novo provisionado a partir do repositório sobe idêntico; um servidor cuja unit foi escrita à mão em algum momento esquecido não sobe nunca igual.

Suba um servidor em minutos

Preço em real, suporte em português e dados no Brasil.

Criar conta