Capstone: Implementando os Microsserviços

Capstone: Implementando os Microsserviços

O que separa código de demonstração de código de produção não é a lógica de negócio, e sim as camadas em volta: o pacote core com logging, métricas e resiliência, o catálogo com cache e circuit breaker, o checkout que orquestra catálogo e pagamento, o consumidor SQS, os manifestos Kubernetes e o ambiente local.
DevOps

• • 26 min de leitura

A diferença entre código de demonstração e código de produção não está na lógica de negócio — está nas camadas que envolvem essa lógica. Um endpoint de API que busca um produto no banco de dados é trivial de escrever. O mesmo endpoint preparado para produção inclui validação de entrada, tratamento de erros estruturado, logging com contexto de rastreamento, métricas expostas para o Prometheus, circuit breaker para o banco de dados, graceful shutdown, healthchecks calibrados e testes que verificam não apenas o caminho feliz mas os cenários de falha.

Este artigo implementa os cinco microsserviços do capstone com todas essas camadas integradas. Cada serviço é apresentado com seu núcleo funcional e as práticas operacionais que o tornam adequado para produção — não como adições posteriores, mas como parte intrínseca do design.

Estrutura Compartilhada: O Pacote Core

Código repetido entre serviços é um passivo. O pacote @loja/core contém as abstrações compartilhadas — logging, métricas, tracing, resiliência e configuração — que todos os serviços consomem:

// packages/core/src/index.js
module.exports = {
  ...require('./logger'),
  ...require('./metrics'),
  ...require('./tracing'),
  ...require('./resilience'),
  ...require('./config'),
  ...require('./health'),
};
// packages/core/src/logger.js
const pino = require('pino');

function criarLogger(contexto = {}) {
  return pino({
    level: process.env.LOG_LEVEL || 'info',
    formatters: {
      level: (label) => ({ level: label }),
    },
    base: {
      servico: process.env.SERVICE_NAME,
      versao:  process.env.APP_VERSION || 'desconhecida',
      ambiente: process.env.NODE_ENV,
      ...contexto,
    },
    timestamp: pino.stdTimeFunctions.isoTime,
    redact: {
      paths: [
        'password', 'senha', 'token', 'secret',
        'authorization', 'cookie', 'cartao',
        '*.password', '*.senha', '*.token',
      ],
      censor: '[REDACTED]',
    },
  });
}

module.exports = { criarLogger };
// packages/core/src/metrics.js
const client = require('prom-client');

// Registra métricas padrão do Node.js (CPU, memória, event loop)
client.collectDefaultMetrics({
  prefix: `${process.env.SERVICE_NAME?.replace(/-/g, '_')}_`,
});

// Métricas HTTP padrão para todos os serviços
const httpRequestDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'Duração das requisições HTTP em segundos',
  labelNames: ['method', 'route', 'status_code', 'service'],
  buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
});

const httpRequestsTotal = new client.Counter({
  name: 'http_requests_total',
  help: 'Total de requisições HTTP',
  labelNames: ['method', 'route', 'status_code', 'service'],
});

function middlewareMetricas(req, res, next) {
  const inicio = Date.now();

  res.on('finish', () => {
    const duracao = (Date.now() - inicio) / 1000;
    const labels = {
      method: req.method,
      route: req.route?.path || req.path,
      status_code: res.statusCode,
      service: process.env.SERVICE_NAME,
    };

    httpRequestDuration.observe(labels, duracao);
    httpRequestsTotal.inc(labels);
  });

  next();
}

async function handleMetricas(req, res) {
  res.set('Content-Type', client.register.contentType);
  res.end(await client.register.metrics());
}

module.exports = { middlewareMetricas, handleMetricas, client };

Serviço de Catálogo

O serviço de catálogo tem as maiores demandas de leitura do sistema. O design prioriza caching agressivo com Redis e uma Read Replica do PostgreSQL para escalar leituras horizontalmente:

// services/catalog-service/src/server.js
const express = require('express');
const { criarLogger, middlewareMetricas, handleMetricas } = require('@loja/core');
const { configurarTracing } = require('./tracing');
const produtosRouter = require('./routes/produtos.routes');
const healthRouter = require('./routes/health.routes');
const { inicializarConexoes, encerrarConexoes } = require('./db/conexoes');

configurarTracing();

const app = express();
const logger = criarLogger({ servico: 'catalog-service' });

app.use(express.json({ limit: '1mb' }));
app.use(middlewareMetricas);

// Middleware de contexto de rastreamento
app.use((req, res, next) => {
  req.logger = logger.child({
    traceId: req.headers['x-trace-id'] || crypto.randomUUID(),
    method:  req.method,
    path:    req.path,
  });
  next();
});

app.use('/health', healthRouter);
app.use('/metrics', handleMetricas);
app.use('/produtos', produtosRouter);

// Handler de erros global
app.use((err, req, res, next) => {
  const status = err.status || 500;

  req.logger.error({
    err: {
      message: err.message,
      stack: process.env.NODE_ENV !== 'production' ? err.stack : undefined,
    },
    status,
  }, 'Erro não tratado');

  res.status(status).json({
    erro: status < 500 ? err.message : 'Erro interno do servidor',
    codigo: err.codigo || 'ERRO_INTERNO',
    traceId: req.logger.bindings().traceId,
  });
});

let servidor;

async function iniciar() {
  await inicializarConexoes();

  servidor = app.listen(process.env.PORT || 3001, () => {
    logger.info({ porta: process.env.PORT || 3001 }, 'Catalog service iniciado');
  });
}

async function encerrar(sinal) {
  logger.info({ sinal }, 'Encerrando catalog service...');

  servidor.close(async () => {
    await encerrarConexoes();
    logger.info('Encerramento concluído');
    process.exit(0);
  });

  setTimeout(() => {
    logger.error('Timeout de encerramento — forçando saída');
    process.exit(1);
  }, 30000);
}

process.on('SIGTERM', () => encerrar('SIGTERM'));
process.on('SIGINT',  () => encerrar('SIGINT'));

iniciar().catch(err => {
  logger.fatal({ err }, 'Falha na inicialização');
  process.exit(1);
});
// services/catalog-service/src/routes/produtos.routes.js
const router = require('express').Router();
const { param, query, validationResult } = require('express-validator');
const ProdutoService = require('../services/produto.service');

const validarUUID = (campo) =>
  param(campo).isUUID(4).withMessage(`${campo} deve ser um UUID válido`);

function checarValidacao(req, res, next) {
  const erros = validationResult(req);
  if (!erros.isEmpty()) {
    return res.status(400).json({
      erro: 'Dados inválidos',
      detalhes: erros.array(),
    });
  }
  next();
}

// GET /produtos — lista com paginação e filtros
router.get('/',
  query('pagina').optional().isInt({ min: 1 }).toInt(),
  query('limite').optional().isInt({ min: 1, max: 100 }).toInt(),
  query('categoria_id').optional().isUUID(4),
  query('busca').optional().isString().trim().isLength({ max: 100 }),
  checarValidacao,
  async (req, res, next) => {
    try {
      const resultado = await ProdutoService.listar({
        pagina:      req.query.pagina || 1,
        limite:      req.query.limite || 20,
        categoriaId: req.query.categoria_id,
        busca:       req.query.busca,
      }, req.logger);

      res.json(resultado);
    } catch (err) {
      next(err);
    }
  }
);

// GET /produtos/:id
router.get('/:id',
  validarUUID('id'),
  checarValidacao,
  async (req, res, next) => {
    try {
      const produto = await ProdutoService.buscarPorId(
        req.params.id,
        req.logger
      );

      if (!produto) {
        return res.status(404).json({
          erro: 'Produto não encontrado',
          codigo: 'PRODUTO_NAO_ENCONTRADO',
        });
      }

      res.json(produto);
    } catch (err) {
      next(err);
    }
  }
);

// GET /produtos/:id/estoque — endpoint crítico para o order-service
router.get('/:id/estoque',
  validarUUID('id'),
  query('quantidade').isInt({ min: 1 }).toInt(),
  checarValidacao,
  async (req, res, next) => {
    try {
      const estoque = await ProdutoService.verificarEstoque(
        req.params.id,
        req.query.quantidade,
        req.logger
      );
      res.json(estoque);
    } catch (err) {
      next(err);
    }
  }
);

module.exports = router;
// services/catalog-service/src/services/produto.service.js
const { CircuitBreaker } = require('@loja/core');
const cache = require('../cache/cache.service');
const ProdutoRepository = require('../repositories/produto.repository');
const { Histogram } = require('prom-client');

const duracaoCacheOp = new Histogram({
  name: 'catalog_cache_operation_seconds',
  help: 'Duração das operações de cache no catalog-service',
  labelNames: ['operacao', 'hit'],
  buckets: [0.001, 0.005, 0.01, 0.025, 0.05],
});

const dbCircuitBreaker = new CircuitBreaker({
  nome: 'catalog-postgres',
  limiarFalhas: 5,
  timeoutAbertura: 30000,
  timeoutRequisicao: 3000,
});

class ProdutoService {
  async buscarPorId(id, logger) {
    const chaveCache = `produto:${id}`;
    const timer = duracaoCacheOp.startTimer({ operacao: 'get' });

    // Tenta o cache primeiro
    const cached = await cache.get(chaveCache);
    if (cached) {
      timer({ hit: 'true' });
      logger.debug({ id, fonte: 'cache' }, 'Produto encontrado no cache');
      return cached;
    }
    timer({ hit: 'false' });

    // Cache miss — busca no banco com circuit breaker
    const produto = await dbCircuitBreaker.executar(
      () => ProdutoRepository.buscarPorId(id),
      // Fallback: retorna null em vez de propagar o erro
      () => null
    );

    if (produto) {
      // Armazena no cache com TTL de 10 minutos
      await cache.set(chaveCache, produto, 600);
      logger.debug({ id, fonte: 'banco' }, 'Produto buscado do banco');
    }

    return produto;
  }

  async listar(filtros, logger) {
    const chaveCache = `produtos:lista:${JSON.stringify(filtros)}`;

    return cache.getOuBuscar(
      chaveCache,
      () => dbCircuitBreaker.executar(
        () => ProdutoRepository.listar(filtros)
      ),
      120 // TTL de 2 minutos para listas
    );
  }

  async verificarEstoque(produtoId, quantidade, logger) {
    // Estoque não é cacheado — precisa ser sempre atual
    const estoque = await dbCircuitBreaker.executar(
      () => ProdutoRepository.buscarEstoque(produtoId)
    );

    if (!estoque) {
      return { disponivel: false, quantidade_disponivel: 0 };
    }

    return {
      disponivel: estoque.quantidade >= quantidade,
      quantidade_disponivel: estoque.quantidade,
    };
  }
}

module.exports = new ProdutoService();

Serviço de Pedidos

O serviço de pedidos é o mais crítico do sistema — orquestra o checkout coordenando chamadas ao serviço de catálogo, ao gateway de pagamento e às filas SQS:

// services/order-service/src/services/checkout.service.js
const { ClienteHTTPResilient, criarLogger } = require('@loja/core');
const PedidoRepository = require('../repositories/pedido.repository');
const AuditoriaService = require('../auditoria/auditoria.service');
const SQSPublisher = require('../queue/sqs.publisher');

const logger = criarLogger({ servico: 'order-service' });

const clienteCatalogo = new ClienteHTTPResilient(
  process.env.CATALOG_SERVICE_URL,
  { nome: 'catalog-service', limiarFalhas: 5 }
);

const clientePagamento = new ClienteHTTPResilient(
  process.env.PAYMENT_GATEWAY_URL,
  {
    nome: 'payment-gateway',
    limiarFalhas: 3,
    timeoutRequisicao: 10000, // Pagamento pode ser lento
  }
);

class CheckoutService {
  async iniciarCheckout(usuarioId, itens, enderecoEntrega, traceId) {
    const logCtx = logger.child({ traceId, usuarioId });

    logCtx.info({ qtdItens: itens.length }, 'Iniciando checkout');

    // 1. Valida disponibilidade de todos os itens em paralelo
    const verificacoes = await Promise.allSettled(
      itens.map(item =>
        clienteCatalogo.get(
          `/produtos/${item.produto_id}/estoque?quantidade=${item.quantidade}`
        )
      )
    );

    const itensSemEstoque = [];
    for (let i = 0; i < verificacoes.length; i++) {
      const { status, value, reason } = verificacoes[i];

      if (status === 'rejected') {
        logCtx.warn(
          { produtoId: itens[i].produto_id, erro: reason?.message },
          'Falha ao verificar estoque — assumindo disponível'
        );
        // Falha na verificação não bloqueia o checkout
        // O sistema de estoque tem sua própria consistência
        continue;
      }

      if (!value.disponivel) {
        itensSemEstoque.push({
          produto_id: itens[i].produto_id,
          solicitado: itens[i].quantidade,
          disponivel: value.quantidade_disponivel,
        });
      }
    }

    if (itensSemEstoque.length > 0) {
      logCtx.info({ itensSemEstoque }, 'Itens sem estoque suficiente');
      return {
        sucesso: false,
        erro: 'ESTOQUE_INSUFICIENTE',
        itens_sem_estoque: itensSemEstoque,
      };
    }

    // 2. Busca os dados dos produtos para o snapshot do pedido
    const dadosProdutos = await Promise.all(
      itens.map(item =>
        clienteCatalogo.get(`/produtos/${item.produto_id}`)
      )
    );

    // 3. Calcula o total do pedido
    const itensProcessados = itens.map((item, i) => ({
      produto_id:     item.produto_id,
      nome_produto:   dadosProdutos[i].nome,
      preco_unitario: dadosProdutos[i].preco_promocional || dadosProdutos[i].preco,
      quantidade:     item.quantidade,
      subtotal:       (dadosProdutos[i].preco_promocional || dadosProdutos[i].preco)
                        * item.quantidade,
    }));

    const subtotal = itensProcessados.reduce((acc, item) => acc + item.subtotal, 0);
    const frete    = this._calcularFrete(enderecoEntrega, subtotal);
    const total    = subtotal + frete;

    // 4. Cria o pedido no banco com status "aguardando_pagamento"
    const pedido = await PedidoRepository.criar({
      usuario_id:       usuarioId,
      status:           'aguardando_pagamento',
      itens:            itensProcessados,
      subtotal,
      frete,
      total,
      endereco_entrega: enderecoEntrega,
      metadata:         { traceId },
    });

    logCtx.info({ pedidoId: pedido.id, total }, 'Pedido criado');

    await AuditoriaService.registrar({
      tipo: 'pedido.criado',
      resultado: 'sucesso',
      ator: { id: usuarioId, tipo: 'usuario' },
      recurso: { tipo: 'pedido', id: pedido.id },
      detalhes: { total, qtdItens: itens.length },
      traceId,
    });

    return { sucesso: true, pedido };
  }

  async processarPagamento(pedidoId, dadosPagamento, traceId) {
    const logCtx = logger.child({ traceId, pedidoId });
    const pedido = await PedidoRepository.buscarPorId(pedidoId);

    if (!pedido || pedido.status !== 'aguardando_pagamento') {
      return { sucesso: false, erro: 'PEDIDO_NAO_ENCONTRADO_OU_STATUS_INVALIDO' };
    }

    logCtx.info({ valor: pedido.total }, 'Processando pagamento');

    let resultadoPagamento;
    try {
      resultadoPagamento = await clientePagamento.post('/cobrar', {
        valor:       pedido.total,
        moeda:       'BRL',
        descricao:   `Pedido #${pedidoId}`,
        referencia:  pedidoId,
        pagamento:   dadosPagamento,
      });
    } catch (erro) {
      logCtx.error({ erro: erro.message }, 'Falha ao processar pagamento');

      await PedidoRepository.atualizarStatus(pedidoId, 'cancelado', {
        motivo_cancelamento: 'falha_pagamento',
        erro_pagamento: erro.message,
      });

      return { sucesso: false, erro: 'FALHA_PAGAMENTO' };
    }

    if (!resultadoPagamento.aprovado) {
      await PedidoRepository.atualizarStatus(pedidoId, 'cancelado', {
        motivo_cancelamento: 'pagamento_recusado',
        codigo_recusa: resultadoPagamento.codigo_recusa,
      });

      return {
        sucesso: false,
        erro: 'PAGAMENTO_RECUSADO',
        codigo_recusa: resultadoPagamento.codigo_recusa,
      };
    }

    // 5. Atualiza o pedido para "pago" e publica evento
    await PedidoRepository.atualizarStatus(pedidoId, 'pago', {
      id_transacao: resultadoPagamento.id_transacao,
      confirmado_em: new Date(),
    });

    // Publica evento de pedido confirmado — consome assincronamente
    // pelos serviços de notificação e catálogo (atualização de estoque)
    await SQSPublisher.publicar('pedido-confirmado', {
      pedidoId,
      usuarioId:   pedido.usuario_id,
      total:       pedido.total,
      itens:       pedido.itens,
      confirmedAt: new Date().toISOString(),
      traceId,
    });

    logCtx.info({ transacaoId: resultadoPagamento.id_transacao }, 'Pagamento aprovado');

    return {
      sucesso: true,
      pedido: await PedidoRepository.buscarPorId(pedidoId),
    };
  }

  _calcularFrete(endereco, subtotal) {
    // Frete grátis acima de R$ 200
    if (subtotal >= 200) return 0;
    // Simplificado — em produção integraria com serviço de frete
    return 15.90;
  }
}

module.exports = new CheckoutService();

Serviço de Notificações

O serviço de notificações é um consumidor SQS puro — sem endpoints HTTP de negócio, apenas processamento de mensagens de fila:

// services/notification-service/src/consumer/sqs.consumer.js
const { SQSClient, ReceiveMessageCommand,
        DeleteMessageCommand, ChangeMessageVisibilityCommand } = require('@aws-sdk/client-sqs');
const { criarLogger } = require('@loja/core');
const EmailService    = require('../services/email.service');
const { Counter, Histogram } = require('prom-client');

const logger = criarLogger({ servico: 'notification-service' });
const sqs    = new SQSClient({ region: process.env.AWS_REGION });

const mensagensProcessadas = new Counter({
  name: 'notifications_messages_processed_total',
  help: 'Total de mensagens SQS processadas',
  labelNames: ['fila', 'resultado'],
});

const duracaoProcessamento = new Histogram({
  name: 'notifications_processing_duration_seconds',
  help: 'Duração do processamento de mensagens',
  labelNames: ['fila', 'tipo'],
  buckets: [0.1, 0.5, 1, 2, 5, 10],
});

const FILAS = {
  'pedido-confirmado': {
    url:     process.env.SQS_URL_PEDIDO_CONFIRMADO,
    handler: processarPedidoConfirmado,
  },
  'pedido-cancelado': {
    url:     process.env.SQS_URL_PEDIDO_CANCELADO,
    handler: processarPedidoCancelado,
  },
};

async function processarPedidoConfirmado(corpo, msgLogger) {
  const { pedidoId, usuarioId, total, traceId } = corpo;

  msgLogger.info({ pedidoId, total }, 'Enviando confirmação de pedido');

  // Busca email do usuário via user-service
  const usuario = await fetch(
    `${process.env.USER_SERVICE_URL}/usuarios/${usuarioId}`,
    { headers: { 'x-trace-id': traceId } }
  ).then(r => r.json());

  await EmailService.enviar({
    para:    usuario.email,
    assunto: `Pedido #${pedidoId} confirmado!`,
    template: 'pedido-confirmado',
    dados: {
      nome:     usuario.nome,
      pedidoId,
      total:    total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
      linkAcompanhamento: `${process.env.APP_URL}/pedidos/${pedidoId}`,
    },
  });

  msgLogger.info({ pedidoId, email: usuario.email }, 'Email de confirmação enviado');
}

async function processarPedidoCancelado(corpo, msgLogger) {
  const { pedidoId, usuarioId, motivoCancelamento } = corpo;

  const usuario = await fetch(
    `${process.env.USER_SERVICE_URL}/usuarios/${usuarioId}`
  ).then(r => r.json());

  await EmailService.enviar({
    para:    usuario.email,
    assunto: `Pedido #${pedidoId} cancelado`,
    template: 'pedido-cancelado',
    dados: {
      nome:    usuario.nome,
      pedidoId,
      motivo:  motivoCancelamento,
    },
  });
}

// Loop de polling da fila
async function iniciarConsumidor(nomeFila, config) {
  logger.info({ fila: nomeFila }, 'Iniciando consumidor SQS');

  while (true) {
    try {
      const { Messages = [] } = await sqs.send(new ReceiveMessageCommand({
        QueueUrl:            config.url,
        MaxNumberOfMessages: 10,
        WaitTimeSeconds:     20, // Long polling — reduz custos e latência
        MessageAttributeNames: ['All'],
      }));

      await Promise.allSettled(
        Messages.map(msg => processarMensagem(msg, nomeFila, config))
      );

    } catch (erro) {
      logger.error({ erro: erro.message, fila: nomeFila }, 'Erro no loop de polling');
      // Backoff em caso de erro para não sobrecarregar a fila
      await new Promise(r => setTimeout(r, 5000));
    }
  }
}

async function processarMensagem(msg, nomeFila, config) {
  const timer = duracaoProcessamento.startTimer({ fila: nomeFila });
  const corpo = JSON.parse(msg.Body);
  const msgLogger = logger.child({
    messageId: msg.MessageId,
    fila: nomeFila,
    traceId: corpo.traceId,
  });

  try {
    await config.handler(corpo, msgLogger);

    // Remove a mensagem da fila após processamento bem-sucedido
    await sqs.send(new DeleteMessageCommand({
      QueueUrl:      config.url,
      ReceiptHandle: msg.ReceiptHandle,
    }));

    mensagensProcessadas.inc({ fila: nomeFila, resultado: 'sucesso' });
    timer({ tipo: corpo.tipo || 'desconhecido' });

  } catch (erro) {
    msgLogger.error({ erro: erro.message }, 'Falha ao processar mensagem');
    mensagensProcessadas.inc({ fila: nomeFila, resultado: 'falha' });

    // Não deleta a mensagem — voltará para a fila após o timeout de visibilidade
    // Após maxReceiveCount tentativas, vai para a DLQ automaticamente
  }
}

// Inicia todos os consumidores em paralelo
async function iniciar() {
  await Promise.all(
    Object.entries(FILAS).map(([nome, config]) =>
      iniciarConsumidor(nome, config)
    )
  );
}

module.exports = { iniciar };

Manifestos Kubernetes: Deployment Completo

# infrastructure/kubernetes/services/order-service/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  namespace: producao
  annotations:
    kubernetes.io/change-cause: "Deploy inicial do order-service"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: order-service
        versao: "1.0.0"
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port:   "3003"
        prometheus.io/path:   "/metrics"
    spec:
      serviceAccountName: order-service
      terminationGracePeriodSeconds: 60

      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: order-service

      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault

      containers:
        - name: order-service
          image: ghcr.io/empresa/order-service:1.0.0
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 3003
              name: http

          env:
            - name: NODE_ENV
              value: production
            - name: PORT
              value: "3003"
            - name: SERVICE_NAME
              value: order-service
            - name: CATALOG_SERVICE_URL
              value: http://catalog-service.producao.svc.cluster.local
            - name: USER_SERVICE_URL
              value: http://user-service.producao.svc.cluster.local

          # Secrets injetados via External Secrets Operator
          envFrom:
            - secretRef:
                name: order-service-secrets

          resources:
            requests:
              memory: "256Mi"
              cpu: "200m"
            limits:
              memory: "512Mi"
              cpu: "1000m"

          livenessProbe:
            httpGet:
              path: /health/live
              port: 3003
            initialDelaySeconds: 30
            periodSeconds: 15
            failureThreshold: 3

          readinessProbe:
            httpGet:
              path: /health/ready
              port: 3003
            initialDelaySeconds: 15
            periodSeconds: 5
            failureThreshold: 3

          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]

          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

          volumeMounts:
            - name: tmp
              mountPath: /tmp

      volumes:
        - name: tmp
          emptyDir: {}

      imagePullSecrets:
        - name: ghcr-credentials
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
  namespace: producao
spec:
  selector:
    app: order-service
  ports:
    - name: http
      port: 80
      targetPort: 3003
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service
  namespace: producao
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 60
    scaleDown:
      stabilizationWindowSeconds: 300
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: order-service-secrets
  namespace: producao
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: order-service-secrets
    creationPolicy: Owner
  dataFrom:
    - extract:
        key: loja/producao/order

Ambiente de Desenvolvimento Local

# docker-compose.yml — desenvolvimento local completo
version: '3.8'

services:
  postgres-catalog:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: catalog_db
      POSTGRES_USER: catalog_admin
      POSTGRES_PASSWORD: dev_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_catalog:/var/lib/postgresql/data
      - ./services/catalog-service/migrations:/docker-entrypoint-initdb.d

  postgres-order:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: order_db
      POSTGRES_USER: order_admin
      POSTGRES_PASSWORD: dev_password
    ports:
      - "5433:5432"
    volumes:
      - postgres_order:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

  localstack:
    image: localstack/localstack:latest
    ports:
      - "4566:4566"
    environment:
      SERVICES: sqs,secretsmanager
      DEFAULT_REGION: us-east-1
    volumes:
      - ./scripts/local-dev/localstack-init.sh:/etc/localstack/init/ready.d/init.sh

  catalog-service:
    build:
      context: .
      dockerfile: services/catalog-service/Dockerfile
      target: development
    ports:
      - "3001:3001"
    environment:
      NODE_ENV: development
      DATABASE_URL: postgresql://catalog_admin:dev_password@postgres-catalog:5432/catalog_db
      REDIS_URL: redis://redis:6379
    volumes:
      - ./services/catalog-service/src:/app/src
    depends_on:
      - postgres-catalog
      - redis

  order-service:
    build:
      context: .
      dockerfile: services/order-service/Dockerfile
      target: development
    ports:
      - "3003:3003"
    environment:
      NODE_ENV: development
      DATABASE_URL: postgresql://order_admin:dev_password@postgres-order:5432/order_db
      CATALOG_SERVICE_URL: http://catalog-service:3001
      USER_SERVICE_URL: http://user-service:3002
      SQS_URL_PEDIDO_CONFIRMADO: http://localstack:4566/000000000000/pedido-confirmado
      AWS_ENDPOINT_URL: http://localstack:4566
    volumes:
      - ./services/order-service/src:/app/src
    depends_on:
      - postgres-order
      - catalog-service
      - localstack

  notification-service:
    build:
      context: .
      dockerfile: services/notification-service/Dockerfile
      target: development
    environment:
      NODE_ENV: development
      SQS_URL_PEDIDO_CONFIRMADO: http://localstack:4566/000000000000/pedido-confirmado
      USER_SERVICE_URL: http://user-service:3002
      AWS_ENDPOINT_URL: http://localstack:4566
    volumes:
      - ./services/notification-service/src:/app/src
    depends_on:
      - localstack

volumes:
  postgres_catalog:
  postgres_order:

O pacote compartilhado é a decisão mais delicada deste artigo. Ele elimina repetição de logging, tracing e tratamento de erro, e ao mesmo tempo cria o acoplamento que microsserviços tentam evitar — cada mudança nele obriga a coordenar releases. Vale manter ali só o que é infraestrutura de verdade, nunca regra de negócio: no dia em que o serviço de pedidos precisar de uma versão diferente da do catálogo, a fronteira já estará no lugar certo.

Fontes e leituras recomendadas

Node.js em produção

Padrões de microsserviços

Serviço pronto para produção

Exercícios

Exercício 1

No iniciarCheckout, a verificação de estoque de cada item é tratada assim:

if (status === 'rejected') {
  msgLogger.warn(…, 'Falha ao verificar estoque — assumindo disponível');
  continue;
}

O catalog-service fica indisponível por 20 minutos numa Black Friday. Descreva o que acontece com os checkouts nesse período.

Ver resposta

✓ Resposta: Todos os checkouts são aprovados, para todos os produtos, sem nenhuma verificação de estoque. A loja vende, durante 20 minutos de pico, quantidade ilimitada de itens que podem não existir.

O mecanismo agrava a si mesmo. Com o catálogo fora, o ClienteHTTPResilient acumula falhas até o circuit breaker abrir — e a partir daí ele passa a rejeitar instantaneamente, sem sequer tentar a chamada. Cada verificação vira uma Promise rejeitada em microssegundos, o continue a descarta, e o checkout segue como se o estoque estivesse confirmado. A proteção que deveria conter o dano é justamente o que o acelera: sem o circuit breaker, cada item ao menos esperaria um timeout, o que reduziria a vazão de pedidos indevidos.

E o prejuízo é financeiro e irreversível, não técnico: os pagamentos foram capturados. A empresa fica com pedidos pagos que não pode entregar, e o que resta é estornar em massa, negociar prazos ou comprar a preço de mercado para honrar — tudo isso no dia de maior visibilidade do ano.

O erro conceitual está no comentário que justifica a decisão: "o sistema de estoque tem sua própria consistência". Não tem — a baixa de estoque é assíncrona via SQS, conforme o ADR-002, e portanto acontece depois da venda. Não existe nenhuma etapa posterior capaz de recusar um pedido já pago.

A correção é tratar a indisponibilidade como falha do checkout, e não como aprovação tácita:

if (status === 'rejected') {
  logCtx.error({ produtoId: itens[i].produto_id, erro: reason?.message },
    'Não foi possível verificar estoque — checkout recusado');
  return { sucesso: false, erro: 'ESTOQUE_INDISPONIVEL_TEMPORARIAMENTE' };
}

Perder vendas durante uma indisponibilidade é ruim; vender o que não se tem é pior, porque converte um incidente técnico de 20 minutos em um passivo comercial de semanas. A regra vale além deste código: em decisões que comprometem recursos escassos ou dinheiro, o estado seguro na ausência de informação é recusar.

Exercício 2

O middlewareMetricas do pacote core rotula cada requisição assim:

route: req.route?.path || req.path,

Duas semanas após o lançamento, o Prometheus começa a consumir dezenas de GB de memória e a ficar instável. As requisições do sistema não aumentaram. O que aconteceu?

Ver resposta

✓ Resposta: Explosão de cardinalidade causada pelo fallback req.path.

Quando a requisição casa com uma rota do Express, req.route.path devolve o padrão — /produtos/:id —, e todas as requisições a produtos diferentes compartilham a mesma série temporal. É o comportamento desejado. Mas quando nenhuma rota casa — um 404, uma varredura automatizada, um link quebrado —, req.route é undefined e o código cai em req.path, que é o caminho literal da requisição.

Cada caminho distinto passa a criar uma nova série no Prometheus, multiplicada por método, status e serviço — e o histograma http_request_duration_seconds tem 10 buckets, então cada caminho novo gera cerca de 13 séries. Um scanner de vulnerabilidades varrendo /wp-admin, /.env, /admin.php e milhares de outros produz dezenas de milhares de séries em poucas horas. Como o Prometheus mantém em memória o índice de todas as séries ativas, o consumo cresce até o processo ser morto por OOM.

Pior: as séries não desaparecem quando o tráfego cessa. Elas permanecem no bloco de dados pelo período de retenção — 15 dias na configuração do artigo — e continuam pesando nas consultas.

A correção é nunca deixar um valor não controlado virar label:

route: req.route?.path ?? 'unmatched',

Todos os 404 passam a somar em uma única série, que continua sendo útil: um pico em unmatched é exatamente o sinal de varredura ou de link quebrado. Se for necessário saber quais caminhos, o lugar disso é o log — que é indexado para busca, não para agregação numérica.

A regra geral para labels do Prometheus: o conjunto de valores possíveis precisa ser pequeno, finito e conhecido de antemão. IDs, e-mails, UUIDs, URLs completas e mensagens de erro jamais devem virar label. Vale notar que o mesmo cuidado se aplica ao status_code — este é seguro, porque a quantidade de códigos HTTP é limitada.

Exercício 3

O consumidor SQS busca até 10 mensagens por vez e processa todas com Promise.allSettled. A fila pedido-confirmado foi criada com visibility_timeout_seconds = 30. O serviço de e-mail fica lento e passa a levar 8 segundos por envio. Que problema surge, e o que o cliente percebe?

Ver resposta

✓ Resposta: As mensagens do lote começam a ser reprocessadas antes de terminarem, e o cliente recebe e-mails duplicados de confirmação.

A conta: o ReceiveMessageCommand traz 10 mensagens de uma vez, e o relógio de visibilidade de todas elas começa a correr no mesmo instante. O Promise.allSettled as dispara em paralelo, mas cada uma depende de uma chamada ao user-service e de um envio de e-mail de 8 segundos — e o event loop, as conexões HTTP e o próprio provedor de e-mail impõem serialização parcial. Quando o lote passa dos 30 segundos, o SQS considera as mensagens ainda não processadas abandonadas, e as devolve à fila. Outro pod as recebe e envia o e-mail de novo. O primeiro pod termina, chama DeleteMessageCommand com um ReceiptHandle já expirado, e a exclusão falha em silêncio.

Esse é o caso concreto de uma propriedade que vale sempre: SQS Standard garante entrega ao menos uma vez, nunca exatamente uma. Duplicatas também ocorrem sem lentidão alguma — por redundância interna do serviço ou por um pod morto entre o envio e o delete. Todo consumidor precisa ser idempotente por construção, e este não é: processarPedidoConfirmado envia o e-mail sem verificar se já enviou.

Três correções, complementares:

  • Idempotência — registrar o efeito antes de causá-lo, com uma chave derivada do evento (notificacao:pedido-confirmado:{pedidoId}) gravada de forma atômica no Redis com SET NX. Se a chave já existe, a mensagem é apenas deletada.
  • Estender a visibilidade durante o processamento — o ChangeMessageVisibilityCommand, aliás importado no topo do arquivo e nunca usado, existe exatamente para isso: renovar o prazo enquanto o trabalho continua.
  • Reduzir o lote — buscar menos mensagens por vez encurta a janela, ao custo de mais chamadas.

Repare que o max_receives = 3 da fila também entra em ação: se o atraso persistir, a mesma mensagem é entregue três vezes (três e-mails) e depois cai na DLQ, disparando o alarme do CloudWatch — que é como o time descobre, tarde, o que os clientes já perceberam.

Exercício 4

Em ProdutoService.buscarPorId, a chamada ao banco é protegida assim:

const produto = await dbCircuitBreaker.executar(
  () => ProdutoRepository.buscarPorId(id),
  () => null    // fallback
);

O RDS do catálogo fica indisponível. Que resposta HTTP o cliente recebe ao acessar a página de um produto? Por que essa resposta é problemática?

Ver resposta

✓ Resposta: Recebe 404 Not Found — "Produto não encontrado". O fallback devolve null, e a rota trata null como ausência do registro, porque não tem como distinguir "não existe" de "não consegui verificar".

A resposta é problemática porque 404 é uma afirmação sobre o mundo: diz que o recurso não existe. O correto para uma dependência indisponível é 503 Service Unavailable, que informa uma condição temporária do servidor — acompanhado de Retry-After, exatamente como o contrato OpenAPI de Projeto Capstone: Arquitetura do Sistema Completo já previa ao declarar a resposta ServicoIndisponivel.

As consequências de errar esse código são concretas e passam longe do usuário final:

  • Buscadores desindexam. O Googlebot lê 404 como "esta página deixou de existir" e remove a URL do índice. Uma indisponibilidade de uma hora pode custar semanas de tráfego orgânico, porque a reindexação não é imediata.
  • O SLO não registra o incidente. O SLO do gateway conta como falha apenas status =~ "5.."; um mar de 404 mantém o indicador em 100%, e o error budget não é consumido. O painel diz que está tudo bem enquanto o catálogo inteiro está fora.
  • O alerta não dispara, pelo mesmo motivo — e o time descobre por reclamação de cliente.
  • Clientes HTTP não tentam de novo. Retry sobre 404 não faz sentido, então nenhuma camada acima reage; sobre 503, o comportamento padrão é justamente repetir mais tarde.

A correção é o fallback preservar a natureza do erro, deixando a rota decidir:

const produto = await dbCircuitBreaker.executar(
  () => ProdutoRepository.buscarPorId(id),
  () => { const e = new Error('Catálogo indisponível'); e.status = 503; throw e; }
);

Vale notar o contraste com listar, no mesmo arquivo: ali não há fallback, e o erro do circuit breaker se propaga até o handler global, que responde 500. Ironicamente, o método sem tratamento se comporta melhor que o método "protegido" — um fallback que apaga a informação de erro é pior do que nenhum fallback.

Exercício 5

Compare o encerramento dos dois serviços. O catalog-service registra process.on('SIGTERM', …) com servidor.close() e timeout de 30 s; o Deployment do order-service tem preStop: sleep 10 e terminationGracePeriodSeconds: 60. E o notification-service? O que acontece com ele durante um rollout?

Ver resposta

✓ Resposta: O notification-service não trata sinal nenhum. Seu consumidor é um while (true) em iniciarConsumidor, sem qualquer process.on('SIGTERM'). Em um rollout, o SIGTERM encerra o processo no meio do que estiver fazendo.

O ponto exato em que o pod morre determina o estrago:

  • Entre o envio do e-mail e o DeleteMessageCommand — o pior caso. O e-mail foi enviado, mas a mensagem não foi removida da fila: ela reaparece após os 30 segundos de visibilidade e o cliente recebe a confirmação duas vezes. Como todo deploy encerra pods, isso acontece a cada deploy.
  • Durante o long polling — inofensivo: nenhuma mensagem em mãos, nada se perde.
  • No meio de um lote de 10 — as mensagens não concluídas voltam para a fila e são reprocessadas, com o mesmo risco de duplicidade das que já haviam sido enviadas.

Repare que o serviço não recebe tráfego HTTP, então nada disso aparece como erro 5xx, latência ou readiness reprovando. O rollout é reportado como bem-sucedido e as métricas ficam limpas — o único sinal é o cliente recebendo e-mail repetido.

O encerramento correto para um consumidor de fila é parar de buscar mensagens novas e terminar as que já estão em mãos:

let encerrando = false;

async function iniciarConsumidor(nomeFila, config) {
  while (!encerrando) {
    const { Messages = [] } = await sqs.send(new ReceiveMessageCommand({ … }));
    await Promise.allSettled(Messages.map(m => processarMensagem(m, nomeFila, config)));
  }
  logger.info({ fila: nomeFila }, 'Consumidor encerrado');
}

process.on('SIGTERM', () => { encerrando = true; });

O WaitTimeSeconds: 20 do long polling impõe o tempo mínimo dessa saída: o loop só reavalia a condição quando a chamada em curso retorna, o que pode levar até 20 segundos, mais o tempo do último lote. O terminationGracePeriodSeconds desse Deployment precisa acomodar essa soma com folga — e vale notar que o preStop: sleep usado nos serviços HTTP não tem função aqui: ele existe para cobrir a propagação da remoção do endpoint no load balancer, e um consumidor de fila não está atrás de nenhum.

Comentários

Mais em DevOps

Trabalhando em Equipe com Git Flow
Trabalhando em Equipe com Git Flow

Git Flow como modelo de organização de branches: os papéis de main e develop…

Publicando Imagens no Docker Hub e GitHub Container Registry
Publicando Imagens no Docker Hub e GitHub Container Registry

Publicando imagens em registros de container: convenção de nomes no Docker Hub…

Geração de Infraestrutura com IA
Geração de Infraestrutura com IA

Geração de Terraform e manifestos Kubernetes por LLM com validação no laço: os…