Explorando configuração em camadas e simultaneidade com Rust

Jan 30 2023
Como eu cheguei aqui? Até recentemente - a maioria dos projetos em que eu estava envolvido tinha seus códigos de back-end escritos em C++/Java/Go e com ênfase crescente na mudança para o ecossistema/padrões de Microservices/Kubernetes. Mas nunca paramos de explorar outras alternativas e, cerca de 1 mês atrás, comecei a me perguntar o que poderia ser feito com a linguagem/ferramentas de programação Rust (que inicialmente apareceu em algumas postagens dentro do meu feed de interesse de TI) a esse respeito.

Como eu cheguei aqui?

Até recentemente - a maioria dos projetos em que eu estava envolvido tinha seus códigos de back-end escritos em C++/Java/Go e com ênfase crescente na mudança para o ecossistema/padrões de Microservices/Kubernetes. Mas nunca paramos de explorar outras alternativas e, cerca de 1 mês atrás, comecei a me perguntar o que poderia ser feito com a linguagem/ferramentas de programação Rust (que inicialmente apareceu em algumas postagens dentro do meu feed de interesse de TI) a esse respeito. E se é fácil/difícil ter algum executável final não gordo (e eu queria que fosse vinculado estaticamente para eliminar quaisquer dependências de bibliotecas compartilhadas) para direcionar várias arquiteturas de 64 bits, que eram x86_64 e aarch64 no meu caso . E assim começou minha aventura no Rust (assim como o retorno à redação de artigos técnicos).

Código fonte

Pode ser baixado/clonado de:https://github.com/andrejusc/rust-example-config-concurrency

Configuração do meu ambiente

Por um bom tempo em laptops baseados em Intel/AMD (esperando que as pessoas também possam adotar o conteúdo abaixo para Macs com CPU não Intel), prefiro executar várias máquinas virtuais (VM) na virtualização do VirtualBox . Portanto, essas VMs podem ser facilmente ajustadas em termos de memória virtual/cpu/pastas compartilhadas/etc. necessidades e clonado/atualizado/destruído conforme necessário. Abordagem semelhante que tomei ao começar a experimentar com Rust e isso me levou a esta configuração:

  • VM executando o Ubuntu 22.04.1 LTS com opção de área de trabalho ( distro Xubuntu ) no VirtualBox 7.0.2 no host Windows 11 Pro com processador Intel i7 de 64 bits. Atribuí a ele 2 CPUs, 8 GB de RAM e um disco dinâmico de 100 GB.
  • Inside VM — Rust da versão para rustc 1.67.0 (fc594f156 2023–01–24)
  • Dentro da VM — Visual Studio Code IDE v1.74.2

Do mergulho profundo anterior/desenvolvimento com microsserviços baseados em Go e vários aspectos da aplicação de 12 fatores - eu queria explorar esses objetivos aqui sem ainda produzir um microsserviço totalmente funcional e alguma imagem do Docker para ele (possivelmente descreverei esse aspecto em algum artigo de acompanhamento) :

  • Como obter a configuração em camadas por meio de arquivos YAML de forma que haja alguma configuração base e possa haver outra, que complementa/substitui alguns valores na base?
  • Como oferecer suporte ao conceito de diferentes funções possíveis de ambientes de tempo de execução, em que a função significaria Dev (para desenvolvimento) ou QA (para teste) ou qualquer outra de alguma infraestrutura DevOps definida e, portanto, apenas alguns subconjuntos de valores de configuração poderiam ser diferentes da base um para atender às suas especificidades?
  • Como externalizar a configuração, para que fique fora do executável e possa ser conectado na inicialização?
  • Como ter log estruturado fácil e ajustável no formato JSON, qual saída poderia caber em algumas ferramentas de análise de log?
  • Como fazer, por exemplo, até 10 chamadas assíncronas GET REST API simultâneas para alguns https:// endpoint(s) com capacidade máxima de chamadas em determinado momento como 5 (para não explodir o cliente ou o servidor com carga pesada)?

Antes de prosseguir - abaixo é apresentada a estrutura em árvore do projeto, onde o Rust load foi inicializado dentro da pasta service e a pasta env foi criada fora da pasta src (veja mais na parte de configuração em camadas abaixo):

$ tree -a -I 'target|\.git'
.
├── .github
│   └── workflows
│       ├── rust.yml
│       └── slack-notify.yml
├── .gitignore
├── LICENSE
├── README.md
└── service
    ├── build.rs
    ├── Cargo.lock
    ├── Cargo.toml
    ├── env
    │   ├── logging-dev.yml
    │   ├── logging.yml
    │   ├── service-dev.yml
    │   └── service.yml
    └── src
        ├── custom_tracing_layer.rs
        └── main.rs

Avançando para mostrar o estado final das dependências usadas dentro do arquivo cargo.toml , para que você possa ver o quadro geral primeiro (e observar talvez já familiar por outras entradas de projetos):

[dependencies]
async-mutex = "1.4.0"
chrono = { version = "0.4.23", features = ["serde"] }
config = "0.13.3"
native-tls = { version = "0.2.11", features = ["vendored"] }
reqwest = { version = "0.11.14", features = ["json"] }
serde_json = "1.0.91"
tokio = { version = "1.24.2", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.16"

[build-dependencies]
copy_to_output = "2.0.0"
glob = "0.3.1"

No arquivo cargo.toml acima, você pode ver que a dependência de configuração está incluída. E tal permite ter múltiplas fontes de configuração de forma similar a tal:

/// Makes merged main config. WIll panic if ENVROLE env variable is not set.
fn make_config() -> Config {
    let env_role = env::var("ENVROLE").unwrap();
    Config::builder()
        .add_source(File::new("env/service", FileFormat::Yaml))
        .add_source(File::new(("env/service-".to_string() + &env_role).as_str(), FileFormat::Yaml))
        .build()
        .unwrap()
}

A mesma abordagem é aplicada à configuração de registro, bem como com a configuração base proveniente do arquivo env/logging.yml e cortesia/substituição via ENVROLE como arquivo logging-dev.yml :

/// Makes merged logging config. WIll panic if ENVROLE env variable is not set.
fn make_log_config() -> Config {
    let env_role = env::var("ENVROLE").unwrap();
    Config::builder()
        .add_source(File::new("env/logging", FileFormat::Yaml))
        .add_source(File::new(("env/logging-".to_string() + &env_role).as_str(), FileFormat::Yaml))
        .build()
        .unwrap()
}

Para construir o código do projeto, estou usando o comando cargo build :

RUSTFLAGS='-C target-feature=+crt-static' cargo build - release - target x86_64-unknown-linux-gnu

Log estruturado em JSON

Para registro estruturado no formato JSON, adotei a abordagem descrita por Bryan Burgers em seus 2 artigos:

  • Log personalizado em Rust usando rastreamento e assinante de rastreamento
  • Log personalizado em Rust usando rastreamento e assinante de rastreamento, parte 2

Eu também adicionei uma pequena função para formatar data/hora no fuso horário UTC e como ISO8601:

fn iso8601(st: &std::time::SystemTime) -> String {
  let dt: DateTime<Utc> = (*st).into();
  // formats like "2023-01-27T20:28:50.004726747Z"
  format!("{dt:?}")
}

// Convert the values into a JSON object
        let mut fields = BTreeMap::new();
        let mut visitor = JsonVisitor(&mut fields);
        event.record(&mut visitor);

        // Output the event in JSON
        let output = serde_json::json!({
          "timestamp": iso8601(&SystemTime::now()),
          "level": event.metadata().level().as_str(),
          "target": event.metadata().target(),
          "name": event.metadata().name(),
          "fields": fields,
        });
        // println!("{}", serde_json::to_string_pretty(&output).unwrap());        
        println!("{}", serde_json::to_string(&output).unwrap());

level: info

level: debug

let log_config = make_log_config();
    // Enables spans and events with levels `INFO` and below:
    // let level_filter = LevelFilter::INFO;
    let level_str = log_config.get_string("level").unwrap();
    let level_filter:LevelFilter = match FromStr::from_str(&level_str) {
        Ok(filter) => {
            filter
        },
        Err(error) => {
            panic!("Problem parsing log level: {error:?}, supplied level: {level_str}");
        }
    };
    // Set up how `tracing-subscriber` will deal with tracing data.
    tracing_subscriber::registry().with(
        CustomTracingLayer.with_filter(level_filter)
            .with_filter(filter::filter_fn(|metadata| {
                metadata.target().eq("service")
            }))).init();

info!("Main entry point...");
    warn!("warning here");
    debug!("debug here");

$ export ENVROLE=dev

{"fields":{"message":"Main entry point…"},"level":"INFO","name":"event src/main.rs:83","target":"service","timestamp":"2023–01–27T21:33:46.036251767Z"}
{"fields":{"message":"warning here"},"level":"WARN","name":"event src/main.rs:84","target":"service","timestamp":"2023–01–27T21:33:46.036261313Z"}
{"fields":{"message":"debug here"},"level":"DEBUG","name":"event src/main.rs:85","target":"service","timestamp":"2023–01–27T21:33:46.036300410Z"}

Simultaneidade e mutexes

Minhas leituras iniciais sobre/prático Rust e suporte assíncrono me fizeram selecionar Tokio runtime (você pode ver a dependência tokio no arquivo cargo.toml acima ). E assim tendo a função principal definida como assíncrona :

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
<body omitted>
}

Além disso, como você se lembra, afirmei no início que preciso de um executável vinculado estaticamente no final, portanto, como dependência adicional envolvida, habilitei o recurso fornecido para suporte a tls nativo no arquivo cargo.toml :

native-tls = { version = "0.2.11", features = ["vendored"] }

// Mock possible list of queries to perform
    let queries: [&str; 10] = [
        "https://petstore.swagger.io/v2/pet/1",
        "https://petstore.swagger.io/v2/pet/1",
        <other repetitive entries omitted>
    ];

Para facilitar as tarefas geradas com o manuseio do tokio, selecionei JoinSet:

let mut set = JoinSet::new();

let concur_max = config.get_int("concurrency").unwrap();
    let data = Arc::new(async_mutex::Mutex::new(0));

// Name your user agent after your app
    static APP_USER_AGENT: &str = concat!(
        env!("CARGO_PKG_NAME"),
        "/",
        env!("CARGO_PKG_VERSION"),
    );

    let client = reqwest::Client::builder()
        .user_agent(APP_USER_AGENT)
        // .connection_verbose(true)
        .build()?;

Depois disso - o loop de invocação principal se parece com isso, ou seja, verificando se alguma das tarefas geradas anteriormente foi concluída dentro do tempo limite desejado e, em seguida, diminuindo o contador (decidi entrar em pânico no tempo limite atualmente):

let mut i: u32 = 0;
    let timeout = time::Duration::from_secs(config.get_int("timeout").unwrap().try_into().unwrap());
    for query in queries {
        i += 1;
        info!("Request id: {}, Making request to: {}", i, query);
        {
            // Lock to be gone when data will go out of scope
            let mut data = data.try_lock().unwrap();
            if *data >= concur_max {
                match tokio::time::timeout(timeout, set.join_next()).await {
                    Err(why) => panic!("! {why:?}"),
                    Ok(opt) => {
                        let call_result: CallResult = opt.unwrap().unwrap();
                        let resp = call_result.resp;
                        info!("Request id: {}, Status: {:#?}", call_result.opid, resp.status());
                        *data -= 1;
                        info!("join1 concur_current: {}", *data);
                    },
                }
            }
        }
        let fut = background_op(i, client.clone(), query);
        set.spawn(fut);
        {
            let mut data = data.try_lock().unwrap();
            *data += 1;
            info!("concur_current: {}", *data);
        }
    }

// Allow every spawned task to complete without timeout constraint
    while let Some(res) = set.join_next().await {
        let call_result = res.unwrap();
        let resp = call_result.resp;
        info!("Request id: {}, Status: {:#?}", call_result.opid, resp.status());
        {
            let mut data = data.try_lock().unwrap();
            *data -= 1;
            info!("join2 concur_current: {}", *data);
        }
    }

E para completar a imagem do meu experimento - como é chamado acima da função background_op(<omitted parameters>) , que adiciona atrasos extras (20 segundos para a solicitação nº 4 e 15 segundos para a solicitação nº 6) em 2 invocações para demonstrar o impacto:

struct CallResult {
    opid: u32,
    resp: Response
}

/// Wrapper for underlying reqwest client call to have operation id assigned.
async fn background_op(id: u32, client: reqwest::Client, query: &str) -> CallResult {
    let call_result = CallResult {
        opid: id,
        resp: client.get(query).send().await.unwrap(),
    };
    // Those delays below are to prove that concurrency works in expected fashion
    if id == 4 {
        let twenty_secs = time::Duration::from_secs(20);
        info!("Request id: {}, Sleep 20 secs", id);
        tokio::time::sleep(twenty_secs).await;
    }
    if id == 6 {
        let fifteen_secs = time::Duration::from_secs(15);
        info!("Request id: {}, Sleep 15 secs", id);
        tokio::time::sleep(fifteen_secs).await;
    }
    call_result
}

Antes de tentar compilar e executar o código — é sempre uma boa prática tentar fazer o lint. Aqui é feito com carga clippy :

$ RUSTFLAGS='-C target-feature=+crt-static' cargo clippy - release - target x86_64-unknown-linux-gnu

$ RUSTFLAGS='-C target-feature=+crt-static' cargo run - release - target x86_64-unknown-linux-gnu

{"fields":{"message":"Request id: 1, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391607261Z"}
{"fields":{"message":"concur_current: 1"},"level":"INFO","name":"event src/main.rs:160","target":"service","timestamp":"2023–01–29T15:19:43.391662265Z"}
{"fields":{"message":"Request id: 2, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391667526Z"}
{"fields":{"message":"concur_current: 2"},"level":"INFO","name":"event src/main.rs:160","target":"service","timestamp":"2023–01–29T15:19:43.391670661Z"}
{"fields":{"message":"Request id: 3, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391672537Z"}
{"fields":{"message":"concur_current: 3"},"level":"INFO","name":"event src/main.rs:160","target":"service","timestamp":"2023–01–29T15:19:43.391676427Z"}
{"fields":{"message":"Request id: 4, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391678527Z"}
{"fields":{"message":"concur_current: 4"},"level":"INFO","name":"event src/main.rs:160","target":"service","timestamp":"2023–01–29T15:19:43.391680884Z"}
{"fields":{"message":"Request id: 5, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391682592Z"}
{"fields":{"message":"concur_current: 5"},"level":"INFO","name":"event src/main.rs:160","target":"service","timestamp":"2023–01–29T15:19:43.391685031Z"}
{"fields":{"message":"Request id: 6, Before calling to: https://petstore.swagger.io/v2/pet/1"},"level":"INFO","name":"event src/main.rs:138","target":"service","timestamp":"2023–01–29T15:19:43.391686959Z"}

{"fields":{"message":"Request id: 2, Status: 200"},"level":"INFO","name":"event src/main.rs:148","target":"service","timestamp":"2023–01–29T15:19:43.839642699Z"}
{"fields":{"message":"join1 concur_current: 4"},"level":"INFO","name":"event src/main.rs:150","target":"service","timestamp":"2023–01–29T15:19:43.839850424Z"}

{"fields":{"message":"Request id: 10, Status: 200"},"level":"INFO","name":"event src/main.rs:168","target":"service","timestamp":"2023–01–29T15:19:44.060471331Z"}
{"fields":{"message":"join2 concur_current: 2"},"level":"INFO","name":"event src/main.rs:172","target":"service","timestamp":"2023–01–29T15:19:44.060856547Z"}
{"fields":{"message":"Request id: 6, Status: 200"},"level":"INFO","name":"event src/main.rs:168","target":"service","timestamp":"2023–01–29T15:19:58.945672861Z"}
{"fields":{"message":"join2 concur_current: 1"},"level":"INFO","name":"event src/main.rs:172","target":"service","timestamp":"2023–01–29T15:19:58.945742575Z"}
{"fields":{"message":"Request id: 4, Status: 200"},"level":"INFO","name":"event src/main.rs:168","target":"service","timestamp":"2023–01–29T15:20:03.849763114Z"}
{"fields":{"message":"join2 concur_current: 0"},"level":"INFO","name":"event src/main.rs:172","target":"service","timestamp":"2023–01–29T15:20:03.849831899Z"}

Tamanho do arquivo executável e verificação do aspecto construído estaticamente

Se verificar o tamanho do arquivo executável:

$ ls -l target/x86_64-unknown-linux-gnu/release/service
-rwxrwxr-x 2 user user 13735312 Jan 29 15:19 target/x86_64-unknown-linux-gnu/release/service

E verifique se há construção estática:

$ file target/x86_64-unknown-linux-gnu/release/service
target/x86_64-unknown-linux-gnu/release/service: ELF 64-bit LSB pie executable, x86–64, version 1 (GNU/Linux), static-pie linked, BuildID[sha1]=46b2fc034555d117fb1b42fcfcdd16559190a5b0, for GNU/Linux 3.2.0, with debug_info, not stripped

$ ldd target/x86_64-unknown-linux-gnu/release/service
 statically linked

Breve conclusão e pensamento futuro

Com base em minha breve exploração com Rust - gosto do resultado que ele produz e do suporte disponível para casos de uso assíncronos da vida real.

Para a dependência de configuração usada , já preenchi a solicitação de aprimoramento para capacidade de imprimir qual valor de configuração efetivo veio de qual fonte.

Como a atividade acima também foi para eu explorar as ações do GitHub em relação ao Rust - na pasta .github/workflows, tenho 2 arquivos YAML para automatizar o cache de compilação/linting/construção e criar notificações para algum canal do Slack.