Dev (Back & Front)ARTIGO

ASP.NET Core + Docker Compose: implementando soluções Web multi-containers

O autor mostra neste artigo como implementar soluções Web multi-containers com o ASP.NET Core e o Docker Compose.

Contando com suporte às principais plataformas de desenvolvimento da atualidade, containers DockerDocker46 conteúdosE o Docker Swarm? Contextos e motivadores diáriosDevSecOps · ago 2024Automatizando o ambiente de desenvolvimento e testes com DockerDevSecOps · mai 2019MySQL + Adminer + Docker Compose: montando rapidamente um ambiente para usoData · abr 2019Ver tudo em DevSecOps vêm se tornando uma presença frequente em projetos com os mais variados escopos. Dentre as vantagens oferecidas por esta tecnologia, estão o isolamento entre aplicações, uma utilização mais racional de recursos computacionais, além de maior agilidade em processos de deployment.

A geração de um novo container para a execução de uma aplicação depende de uma imagem criada previamente. Trata-se de um processo extremamente simples e que já abordei envolvendo diferentes cenários nos seguintes artigos:

Mas e se o projeto em questão depender de outros serviços, havendo ainda a necessidade de uso de containers com todas as partes envolvidas?

Num cenário mais simplista, criaríamos containers um a um, efetuando também os ajustes de configuração necessários para que a aplicação principal acesse corretamente cada serviço. Embora eficaz, esta abordagem pode se mostrar improdutiva em projetos complexos que envolvam diversas dependências. A geração individual de vários containers durante um deployment é um procedimento trabalhoso e, sem sombra de dúvidas, sujeito a erros.

Como simplificar todo este processo?

A resposta para esta pergunta está no Docker Compose, um serviço do próprio Docker voltado à criação e execução conjunta dos múltiplos containers de uma aplicação. Tal capacidade facilita o deployment de um projeto em diferentes ambientes. Além disso, o Docker Compose é considerado uma alternativa extremamente útil em cenários que envolvam a implementação de uma arquitetura de micro serviços

Este artigo traz um exemplo de utilização do Docker Compose em conjunto com o ASP.NET.NET113 conteúdosNovidades do .NET 9 – o tipo genérico OrderedDictionaryDev (Back & Front) · dez 2024Novidades .NET 10: novas formas de uso do .NET CLIDev (Back & Front) · dez 2025Novidades do .NET 5: implementando um proxy reverso com YARP + ASP.NET 5Dev (Back & Front) · nov 2020Ver tudo em Dev (Back & Front) Core 2.0. Para esta aplicação, serão criados dois containers Docker: um contendo o próprio site correspondente ao projeto, enquanto no segundo estará uma instância do Redis empregada no armazenamento de dados em cache.

Implementando a aplicação para testes

O projeto descrito neste artigo (SiteHeroisMarvel) está baseado no uso de Razor Pages, uma nova estrutura para implementação de soluções Web introduzidas com o ASP.NET Core 2.0. Esta aplicação acessará a API gratuita da Marvel Comics, armazenando em cache dados sobre super-heróis através do Redis (solução NoSQLNoSQL9 conteúdosSQL ou NoSQL: eis a questão!!Data · mar 2020DBA Developers Summit 2019: O que você precisa saber para modelar bancos de dados NoSQLData · mai 2019ElasticSearch… Duas coisas que você precisa saber!Data · mai 2020Ver tudo em Data e open source do tipo chave-valor).

Uma implementação similar a esta já foi detalhada no seguinte artigo:

As fontes do projeto SiteHeroisMarvel (criado a partir do Visual Studio Code) já se encontram no GitHub:

Na listagem a seguir, é possível observar que o uso de cache com Redis foi ativado no método ConfigureServices da classe Startup, com isto acontecendo por meio de uma chamada à operação AddDistributedRedisCache:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

namespace SiteHeroisMarvel
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            // Ativando o uso de cache via Redis
            services.AddDistributedRedisCache(options =>
            {
                options.Configuration =
                    Configuration.GetConnectionString("ConexaoRedis");
                options.InstanceName = "TesteRedisCache";
            });
            
            services.AddMvc();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }
            else
            {
                app.UseExceptionHandler("/Error");
            }

            app.UseStaticFiles();

            app.UseMvc();
        }
    }
}

O tipo Personagem contém as informações que serão exibidas sobre heróis Marvel (e também vinculadas ao cache):

namespace SiteHeroisMarvel
{
    public class Personagem
    {
        public string Nome { get; set; }
        public string Descricao { get; set; }
        public string UrlImagem { get; set; }
        public string UrlWiki { get; set; }
    }
}

Já na listagem a seguir está o código para a exibição de dados na View Index.cshtml:

@page
@model IndexModel
@{
    ViewData["Title"] = "Home Page";
    Personagem personagem = (Personagem)TempData["Personagem"];
}

<div style="overflow: hidden; margin-top: 50px;">
    <div style="float: left; margin-right: 50px;">
        <img src="@personagem.UrlImagem" height="200" width="200" />
    </div>
    <div style="margin-left: 50px;">
        <h2><a href="@personagem.UrlWiki">@personagem.Nome</a></h2>
        <p>@personagem.Descricao</p>
    </div>
</div>

E a próxima listagem traz a implementação do método OnGet para o arquivo Index.cshtml.cs, com o acesso à API REST da Marvel Comics:

using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;

namespace SiteHeroisMarvel.Pages
{
    public class IndexModel : PageModel
    {
        private static object syncObject = Guid.NewGuid();
        private static string[] HEROIS = new string[]
        {
            "Captain America", "Iron Man", "Thor", "Hulk",
            "Wolverine", "Spider-Man", "Black Panther",
            "Doctor Strange", "Daredevil"
        };

        public void OnGet(
            [FromServices]IConfiguration config,
            [FromServices]IDistributedCache cache)
        {
            Personagem personagem = null;
            string heroi = HEROIS[new Random().Next(0, 9)];

            string valorJSON = cache.GetString(heroi);
            if (valorJSON == null)
            {
                // Exemplo de implementação do pattern Double-checked locking
                // Para mais informações acesse:
                // https://en.wikipedia.org/wiki/Double-checked_locking
                lock (syncObject)
                {
                    valorJSON = cache.GetString(heroi);
                    if (valorJSON == null)
                    {
                        personagem = ObterPersonagem(config, heroi);

                        DistributedCacheEntryOptions opcoesCache =
                            new DistributedCacheEntryOptions();
                        opcoesCache.SetAbsoluteExpiration(
                            TimeSpan.FromMinutes(20));

                        valorJSON = JsonConvert.SerializeObject(personagem);
                        cache.SetString(heroi, valorJSON, opcoesCache);
                    }
                }
            }
            
            if (personagem == null && valorJSON != null)
            {
                personagem = JsonConvert
                    .DeserializeObject<Personagem>(valorJSON);
            }

            TempData["Personagem"] = personagem;
        }

        private Personagem ObterPersonagem(
            IConfiguration config, string heroi)
        {
            using (var client = new HttpClient())
            {
                client.DefaultRequestHeaders.Accept.Clear();
                client.DefaultRequestHeaders.Accept.Add(
                    new MediaTypeWithQualityHeaderValue("application/json"));

                string ts = DateTime.Now.Ticks.ToString();
                string publicKey = config.GetSection("MarvelComicsAPI:PublicKey").Value;
                string hash = GerarHash(ts, publicKey,
                    config.GetSection("MarvelComicsAPI:PrivateKey").Value);
                
                string url = config.GetSection("MarvelComicsAPI:BaseURL").Value +
                    $"characters?ts={ts}&apikey={publicKey}&hash={hash}&" +
                    $"name={Uri.EscapeUriString(heroi)}";
                HttpResponseMessage response = client.GetAsync(
                    url).Result;

                response.EnsureSuccessStatusCode();
                string conteudo =
                    response.Content.ReadAsStringAsync().Result;

                dynamic resultado = JsonConvert.DeserializeObject(conteudo);

                Personagem personagem = new Personagem();
                personagem.Nome = resultado.data.results[0].name;
                personagem.Descricao = resultado.data.results[0].description;
                personagem.UrlImagem = resultado.data.results[0].thumbnail.path + "." +
                    resultado.data.results[0].thumbnail.extension;
                personagem.UrlWiki = resultado.data.results[0].urls[1].url;

                return personagem;
            }
        }

        private string GerarHash(
            string ts, string publicKey, string privateKey)
        {
            byte[] bytes =
                Encoding.UTF8.GetBytes(ts + privateKey + publicKey);
            var gerador = MD5.Create();
            byte[] bytesHash = gerador.ComputeHash(bytes);
            return BitConverter.ToString(bytesHash)
                .ToLower().Replace("-", String.Empty);
        }
    }
}

Por fim, temos ainda a string de conexão para uso do Redis no arquivo appsettings.json. Ao invés de um host e sua respectiva porte de acesso – algo como localhost:6379 – foi informada na mesma o nome do container que o Docker Compose irá gerar (maiores detalhes sobre esta característica serão discutidos nas próximas seções):

{
  "ConnectionStrings": {
    "ConexaoRedis": "redisheroismarvel"
  },
  "MarvelComicsAPI": {
    "BaseURL": "http://gateway.marvel.com/v1/public/",
    "PublicKey": "CHAVE PÚBLICA",
    "PrivateKey": "CHAVE PRIVADA"
  },
  "Logging": {
    "IncludeScopes": false,
    "LogLevel": {
      "Default": "Warning"
    }
  }
}

Criando os arquivos Dockerfile e docker-compose.yaml

Deverão ser criados também, os arquivos Dockerfile e docker-compose.yaml, a fim de possibilitar a geração dos containers esperados via Docker Compose. O Visual Studio Code conta inclusive, com suporte aos formatos utilizados por estes itens (no caso específico de arquivos do Docker Compose o padrão/formato adotado é conhecido como YAML):

O arquivo Dockerfile será empregado na geração da imagem Docker do projeto SiteHeroisMarvel. As instruções necessárias para a montagem dessa estrutura estão indicadas na próxima listagem:

FROM microsoft/aspnetcore-build:2.0 AS build-env
WORKDIR /app

# Copiar csproj e restaurar dependencias
COPY *.csproj ./
RUN dotnet restore

# Build da aplicacao
COPY . ./
RUN dotnet publish -c Release -o out

# Build da imagem
FROM microsoft/aspnetcore:2.0
WORKDIR /app
COPY --from=build-env /app/out .
ENTRYPOINT ["dotnet", "SiteHeroisMarvel.dll"]

Quanto ao docker-compose.yaml, é possível observar neste arquivo as seguintes seções:

  • services, com os diferentes serviços a serem criados por meio de containers (siteheroismarvel e redisheroismarvel);
  • networks, com as configurações para a criação de uma nova network/rede (marvel-network). Será justamente esta configuração que permitirá ao container da aplicação SiteHeroisMarvel acessar o container Redis sem a necessidade de indicação de uma porta (esta característica já foi ressaltada anteriormente ao se definir a string de conexão no arquivo appsettings.json).

No caso do serviço siteheroismarvel foram especificados o nome da imagem (image), o mapeamento entre as portas (ports), a rede empregada (networks) e a dependência para com o container redisheroismarvel (depends_on). Além disso, a presença do item build indicará a necessidade de se proceder com a geração de uma imagem antes da criação do container correspondente.

Para o container redisheroismarvel, foram configurados o nome da imagem (image, a qual será baixada caso ainda não exista na máquina de testes), o mapeamento entre portas (ports) e a rede (networks).

version: '3'

services:
  siteheroismarvel:
    image: siteheroismarvel
    build:
      context: .
    ports:
      - "20000:80"
    networks:
      - marvel-network
    depends_on:
      - "redisheroismarvel"

  redisheroismarvel:
    image: redis:alpine
    ports:
      - "16379:6379"
    networks:
      - marvel-network

networks: 
    marvel-network:
        driver: bridge

Executando o Docker Compose e testando a aplicação

Para iniciar a execução do projeto de testes via Docker Compose, será necessário agora acessar o diretório inicial do mesmo, através de uma ferramenta de linha de comandos (no caso de um sistema Windows, uma opção é o PowerShell). Acionar na sequência o comando:

docker-compose up -d

Essa instrução fará com que as imagens necessárias sejam criadas e/ou baixadas. Montará ainda, a rede especificada em docker-compose.yaml, e finalmente irá gerar e iniciar em modo background os containers esperados para a aplicação:

O comando docker images exibirá as imagens siteheroismarvel:latest e redis:alpine, as quais serviram de base para a geração dos containers indicados em docker-compose.yaml:

Já o comando docker network ls listará a rede marvel-network entre as networks disponíveis:

A instrução docker-compose ps trará os containers gerados por meio do comando docker-compose up (os mesmos se encontram em execução nas portas 20000 e 16379):

Ao acessar a porta 20000 via browser, serão exibidos resultados das consultas à API da Marvel Comics:

Uma conexão à porta 16379 via Redis Desktop Manager trará as chaves armazenadas no container Redis:

A remoção dos containers e da network gerados durante os testes é uma tarefa muito simples, bastando para isto acionar o comando docker-compose down:

E para concluir este artigo, deixo aqui a gravação de um evento recente do Canal .NET focado em Docker Compose:

Referências

Atua como consultor em atividades voltadas ao desenvolvimento de sistemas há mais de 15 anos. Microsoft MVP (Most Valuable Professional) e participante do programa MTAC (Multi-Plataform Technical Audience Contributor). Bacharel em Sistemas de Informação, com Especialização em Engenharia de Software e MBA em Business Intelligence. Também é palestrante e autor técnico em portais e revistas especializadas, com foco em tecnologias Microsoft e boas práticas na área de software.

Ver perfil