.NET

26 abr, 2018

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

Publicidade

Contando com suporte às principais plataformas de desenvolvimento da atualidade, containers Docker 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 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 NoSQL 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 +
                    quot;characters?ts={ts}&apikey={publicKey}&hash={hash}&" +
                    quot;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