Blog

Configuração que muda em produção sem deploy: recarregar parâmetros sem reiniciar e sem estado inconsistente

Um limite de cobrança, um timeout, o tamanho de um pool, o provedor ativo de pagamento: parâmetros assim mudam com frequência muito maior do que o código, e exigir um deploy para cada ajuste transforma operação em fila de espera. A alternativa de recarregar em tempo de execução parece simples até a primeira requisição que lê o parâmetro duas vezes e recebe dois valores diferentes, ou até a instância que aplica uma versão quebrada e passa a recusar tudo. Este artigo mostra por que a leitura campo a campo produz estado inconsistente, como um snapshot imutável com troca atômica elimina o problema, como fixar o snapshot por requisição com AsyncLocalStorage, como recarregar da fonte com validação e última versão boa conhecida, como reconstruir componentes que derivam da configuração sem derrubar o que está em voo e como provar em teste que nenhuma requisição vê duas versões.

2026-10-14 / Arquitetura / 14 min

01

O que muda sem deploy e por que reiniciar não é resposta

Em um serviço de cobrança, o código muda algumas vezes por semana; o limite acima do qual um pedido vai para revisão manual muda algumas vezes por dia, conforme a fraude do momento. Tratar os dois com o mesmo processo cria um problema de ritmo: a operação precisa de um ajuste em minutos e recebe um pipeline de quarenta. A primeira solução que aparece é reiniciar o processo com a variável de ambiente nova, o que resolve a consistência, porque o processo nasce inteiro com a versão nova, mas custa um rolling restart por ajuste, com drenagem de conexões, aquecimento de cache e janela de capacidade reduzida. Em uma frota de trinta instâncias, dez ajustes por dia viram trezentos reinícios.

Tipo de parâmetroFrequência de mudançaCusto de um valor erradoPrecisa de reinício?
Limites de negócio (valor para revisão, cota por cliente)DiáriaCobrança indevida ou fraude liberadaNão: lido por requisição
Timeouts e tentativas de dependênciasSemanal, e a cada incidenteCascata de lentidão ou desistência precoceNão: lido por chamada
Tamanho de pool e limites de concorrênciaMensalEsgotamento ou subutilização do bancoNão, se o componente for reconstruído
Endpoint ou provedor ativoRara, e em emergênciaTráfego indo para o provedor erradoNão, com cliente reconstruído
Porta de escuta, modo de cluster, flags do runtimeRaraProcesso não sobeSim: faz parte da inicialização

As quatro primeiras linhas justificam recarregar em tempo de execução. A última lembra que nem tudo deve ser recarregável: parâmetros consumidos uma única vez na inicialização continuam exigindo reinício, e fingir o contrário gera uma configuração que diz uma coisa enquanto o processo faz outra.

02

A leitura campo a campo é o que cria o estado inconsistente

O primeiro recarregador que se escreve é um objeto global com um método get por chave, atualizado por um temporizador. Funciona enquanto cada requisição lê cada parâmetro uma única vez. Deixa de funcionar quando a mesma chave é lida duas vezes com um await entre elas, ou quando duas chaves que só fazem sentido juntas, como provedor e timeout, são lidas em momentos diferentes. A requisição passa a enxergar uma mistura de versões que nunca existiu em lugar nenhum.

// cobranca.js (versao com problema)
import { config } from './config-global.js';

export async function cobrar(pedido) {
  // leitura 1: no inicio da requisicao
  if (pedido.valor > config.get('limiteSemRevisao')) {
    await enviarParaRevisao(pedido);
    return { status: 'em_revisao' };
  }

  const resultado = await gateway.cobrar(pedido); // 300 ms de espera

  // leitura 2: depois do await, a configuracao pode ter sido recarregada
  const limite = config.get('limiteSemRevisao');
  await registrarAuditoria({
    pedidoId: pedido.id,
    limiteAplicado: limite, // grava um limite que nao foi o usado na decisao
    revisado: pedido.valor > limite,
  });
  return resultado;
}
Serviço de cobrança com limite de revisão manual lido de um parâmetro remoto. Operação baixa o limite de R$ 1.000 para R$ 50 no meio do tráfego.

t=0         pedido 8841 de R$ 300 entra; leitura 1: limite = 1.000; não precisa de revisão
t=0,02 s    chamada ao gateway de pagamento começa (300 ms)
t=0,10 s    recarregador aplica a nova versão: limite = 50
t=0,32 s    gateway confirma a cobrança de R$ 300
t=0,33 s    leitura 2: limite = 50; auditoria grava "revisado = true, limite = 50"
            resultado: pedido cobrado sem revisão, auditoria dizendo que foi revisado
            nenhuma linha errada; duas versões de configuração na mesma requisição

A linha de tempo mostra o pior tipo de defeito: nenhum erro, nenhum log de exceção, e um registro de auditoria que afirma algo falso. O mesmo padrão aparece quando um retry lê o timeout de novo e passa a esperar um valor diferente do que a primeira tentativa esperou, ou quando o provedor muda entre a autorização e a captura. Não é um bug da lógica de negócio. É a ausência de uma unidade de consistência: a requisição precisa de uma versão só, do começo ao fim.

03

Snapshot imutável, troca atômica e fixação por requisição

A correção tem três partes. A configuração deixa de ser um conjunto de chaves e vira um objeto único, congelado e numerado: um snapshot. Recarregar não altera campos; produz um novo snapshot e troca a referência em uma atribuição, que em JavaScript é atômica por natureza do modelo de execução. E cada requisição fixa a referência que estava valendo quando ela começou, de modo que qualquer leitura dentro dela, antes ou depois de um await, cai no mesmo objeto. Em Node.js, AsyncLocalStorage carrega esse snapshot por toda a cadeia assíncrona sem precisar passá-lo como argumento em cada função.

// config-store.js
import { AsyncLocalStorage } from 'node:async_hooks';

const congelar = (objeto) => {
  for (const valor of Object.values(objeto)) {
    if (valor && typeof valor === 'object' && !Object.isFrozen(valor)) congelar(valor);
  }
  return Object.freeze(objeto);
};

export class ConfigStore {
  #atual;
  #ouvintes = new Set();
  #contexto = new AsyncLocalStorage();

  constructor({ inicial, validar }) {
    this.validar = validar; // (bruto) => config normalizada, ou lanca erro
    this.#atual = congelar({ ...this.validar(inicial), versao: 1 });
  }

  // Snapshot imutavel e completo: um objeto, uma versao, lido uma vez.
  get snapshot() {
    return this.#contexto.getStore() ?? this.#atual;
  }

  // Fixa o snapshot atual para tudo que rodar dentro de fn, inclusive apos awaits.
  fixarPara(fn) {
    return this.#contexto.run(this.#atual, fn);
  }

  // Troca atomica: ou a nova versao inteira passa a valer, ou nada muda.
  aplicar(bruto) {
    const validada = this.validar(bruto); // lanca antes de tocar em #atual
    const anterior = this.#atual;
    this.#atual = congelar({ ...validada, versao: anterior.versao + 1 });
    for (const ouvinte of this.#ouvintes) {
      try {
        ouvinte(this.#atual, anterior);
      } catch (erro) {
        console.error('ouvinte de configuracao falhou', erro);
      }
    }
    return this.#atual;
  }

  aoMudar(ouvinte) {
    this.#ouvintes.add(ouvinte);
    return () => this.#ouvintes.delete(ouvinte);
  }
}

// cobranca.js (versao corrigida)
export async function cobrar(pedido, { config }) {
  const { limiteSemRevisao, versao } = config.snapshot; // uma leitura, uma versao
  if (pedido.valor > limiteSemRevisao) {
    await enviarParaRevisao(pedido);
    return { status: 'em_revisao' };
  }
  const resultado = await gateway.cobrar(pedido);
  await registrarAuditoria({
    pedidoId: pedido.id,
    limiteAplicado: limiteSemRevisao,
    configVersao: versao, // a auditoria diz qual versao decidiu
    revisado: false,
  });
  return resultado;
}

// middleware HTTP: cada requisicao enxerga uma unica versao do inicio ao fim
export const comConfigFixa = (config) => (req, res, next) => {
  config.fixarPara(() => {
    res.setHeader('x-config-version', String(config.snapshot.versao));
    next();
  });
};

Três detalhes fazem diferença. O congelamento profundo garante que ninguém altera o snapshot no lugar e cria uma versão intermediária que só aquela instância vê. A versão incremental vai para o cabeçalho de resposta, para o log e para a auditoria, e com isso um pedido estranho de ontem pode ser lido junto da configuração que de fato o decidiu. E o ouvinte que falha não impede a troca: a nova versão já está valendo quando os ouvintes rodam, e o erro de um deles vira log, não rollback parcial.

Trabalhos de longa duração seguem a mesma regra com uma nuance: um job que roda por vinte minutos fixa o snapshot no início de cada unidade de trabalho, como cada lote de mil linhas, e não no início do job inteiro. Assim a mudança de configuração entra entre lotes, nunca no meio de um, e o job não fica preso a uma versão de vinte minutos atrás.

04

Recarregar da fonte: validar antes, manter a última boa conhecida

A fonte pode ser o Parameter Store, o Consul, um objeto no S3 ou um arquivo montado por ConfigMap. Para o recarregador, tudo isso é uma função que devolve um corpo e um identificador de versão, como ETag ou número de revisão, para que o ciclo não reaplique a mesma configuração a cada intervalo. O ponto que não pode faltar é a validação completa antes de aplicar: tipo, faixa, valores permitidos e regras entre campos. Uma versão que passa por campo mas é incoerente como conjunto, como um timeout de 200 ms para um provedor que nunca responde abaixo de 500 ms, precisa ser rejeitada inteira.

// recarregador.js
const esquema = {
  limiteSemRevisao: { tipo: 'number', min: 0, max: 1_000_000 },
  timeoutGatewayMs: { tipo: 'number', min: 100, max: 30_000 },
  poolMaxConexoes: { tipo: 'number', min: 1, max: 200 },
  provedorPagamento: { tipo: 'string', valores: ['stripe', 'adyen', 'pagarme'] },
};

export function validarConfig(bruto) {
  const saida = {};
  for (const [chave, regra] of Object.entries(esquema)) {
    const valor = bruto[chave];
    if (typeof valor !== regra.tipo) throw new Error(`${chave}: esperado ${regra.tipo}`);
    if (regra.min !== undefined && valor < regra.min) throw new Error(`${chave}: abaixo de ${regra.min}`);
    if (regra.max !== undefined && valor > regra.max) throw new Error(`${chave}: acima de ${regra.max}`);
    if (regra.valores && !regra.valores.includes(valor)) throw new Error(`${chave}: valor invalido`);
    saida[chave] = valor;
  }
  // regras entre campos: uma config valida campo a campo ainda pode ser incoerente
  if (saida.timeoutGatewayMs < 500 && saida.provedorPagamento === 'adyen') {
    throw new Error('timeoutGatewayMs abaixo de 500 ms nao e compativel com adyen');
  }
  return saida;
}

// Fonte generica: qualquer coisa que devolva { etag, corpo } (SSM, Consul, S3, arquivo)
export function iniciarRecarregador({ fonte, config, intervaloMs = 15_000, metricas }) {
  let etagAtual = null;
  let parado = false;

  const ciclo = async () => {
    if (parado) return;
    try {
      const { etag, corpo } = await fonte.ler({ seDiferenteDe: etagAtual });
      if (etag !== etagAtual) {
        const aplicada = config.aplicar(corpo); // valida e so entao troca
        etagAtual = etag;
        metricas.gauge('config_version', aplicada.versao);
        metricas.gauge('config_last_success_timestamp', Date.now() / 1000);
        console.info('configuracao aplicada', { versao: aplicada.versao, etag });
      }
    } catch (erro) {
      // versao invalida ou fonte fora do ar: a ultima boa conhecida continua valendo
      metricas.counter('config_reload_failures_total').inc();
      console.error('configuracao rejeitada, mantendo a anterior', { erro: erro.message });
    } finally {
      if (!parado) setTimeout(ciclo, intervaloMs).unref();
    }
  };

  ciclo();
  return () => {
    parado = true;
  };
}

Quando a validação falha, ou a fonte está fora do ar, o processo continua com a última versão boa conhecida e sobe um contador. É a diferença entre uma mudança errada que afeta ninguém e uma que derruba a frota. Em uma instância recém-iniciada o mesmo princípio vale com um complemento: o processo deve subir com um snapshot válido, vindo da fonte ou de uma cópia local gravada na última aplicação bem-sucedida, e só então começar a aceitar tráfego. Subir com valores padrão silenciosos porque a fonte demorou é a forma mais comum de ter uma instância diferente das outras sem ninguém perceber.

Mecanismo de detecçãoLatência até aplicarCarga na fonteQuando usar
Polling com ETag ou revisãoAté um intervalo (15 a 60 s)Uma leitura condicional por instância por intervaloPadrão: simples, previsível, funciona com qualquer fonte
Watch ou stream (etcd, Consul, SSE)SegundosUma conexão aberta por instânciaQuando a latência de minutos não serve, como kill switch
Sinal SIGHUP com arquivo localImediata, manualNenhumaProcesso único ou poucos hosts com orquestração própria
Push por fila ou webhookSegundosMensagem por mudançaQuando já existe fila; exige polling de segurança para instâncias que perderam a mensagem

05

Componentes que guardam estado derivado da configuração

Um limite lido por requisição muda sozinho quando o snapshot muda. Um pool de conexões, um cliente HTTP com keep-alive, um rate limiter com janela em memória e um temporizador já criado não mudam: eles leram a configuração uma vez na construção e guardaram o resultado. Para esses componentes, recarregar significa reconstruir, e reconstruir sem derrubar o que está em voo significa criar o novo antes de encerrar o antigo e deixar o antigo drenar no próprio ritmo.

// pool-gerenciado.js
import { Pool } from 'pg';

export function criarPoolGerenciado({ config, urlBanco }) {
  let atual = new Pool({ connectionString: urlBanco, max: config.snapshot.poolMaxConexoes });

  config.aoMudar((nova, anterior) => {
    if (nova.poolMaxConexoes === anterior.poolMaxConexoes) return; // nada a reconstruir

    const antigo = atual;
    atual = new Pool({ connectionString: urlBanco, max: nova.poolMaxConexoes });
    // o pool antigo so fecha depois que as consultas em andamento terminarem:
    // pool.end() espera os clientes emprestados voltarem antes de encerrar
    antigo
      .end()
      .then(() => console.info('pool antigo drenado', { versao: anterior.versao }))
      .catch((erro) => console.error('falha ao drenar pool antigo', erro));
  });

  // quem consulta sempre pega o pool atual; uma consulta em voo mantem o seu
  return {
    query: (...args) => atual.query(...args),
    conectar: () => atual.connect(),
  };
}
ComponenteEstratégiaCuidado
Limites, timeouts, chaves de decisãoLer do snapshot a cada usoLer uma vez por requisição, nunca por campo em momentos diferentes
Pool de conexões, cliente HTTPCriar o novo, trocar a referência, drenar o antigoGarantir que o antigo fecha de verdade; dois pools cheios dobram as conexões no banco
Rate limiter, cache em memóriaReconstruir com os limites novos; aceitar perder a janela correnteDurante a troca há um instante sem histórico; documentar que é esperado
Temporizadores e agendamentosCancelar o antigo e registrar o novo no ouvinteIntervalo em voo termina com o valor antigo; a próxima execução usa o novo
Porta, modo de cluster, flags de runtimeMarcar como somente inicializaçãoRejeitar a mudança no validador em vez de aceitá-la e ignorá-la

A última linha é a que evita a confusão mais cara: um parâmetro que o validador aceita mas o processo nunca aplica. Se um campo só tem efeito na inicialização, o validador deve recusar a mudança em tempo de execução com uma mensagem explícita, para que a operação saiba que aquele ajuste exige deploy.

06

Operar e provar: versão visível, rollout gradual e o teste da requisição em voo

Uma configuração que muda sem deploy precisa da mesma rastreabilidade de um deploy. Isso quer dizer versão no log de cada requisição, no cabeçalho de resposta e numa métrica por instância: um gráfico de config_version por réplica mostra em segundos se uma instância ficou presa numa versão antiga porque a fonte falhou só para ela. Vale também o rollout gradual: aplicar uma mudança a uma fração da frota primeiro, por chave de instância ou por percentual, e só então ao restante. E vale um caminho de reversão que não dependa da fonte estar saudável: a última versão boa conhecida gravada localmente serve de ponto de retorno.

// config-store.test.js
import test from 'node:test';
import assert from 'node:assert/strict';
import { ConfigStore } from './config-store.js';
import { validarConfig } from './recarregador.js';

const base = {
  limiteSemRevisao: 1000,
  timeoutGatewayMs: 3000,
  poolMaxConexoes: 10,
  provedorPagamento: 'stripe',
};

test('uma requisicao em voo nao enxerga a versao aplicada no meio dela', async () => {
  const config = new ConfigStore({ inicial: base, validar: validarConfig });
  const leituras = [];

  const requisicao = config.fixarPara(async () => {
    leituras.push(config.snapshot.limiteSemRevisao);
    await new Promise((resolver) => setTimeout(resolver, 10)); // simula o await do gateway
    leituras.push(config.snapshot.limiteSemRevisao);
  });

  config.aplicar({ ...base, limiteSemRevisao: 50 }); // chega enquanto a requisicao espera
  await requisicao;

  assert.deepEqual(leituras, [1000, 1000]); // as duas leituras vieram da mesma versao
  assert.equal(config.snapshot.limiteSemRevisao, 50); // fora da requisicao, a nova ja vale
});

test('versao invalida e rejeitada e a anterior continua valendo', () => {
  const config = new ConfigStore({ inicial: base, validar: validarConfig });

  assert.throws(() => config.aplicar({ ...base, poolMaxConexoes: 0 }), /poolMaxConexoes/);
  assert.throws(
    () => config.aplicar({ ...base, provedorPagamento: 'adyen', timeoutGatewayMs: 200 }),
    /adyen/,
  );

  assert.equal(config.snapshot.versao, 1);
  assert.equal(config.snapshot.poolMaxConexoes, 10);
});

test('snapshot e imutavel', () => {
  const config = new ConfigStore({ inicial: base, validar: validarConfig });
  assert.throws(() => {
    'use strict';
    config.snapshot.limiteSemRevisao = 0;
  }, TypeError);
});

O primeiro teste é o que importa: uma requisição em voo lê o mesmo valor antes e depois de um await, mesmo com uma versão nova aplicada no meio, enquanto o código fora da requisição já enxerga a nova. É esse teste que transforma a frase "nossa configuração é recarregável" em uma propriedade verificada. Os outros dois garantem que uma versão inválida nunca substitui a válida e que ninguém altera o snapshot no lugar. Nenhum deles precisa de infraestrutura: rodam com node:test em milissegundos.

  • Versão da configuração em cada log de requisição, no cabeçalho de resposta e numa métrica por instância.
  • Contador de falhas de recarga com alerta quando passa de zero por mais de um ciclo.
  • Idade da última aplicação bem-sucedida por instância, para detectar réplica presa numa versão antiga.
  • Rollout por fração da frota antes de aplicar ao restante, com o mesmo cuidado de um deploy.
  • Cópia local da última versão boa conhecida, para subir instâncias novas sem depender da fonte.
  • Teste automatizado da requisição em voo que não enxerga a versão aplicada no meio dela.

FAQ

Perguntas frequentes

Feature flags não resolvem o mesmo problema?

Resolvem uma parte e sofrem do mesmo defeito quando mal usadas. Uma feature flag é um parâmetro booleano com rollout por público; um limite numérico, um timeout ou um provedor ativo são parâmetros com domínio maior, e muitas ferramentas de flag os suportam como variáveis. O que importa é a leitura: um SDK de flags avaliado duas vezes na mesma requisição, com um await entre elas, produz exatamente a mistura de versões descrita aqui. A regra do snapshot fixado por requisição vale para flags e para qualquer outro parâmetro que muda em tempo de execução.

Fixar a configuração por requisição não atrasa a aplicação de um kill switch?

Atrasa pelo tempo de uma requisição, que é o atraso correto. Uma requisição que começou com o provedor A deve terminar com o provedor A; interrompê-la no meio produz estados parciais piores do que deixá-la concluir. O kill switch passa a valer para toda requisição nova no instante da troca, e as em voo terminam em milissegundos ou segundos. Se um trabalho de longa duração precisa reagir mais rápido, ele deve fixar o snapshot por unidade de trabalho, como por lote, e não por execução inteira.

Com várias instâncias, como garantir que todas aplicam a mesma versão ao mesmo tempo?

Não se garante, e o desenho não deve depender disso. Com polling, as instâncias aplicam a nova versão dentro de um intervalo, em momentos diferentes; com watch, em segundos, ainda em momentos diferentes. O sistema precisa tolerar a convivência de duas versões por um curto período, da mesma forma que tolera um rolling deploy. O que precisa existir é visibilidade: versão por instância numa métrica, alerta para réplica presa numa versão antiga e, para mudanças que não podem conviver, como trocar o formato de uma chave, um parâmetro intermediário que aceita os dois formatos, aplicado primeiro, como num expandir e contrair de banco.

Recarregar sem reiniciar é uma questão de unidade de consistência, não de temporizador

Qualquer serviço consegue reler um arquivo a cada trinta segundos. O que separa um recarregador confiável de um gerador de incidentes silenciosos é a unidade de consistência: um snapshot imutável com versão, trocado atomicamente, fixado por requisição ou por unidade de trabalho, validado inteiro antes de valer, com a última versão boa conhecida como rede de segurança e com componentes derivados reconstruídos sem derrubar o que está em voo. Sem o teste da requisição em voo, a propriedade continua sendo uma suposição.