1 de junho de 202612 min de leitura

Configuração dinâmica para serviços Swift cloud native: como evitar torn reads e operar em Kubernetes

Joe Heck, Swift Documentation Workgroup Member, Apple

Cloud Native Computing Foundation

Banner - Configuração dinâmica para serviços Swift cloud native: como evitar torn reads e operar em Kubernetes

TL;DR: Este artigo analisa a biblioteca Swift Configuration e seus benefícios para serviços Swift em Kubernetes. Diferente da abordagem ad hoc com variáveis de ambiente e arquivos YAML, ela oferece um modelo de providers com prioridade explícita, recarga a quente de ConfigMaps sem torn reads e snapshots imutáveis. Conclusão principal: a biblioteca preenche uma lacuna crítica de maturidade operacional para Swift no ecossistema cloud native, eliminando riscos de race conditions em configuração.

Serviços Swift modernos rodam cada vez mais lado a lado com as mesmas stacks de infraestrutura cloud native que alimentam grande parte do ecossistema Kubernetes atual — incluindo ConfigMaps, workloads containerizados, deployments declarativos e gerenciamento de ciclo de vida de serviços. Projetos como Prometheus e OpenTelemetry ajudaram a padronizar observability e patterns operacionais em sistemas distribuídos, mas o gerenciamento de configuração em serviços Swift permaneceu comparativamente ad hoc.

Swift é ativamente usado para construir serviços de produção em Linux, beneficiando-se de concorrência moderna, garantias de memory e data race safety, e forte performance. Na prática, no entanto, a configuração geralmente é montada manualmente — lendo variáveis de ambiente com ProcessInfo.environment e parsing direto de arquivos YAML, JSON ou formatos similares.

Essas abordagens funcionam para casos simples, mas deixam várias preocupações operacionais sem solução:

  • Não há um modelo padrão para compor múltiplas fontes de configuração com ordem de prioridade explícita.
  • Recarregar configuração de um volume backed por ConfigMap pode introduzir torn reads durante tráfego ao vivo.
  • Uma única requisição pode observar um estado de configuração inconsistente se uma recarga ocorrer no meio do processamento.

A Swift Configuration foi construída para resolver esses gaps. Ela oferece um modelo de providers em camadas com regras de precedência explícitas, recarga a quente baseada em arquivos projetada para volumes de ConfigMap no estilo Kubernetes, e snapshots imutáveis de configuração que garantem que leitores observem uma visão consistente durante atualizações em runtime.

Este post percorre esses patterns usando um serviço Kubernetes completo como exemplo.

Como funciona a leitura de configuração: readers, providers e hierarquia?

A biblioteca Swift Configuration separa a leitura de configuração do fornecimento. Um ConfigReader recebe uma lista ordenada de tipos que conformam a ConfigProvider. O primeiro provider que tem um valor para uma dada chave tem precedência. Você compõe explicitamente a cadeia de prioridade.

Em produção, é comum empilhar providers com a prioridade mais alta primeiro:

// Providers são inicializados assincronamente: EnvironmentVariablesProvider lê
// o ambiente do processo e o arquivo .env na inicialização, então a inicialização é assíncrona.
async let staticProviders: [(any ConfigProvider)] = [
    CommandLineArgumentsProvider(),
    EnvironmentVariablesProvider(),
    EnvironmentVariablesProvider(environmentFilePath: ".env", allowMissing: true),
    InMemoryProvider(values: [
        "log.level": "info",
        "config.filePath": "/etc/config/appsettings.yaml",
        "config.pollIntervalSeconds": 15,
        "http.serverName": "my-service",
    ]),
]

No exemplo acima, argumentos de CLI sobrescrevem variáveis de ambiente, que por sua vez sobrescrevem um arquivo .env, com defaults in-memory como fallback. A ordem de prioridade é explícita na ordenação dos providers. Não há comportamento implícito — adicionar ou reordenar fontes é uma alteração de uma linha.

Colete e passe um ou mais providers para um ConfigReader, que você usa para acessar valores:

let initConfig = ConfigReader(providers: staticProviders)

Você lê valores do provider de configuração:

let logLevel = initConfig.string(
    forKey: "log.level",
    as: Logger.Level.self,
    default: .info
)

Chaves na Swift Configuration usam notação de ponto para expressar hierarquia. Scoped readers permitem que um componente leia de uma subárvore da configuração sem precisar do caminho completo da chave:

let httpConfig = initConfig.scoped(to: "http") // lê "http.port", "http.host" como apenas "port", "host"

Para providers que não suportam dot syntax nativamente, como variáveis de ambiente e arquivos .env, as chaves com notação de ponto são traduzidas automaticamente. A chave log.level, por exemplo, mapeia para a variável de ambiente LOG_LEVEL. O mesmo nome de chave funciona em todos os tipos de provider sem nenhum mapeamento manual.

Recarga a quente a partir de um ConfigMap

Providers estáticos lidam com valores de configuração de bootstrap que não mudam em runtime, mas alguns valores precisam mudar enquanto o serviço está rodando. Para valores que devem ser atualizados sem restart — feature flags, rate limits, tamanhos de connection pool — use um provider dinâmico.

ReloadingFileProvider é um provider embutido que observa um arquivo em busca de mudanças e fornece snapshots consistentes a cada atualização do arquivo. No Kubernetes, monte um ConfigMap como volume e aponte o provider para o caminho montado. O Kubernetes lida com a atualização do arquivo; o ReloadingFileProvider lida com a recarga.

A Swift Configuration acompanha providers YAML e JSON. Qualquer um pode conformar a ConfigProvider para adicionar novos formatos — por exemplo, a comunidade já construiu um leitor TOML. O exemplo a seguir configura um provider de recarga baseado em YAML que lê o caminho do arquivo da configuração estática:

let reloadingProvider = try await ReloadingFileProvider<YAMLSnapshot>(
    config: initConfig.scoped(to: "config")
)

Com o provider scoped à chave config, ele lê config.filePath e config.pollIntervalSeconds do ConfigReader inicial.

Monte um configuration reader combinado que empilha o provider dinâmico no topo, para uso em todo o serviço:

let config = ConfigReader(
    providers: [reloadingProvider] + staticProviders
)

Do lado do Kubernetes, a configuração vive em um ConfigMap. A estrutura espelha o que seu código espera — chaves YAML aninhadas correspondem às chaves em notação de ponto que você passa para o ConfigReader:

---
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-service-config
data:
  appsettings.yaml: |
    app:
      name: "production"    

O manifesto do Deployment monta o volume do ConfigMap no container e define o caminho do arquivo que o provider observará — o manifesto completo está no repositório de exemplo:

containers:
- name: http-server
  image: reloading-example:latest
  env:
  - name: LOG_LEVEL
    value: "debug"
  # localização do arquivo de configuração montado a partir do ConfigMap
  - name: CONFIG_FILE_PATH
    value: "/etc/config/appsettings.yaml"
  volumeMounts:
  - name: config-volume

Quando você executa kubectl apply com um ConfigMap atualizado, o Kubernetes propaga a mudança para o volume montado. Isso geralmente leva de um a dois minutos, impulsionado pelo período de sync do kubelet e pelo time-to-live do cache de ConfigMap do cluster. O intervalo de polling controla a rapidez com que o provider detecta uma mudança no arquivo, mas não reduz a janela de propagação do Kubernetes. Um intervalo de polling de 15 segundos é o default: baixo o suficiente para capturar a mudança logo após o arquivo ser atualizado sem colocar carga desnecessária no filesystem.

Observando valores específicos

Para casos em que seu serviço precisa reagir no momento em que um valor de configuração muda, em vez de apenas ler o novo valor na próxima requisição, o ConfigReader expõe uma API de watch baseada em async/await do Swift.

O exemplo a seguir é uma tarefa long-running simples que conforma ao protocolo Service da biblioteca Swift Service Lifecycle. Ela usa a API watch do ConfigReader para logar atualizações quando o arquivo muda.

struct ConfigWatchReporter: Service {
    let config: ConfigReader
    let logger: Logger
    func run() async throws {
        try await self.config.scoped(to: "app")
            .watchString(forKey: "name", default: "unset") { updates in
                for try await update in updates.cancelOnGracefulShutdown() {
                    logger.info("Recebeu uma mudança de configuração: \(update)")
                }
            }
    }
}

A API de watch fornece atualizações como uma sequência assíncrona de valores; cancelOnGracefulShutdown() garante que o serviço finalize de forma limpa. Registre o reporter junto com o ReloadingFileProvider ao montar o app:

let configReporter = ConfigWatchReporter(config: config, logger: logger)
let app = Application(
    router: router,
    configuration: ApplicationConfiguration(reader: config.scoped(to: "http")),
    services: [
        reloadingProvider,
        configReporter,
    ],
    logger: logger
)

Diagrama de arquitetura mostrando o fluxo de configuração estática e dinâmica

Snapshots consistentes e prevenção de torn reads

Recarga a quente — seja por polling do ReloadingFileProvider ou observada via API de watch — introduz uma sutileza que vale a pena abordar diretamente: se duas leituras na mesma requisição observarem versões diferentes da configuração, você tem um torn read. Imagine um middleware que lê um limite de rate limiting da config, e o handler que roda depois lê a mesma chave e vê um valor diferente porque o arquivo recarregou entre as duas leituras. Torn reads são não determinísticos e confiavelmente difíceis de reproduzir em testes.

A Swift Configuration previne isso através de snapshots. Um configuration snapshot é uma captura imutável do estado da configuração em um ponto no tempo. Atomicidade é uma garantia em nível de protocolo. Todo provider deve entregá-la, não apenas o ReloadingFileProvider. Quando uma recarga dispara, o provider substitui a referência do snapshot em uma única atribuição atômica — o mapa chave-valor inteiro de uma vez, não entrada por entrada. Nenhum leitor pode observar uma atualização parcial.

Snapshots também protegem contra um risco relacionado: recargas malformadas. Se uma recarga produz um resultado inválido ou não parseável, o ReloadingFileProvider retém o último snapshot válido em vez de substituí-lo. Seu serviço continua rodando com a configuração anterior válida, e o provider loga a recarga com falha para que você investigue sem uma interrupção.

Se sua operação abrange múltiplas leituras e precisa de uma visão consistentemente garantida entre todas elas, capture um snapshot diretamente da API do provider.

Como tudo se encaixa: a integração com Hummingbird

Com cada peça individual estabelecida, aqui está como elas se montam em um serviço Kubernetes funcional usando Hummingbird.

Um exemplo funcional completo deste post, incluindo os manifestos Kubernetes, está disponível no diretório reloading-example do repositório.

A aplicação é montada primeiro a partir de uma configuração estática. Essa configuração estática bootstrap um provider de recarga, e então todos os providers são combinados para criar um configuration reader para a aplicação Hummingbird.

func buildApplication(initialConfigProviders: [(any ConfigProvider)]) async throws
    -> some ApplicationProtocol
{
    // Cria um configuration reader inicial para bootstrap de readers
    // que dependem dele, como um ReloadingFileProvider, e
    // configuração de logging.
    let initConfig = ConfigReader(providers: initialConfigProviders)
    
    let logger = {
        var logger = Logger(label: initConfig.string(
            forKey: "http.serverName",
            default: "default-HB-server"
        ))
        logger.logLevel = initConfig.string(
            forKey: "log.level",
            as: Logger.Level.self,
            default: .info)
        return logger
    }()
    
    // Cria um provider de configuração dinâmica que observa um arquivo
    // em busca de mudanças. Quando o arquivo muda, ele recarrega. O caminho
    // do arquivo e o intervalo de polling são lidos do configuration reader inicial.
    let reloadingProvider = try await ReloadingFileProvider<YAMLSnapshot>(
        config: initConfig.scoped(to: "config")
    )
    // Monta um configuration reader final que inclui
    // o provider dinâmico
    let config = ConfigReader(
        providers: [reloadingProvider] + initialConfigProviders,
        accessReporter: AccessLogger(logger: logger)
    )
    
    let configReporter = ConfigWatchReporter(
        config: config,
        logger: logger)
    
    // Monta as rotas do app.
    let router = try buildRouter(config: config)
    
    // Cria o app Hummingbird e adiciona os serviços a ele.
    // Isso roda um serviço background que observa mudanças no filesystem
    // para configuração, e outro que reporta mudanças em um valor
    // específico de configuração.
    let app = Application(
        router: router,
        configuration: ApplicationConfiguration(reader: config.scoped(to: "http")),
        services: [
            reloadingProvider,
            configReporter,
        ],
        logger: logger
    )
    return app
}

O padrão de duas fases — um ConfigReader de bootstrap seguido pelo reader completo — existe porque o ReloadingFileProvider precisa saber o caminho do arquivo e o intervalo de polling antes de poder iniciar. Ler esses valores dos providers estáticos iniciais e depois adicionar o provider dinâmico por cima evita uma dependência circular.

O ConfigReader completo usa um AccessLogger atrelado ao logger da aplicação. O AccessLogger registra cada acesso a valor de configuração e garante que secrets não sejam logados em texto puro.

Como começar

Para adicionar Swift Configuration ao seu próprio pacote, certifique-se de selecionar as traits para as funcionalidades que você quer habilitar ao adicionar a dependência ao Package.swift do seu projeto.

O exemplo a seguir mostra a ativação das traits para recarga, YAML e argumentos de linha de comando, além dos providers default:

.package(
    url: "https://github.com/apple/swift-configuration.git",
    from: "1.0.0",
    traits: [.defaults, "CommandLineArguments", "Reloading", "YAML"]
)

Como se envolver

A documentação para Swift Configuration está no Swift Package Index, um vídeo walkthrough está disponível no YouTube, e um post no Swift Blog introduz suas capacidades.

A Swift Configuration está na versão 1.0 e pronta para uso em produção. Abra uma issue se encontrar um problema, e consulte o guia CONTRIBUTING para submeter um pull request. O protocolo de provider é aberto — se sua stack precisa de um formato ou fonte que ainda não existe, você pode construí-lo. Adoraríamos que você nos contasse como isso funciona para seu cenário, e se há algo faltando que você precise para usar em serviços de produção escritos em Swift.

Perguntas Frequentes

  • O que diferencia a Swift Configuration de ler variáveis de ambiente com ProcessInfo.environment?
    A Swift Configuration oferece um modelo de providers hierárquicos com prioridade explícita, recarga a quente via ReloadingFileProvider sem torn reads, e snapshots imutáveis. Já a abordagem manual com ProcessInfo.environment é frágil, não tem suporte a hierarquia de chaves e não garante consistência durante atualizações em produção.

  • Como a Swift Configuration evita torn reads durante a recarga de um ConfigMap?
    Através de snapshots imutáveis: quando o ReloadingFileProvider detecta uma mudança no arquivo, ele substitui a referência do snapshot inteiro em uma única operação atômica. Nenhum leitor observa um estado parcial. Se a recarga falhar (arquivo inválido), o provider mantém o snapshot anterior válido, evitando outages.

  • A Swift Configuration funciona apenas com YAML ou suporta outros formatos de configuração?
    Ela já inclui providers nativos para YAML e JSON, e o protocolo ConfigProvider é público — a comunidade já criou um leitor para TOML. Você pode implementar seu próprio provider para qualquer formato ou fonte (banco de dados, API externa etc.).

  • Preciso recompilar o serviço Swift para alterar uma configuração em produção?
    Não. Com o ReloadingFileProvider apontando para um ConfigMap montado como volume no Kubernetes, você só precisa executar kubectl apply no ConfigMap atualizado. O provider detecta a mudança (por polling, com intervalo padrão de 15 segundos) e recarrega o snapshot sem reiniciar o processo.


Artigo originalmente publicado por Joe Heck, Swift Documentation Workgroup Member, Apple em Cloud Native Computing Foundation.

Gostou? Compartilhe:
Precisa de ajuda?Fale com nossos especialistas 👋
Avatar Walcew - Headset