Quando você cuida de servidores com cPanel e atende múltiplos clientes ou projetos, cedo ou tarde esbarra no clássico pesadelo: uma única aplicação web começa a abrir conexões sem parar e quase derruba o banco de dados inteiro.
Passei exatamente por isso recentemente. Uma aplicação de chat de atendimento (LiveZilla) instalada em uma das contas começou a canibalizar os recursos do mysqld. O servidor começou a responder com lentidão e outros sites reclamavam de espera no banco. A meta era direta: aplicar uma trava rígida de 20 conexões simultâneas para aquele usuário específico.
O que parecia ser um comando de cinco segundos no terminal virou uma sessão de investigação prática envolvendo resolução de hosts nas tabelas internas do MySQL, peculiaridades do CloudLinux MySQL Governor e travas de processos no Kernel via LVE.
Abaixo, documentei o histórico dessa intervenção, os comandos que falharam, os que funcionaram e o motivo técnico de cada comportamento.
Fase 1: a tentativa nativa e o erro de privilégios no MySQL#
Minha primeira reação foi a mais direta: impor a trava direto no motor do MySQL. Quando definimos o limite na base, o processo mysqld rejeita o handshake antes de carregar buffers de memória pesados para a sessão.
Ao receber uma chamada accept(), o MySQL checa seus contadores na função interna check_for_max_user_connections (localizada em sql/sql_connect.cc). Se o limite configurado já foi atingido, o socket é descartado com o erro ER_TOO_MANY_USER_CONNECTIONS (ERROR 1203 (42000)), conforme documentado no manual de User Resource Limits do MySQL, poupando CPU e memória de disputas de trava.
Tentei aplicar o limite de 20 conexões com a query padrão:
mysql -e "ALTER USER 'user_chatlivezilla'@'localhost' WITH MAX_USER_CONNECTIONS 20;"
A resposta imediata foi uma falha:
ERROR 1396 (HY000) at line 1: Operation ALTER USER failed for 'user_chatlivezilla'@'localhost'
Por que o erro 1396 ocorreu#
O código de erro 1396 (HY000) (ER_CANNOT_USER) ocorre no parser do MySQL durante a fase de resolução de privilégios (função mysql_edit_user no código-fonte em C), conforme detalhado no guia de códigos de erro do MySQL. No MySQL, a chave primária de identificação de um usuário é o par User@Host.
Se eu tento alterar 'user_chatlivezilla'@'localhost', mas no cadastro em memória o usuário foi criado como 'user_chatlivezilla'@'%' (o wildcard padrão gerado pelo cPanel) ou amarrado ao hostname do servidor, o MySQL não encontra o registro exato e aborta silenciosamente com esse erro genérico.
Para descobrir a combinação exata antes de forçar o comando, basta consultar a tabela de autenticação:
mysql -e "SELECT User, Host, plugin FROM mysql.user WHERE User LIKE 'user_chat%';"
Em vez de insistir na camada SQL pura naquele momento, decidi inspecionar as camadas de isolamento do sistema operacional para conter o ofensor na raiz.
Fase 2: o MySQL Governor e o comportamento do utilitário dbctl#
Como a tentativa nativa esbarrou no mapeamento de host, passei para o controle de recursos do CloudLinux: o MySQL Governor. O Governor monitora o consumo de threads e aplica penalidades conforme as regras do ambiente virtualizado (LVE).
Minha primeira tentativa com o comando dbctl esbarrou em formatos de argumentos:
dbctl --set user --max_user_connections=20
# Retorno: Incorrect syntax: unknown command "--set"
dbctl -set user --max_user_connections=20
# Retorno: Incorrect syntax: unknown command "-set"
dbctl set user --max_user_connections=20
# Retorno: /usr/share/lve/dbgovernor/utils/dbctl_orig: unrecognized option '--max_user_connections=20'
O binário wrapper por baixo dos panos#
Essa mensagem de erro revelou algo útil: o comando dbctl nada mais é do que um wrapper em shell script chamando o binário /usr/share/lve/dbgovernor/utils/dbctl_orig.
Conforme a documentação oficial do CloudLinux MySQL Governor, o subcomando dbctl set <usuario> [OPÇÕES] processa exclusivamente flags com dois hífens para parâmetros específicos de CPU e I/O (--cpu, --read, --write, --slow).
Quando tentei a sintaxe posicional:
dbctl set user max_user_connections 20
O comando retornou sem mensagens de erro e com código de saída 0. No entanto, tratava-se de um clássico efeito placebo no terminal Unix: o binário dbctl_orig simplesmente ignorou os argumentos posicionais desconhecidos além do nome do usuário. Nenhuma trava de conexão foi gravada.
Como o Governor realmente controla conexões e a cota do XML#
O MySQL Governor não gerencia conexões concorrentes por usuário de forma estática via linha de comando. Seu mecanismo de defesa é reativo: quando uma conta ultrapassa as métricas de CPU ou I/O nas janelas de monitoramento (1s, 5s, 1m, 5m), o daemon db_governor rebaixa o usuário para um estado restrito (Níveis 1 a 4).
Esse comportamento é regido pelo arquivo de configuração /etc/container/mysql-governor.xml, na diretiva <restrict>:
<restrict level1="60s" level2="15m" level3="1h" level4="1d" timeout="1h" log="/var/log/dbgovernor-restrict.log" user_max_connections="30"></restrict>
Ao disparar a penalidade, o Governor altera temporariamente o usuário no MySQL para o teto configurado em user_max_connections="30". É por isso que, na auditoria da Fase 4, todos os usuários sob restrição apresentavam a marcação de 30 conexões.
Ferramentas essenciais para operar e auditar o Governor em produção:
dbctl list-restricted: lista as contas atualmente penalizadas, o recurso violado e o tempo restante.tail -f /var/log/dbgovernor-restrict.log: monitora em tempo real os gatilhos de penalização.dbtop: console interativo que colore contas restritas em vermelho e em cooldown em azul.dbctl unrestrict <usuario>: remove a penalidade de uma conta imediatamente sem aguardar o timeout do nível.
Fase 3: barreira no nível de kernel com LVE e o conflito NPROC vs EP#
Para criar uma proteção em camadas e evitar que o PHP sequer abrisse o socket TCP contra o MySQL, decidi restringir a conta cPanel no nível do Kernel via lvectl. O objetivo era limitar o total de processos concorrentes (NPROC) do usuário do sistema.
Executei:
lvectl set-user user --nproc=20
E o Kernel recusou imediatamente:
error: You're trying to set invalid LVE limits. NPROC limit must be greater than EP + 15 limit, because number of processes and threads within LVE includes also Apache processes/threads, SSH sessions and etc, which enter into LVE. Limits of user with LVE ID "1001" are affected.
Entendendo a regra de segurança do Kernel#
Essa mensagem do lvectl deixa clara a mecânica do módulo de kernel kmod-lve, detalhada na documentação de limites do CloudLinux LVE:
- EP (Entry Processes): Controla os pontos de entrada do ambiente (requisições HTTP atendidas pelo Apache ou LiteSpeed).
- NPROC: É o teto absoluto de processos na jaula, somando processos filhos (workers do PHP, tarefas em cron, sessões SSH e threads de background).
O LVE impõe por código que $NPROC > EP + 15$. Isso impede que um limite muito baixo de processos bloqueie serviços essenciais do sistema na conta e cause travamentos inesperados. Como o EP herdado do plano padrão da conta era maior, fixar NPROC em 20 quebrou essa proporção.
Para resolver, ajustei ambos os parâmetros de forma equilibrada:
lvectl set-user user --maxEntryProcs=5 --nproc=20
Com 5 processos web de entrada no máximo, o usuário não conseguia abrir dezenas de conexões concorrentes no banco, funcionando como um funil protetor.
Diagnóstico e conformidade das cotas no LVE#
Para auditar se outros pacotes ou contas cPanel possuem violações de proporção que possam causar instabilidade, utilize as ferramentas de validação do CloudLinux:
# Validar a integridade das cotas de LVE em todo o servidor
cldiag --check-lve-limits
# Inspecionar consumo histórico de processos de entrada e processos totais
lveinfo --period=1d --by-usage=ep,nproc
Se o cldiag alertar sobre limites inválidos em pacotes herdados, ajuste o pacote base via lvectl set-pkg <pacote> --nproc=100 --save para que novas contas não quebrem a proporção do Kernel.
Fase 4: o mistério das 59 conexões ativas#
Com os limites no Governor e no LVE definidos, fui conferir o banco de dados para checar os hosts e tentar entender o limite em memória:
mysql -e "SELECT User, Host, max_user_connections FROM mysql.user WHERE User LIKE 'user%';"
O resultado trouxe:
+--------------+--------------------+----------------------+
| User | Host | max_user_connections |
+--------------+--------------------+----------------------+
| user | 192.0.2.100 | 30 |
| user | localhost | 30 |
| user | server.domain.com | 30 |
| user_usrlive | server.domain.com | 30 |
| user_usrlive | 192.0.2.100 | 30 |
| user_usrlive | localhost | 30 |
| user | server.domain2.com | 30 |
+--------------+--------------------+----------------------+
O usuário user_chatlivezilla não aparecia nessa listagem específica. Porém, monitorando a fila de processos em tempo real, as conexões continuavam acumuladas:
watch -n 5 "mysqladmin proc stat | grep user_chatlivezilla | wc -l"
O contador marcava 59 conexões.
Por que as 59 threads continuavam ativas?#
O mysqladmin consulta a tabela dinâmica de processos ativos mantida pelas threads no kernel do MySQL, enquanto o SELECT consultava a tabela estática de autenticação. Havia três explicações para o acúmulo:
- Conexões já abertas não caem sozinhas: Alterar
MAX_USER_CONNECTIONSou limites no Governor não derruba sessões ativas. O MySQL valida essa cota apenas no momento do handshake (sys_connect). As 59 conexões já estavam estabelecidas antes do ajuste. - Persistência de conexão em aplicações de chat: O LiveZilla frequentemente usa conexões persistentes (
PDO::ATTR_PERSISTENToumysql_pconnect). Se os processos do PHP-FPM não fecham o socket com o pacoteFINdo TCP, a conexão fica em estado de espera no MySQL. - Wildcard de host não listado: A credencial utilizada pela aplicação podia estar associada a um host
%ou nome alternativo não capturado no filtro inicial.
O ciclo de vida dos workers no PHP-FPM e o vazamento de sockets#
Aplicações de chat e widgets em tempo real costumam ativar conexões persistentes via PDO::ATTR_PERSISTENT => true. No PHP-FPM, a conexão persistente com o banco de dados fica atrelada ao processo worker do sistema operacional.
Se o pool da conta em /opt/cpanel/ea-phpXX/root/etc/php-fpm.d/usuario.conf estiver com pm.max_requests = 0 (o valor padrão em que o worker nunca é reciclado), o processo do PHP mantém a conexão aberta no MySQL em estado Sleep indefinidamente, mesmo após entregar a requisição HTTP.
Para estancar o vazamento na raiz e forçar a liberação periódica de sockets ociosos:
pm = ondemand
pm.max_children = 15
pm.max_requests = 500
pm.process_idle_timeout = 10s
Com pm.max_requests = 500, a cada 500 requisições atendidas o worker é reciclado graciosamente, fechando sockets antigos com o handshake FIN do TCP e impedindo o acúmulo invisível de threads no MySQL.
Fase 5: expurgo de threads sem derrubar o servidor#
Para que as novas regras entrassem em vigor, era indispensável reiniciar o serviço do Governor e encerrar as conexões ativas do ofensor sem reiniciar o MySQL, já que reiniciar o banco derrubaria todos os outros clientes em produção.
Primeiro, tentei reiniciar o utilitário pelo próprio comando:
dbctl restart
# Retorno: Incorrect syntax: unknown command "restart"
O controle de serviço é feito pelo Systemd:
systemctl restart db_governor
Em seguida, medi o volume de sockets abertos na porta do MySQL associados ao usuário do sistema:
ss -tanp | grep 3306 | grep -c $(id -u user)
Comprovado o volume anormal, apliquei o expurgo cirúrgico: encerrei as threads em estado ocioso gerando comandos KILL CONNECTION dinamicamente:
mysql -e "SELECT concat('KILL CONNECTION ', id, ';') FROM information_schema.processlist WHERE user='user_chatlivezilla' AND command='Sleep' AND time > 10;" | grep -v "concat" | mysql
Diferenciar o tipo de terminação é fundamental em produção:
KILL CONNECTION <id>: encerra a conexão de rede associada à thread, liberando o descritor de arquivo e os buffers de memória da sessão.KILL QUERY <id>: aborta apenas a instrução SQL que a thread estiver executando no momento, preservando o socket TCP aberto.
Em poucos segundos, as 59 conexões caíram. Quando a aplicação tentou reconectar, o LVE restringiu as requisições de entrada a 5 processos e o Governor barrou qualquer tentativa acima de 20 conexões no banco, normalizando o Load Average do servidor imediatamente.
O que fica de lição desse incidente e cenários de borda#
Controlar abusos em ambientes compartilhados exige entender como as peças conversam entre si. Se mesmo após matar as threads o banco voltasse a sofrer com excesso de conexões, havia dois cenários que eu já tinha no radar para investigar:
- Usuários com privilégio SUPER: Há uma diferença técnica importante entre os motores: no MariaDB (conforme documentado na MariaDB Knowledge Base), o privilégio
SUPERouCONNECTION ADMINisenta a conta de qualquer limite emmax_user_connections. Já no Oracle MySQL 8.0, omax_user_connectionsaplica-se estritamente a todas as contas; o privilégio administrativo concede apenas o direito de usar o slot reserva de emergênciamax_connections + 1. Vale sempre auditar comSHOW GRANTS FOR 'usuario'@'host';. - Exaustão de backlog TCP e timeouts longos: Se uma aplicação abre conexões e as abandona em sleep, o parâmetro
wait_timeoutno/etc/my.cnfdeve ser reduzido (por exemplo, de 28800 para 60 segundos). Tratei detalhadamente essa estratégia em meu artigo sobre como conter a exaustão de conexões no MariaDB com CloudLinux sem downtime.
Conter abusos de banco no cPanel não depende só de mexer no my.cnf. É o equilíbrio prático entre os limites internos do banco, a camada de controle do Governor e o isolamento de processos no Kernel com o LVE.
Perguntas frequentes sobre limites de conexão no CloudLinux e cPanel#
O MySQL Governor substitui o MAX_USER_CONNECTIONS nativo do MySQL?#
Não substitui, eles se complementam. O MAX_USER_CONNECTIONS nativo é estático e rejeita handshakes diretamente no parser do banco de dados para contas específicas. Já o MySQL Governor monitora o consumo dinâmico de CPU e I/O da conta; ao detectar abuso, ele injeta automaticamente o teto de conexões configurado em /etc/container/mysql-governor.xml (geralmente 30) como mecanismo temporário de contenção.
Como liberar um usuário penalizado pelo MySQL Governor imediatamente?#
Para remover a penalidade de uma conta sem esperar o término do timeout do nível de restrição, execute dbctl unrestrict <usuario>. Para inspecionar previamente quais contas estão sofrendo estrangulamento e o motivo exato, utilize dbctl list-restricted ou visualize em tempo real com dbtop.
Por que o módulo de kernel LVE exige estritamente NPROC > EP + 15?#
Porque os Entry Processes (EP) contabilizam apenas os pontos de entrada web ativos (requisições HTTP no Apache ou LiteSpeed). Cada requisição pode gerar subprocessos adicionais, como workers PHP, scripts auxiliares, tarefas em cron e sessões SSH. O Kernel impõe essa margem de segurança de 15 processos para evitar que o usuário entre em starvation total de processos do sistema, o que causaria travamento catastrófico da conta.
Como evitar que conexões persistentes do PHP fiquem presas em Sleep?#
Configure o pool do PHP-FPM da conta com reciclagem periódica de workers (pm.max_requests = 500) e defina um timeout de ociosidade curto (pm.process_idle_timeout = 10s). No arquivo /etc/my.cnf do MySQL ou MariaDB, reduza os parâmetros wait_timeout e interactive_timeout do valor padrão de 28800 segundos para 60 segundos.
Este artigo foi útil?
Deixe uma reação rápida para apoiar o conteúdo:
Este post está licenciado sob CC BY-NC.



Comentários
Participe da discussão abaixo.
0 comentários