Tenho uma necessidade específica: usar a AWS-CLI. Porém, o meu desafio é fazer isso sem instalar nada. E ainda por cima para essa atividade específica eu precisava disponibilizar na mesma máquina tanto o AWS-CLI quanto também acesso ao Terraform.

Na primeira instância que realizei isso foi através do Cloud Shell da própria AWS. Mas posso dizer com tranquilidade que isso não se comparava em nada com o conforto de poder fazer isso da minha própria máquina, né?

Então, veio o desafio: como poder interagir com o AWS-CLI, porém sem instalar nada, e também tentando evitar todo e qualquer contaminação com outro ambiente que eventualmente eu pudesse ter na minha máquina?

Foi então que eu me lembrei de que eu poderia tentar usar uma imagem docker para tentar burlar essa limitação! E claro que a Amazon subiu uma imagem oficial de seu Amazon Linux com a AWS-CLI no Docker Hub: amazon/aws-cli.

Container/Imagem 101

Vamos lá. Ao usar Docker, estamos usando uma imagem em um container. De modo bem grosseiro para quem usa programação orientada a objetos estilo Java/C++: é como se a imagem fosse a classe e o objeto que é a instância da classe fosse o container.

Nesse sentido, a classe é imutável. A imagem é única também. E a partir dela podemos instanciar uma classe e assim teremos um objeto, que pode evoluir. Eventualmente esse objeto deixará de existir. Podemos dizer que ele vai ser “destruído” ou “coletado” no final do seu ciclo de vida.

Mas eu também posso querer trabalhar com uma mesma customização em todas as imagens por debaixo. Para isso, damos a ela o caminho adequado para a construção de novos containers em cima de uma nova imagem. Para isso criamos o Dockerfile.

Muitas imagens são criadas com base em algo já existente. Por exmeplo, no post Rodando o gitlab runner em um docker-in-docker, para rodar a imagem já com as dependências pré-instaladas e assim economizar uma boa parte do tempo de build do Computaria, usei a imagem ruby.

Agora, apenas a imagem não é o suficiente. Esse caso da imagem que se chama apenas ruby, sem a barra, indica mais ou menos de que ela vem da “distribuição padrão”. No caso de um Docker sem nenhuma alteração (como, por exemplo, endereço do repositório de imagens customizado), isso significa Imagens Dokcer Oficiais. E a específica que usei para o Ruby foi a imagem docker oficial de Ruby.

Mas só usar a imagem não é lá essas coisas de confiável. A imagem eventualmente é atualizada. Precisamos atrelar nela uma versão! E pra isso o :3.2-alpine. Aqui estou dizendo que “não basta pegar uma imagem qualquer do Ruby, eu quero pegar especificamente a versão 3.2-alpine”.

Se você quiser prender com mais força a imagem em si, tavez nem seja o adequado usar uma tag genérica de versão. As vezes faz mais sentido prender a um ponto específico no histórico da imagem, como se fosse “naquele commit específico” e não “naquela tag específica”. Para isso, usamos o @. Usamos o @ seguido da “assinatura” daquele ponto no histórico. E para isso podemos mencionar o “manifest” (que pega num nível mais alto, independente de arquitetura) ou direto o índice já com a arquitetura.

No meu caso, pegando a versão 3.2-alpine para um arm64-v7:

manifest digest sha256:f2eacfbe046e6842d45984663c8868422762dbd62b54cda603cba11a80c6d484:

docker run --rm -it ruby@sha256:f2eacfbe046e6842d45984663c8868422762dbd62b54cda603cba11a80c6d484

manifest digest sha256:d206c25708a44df6a7ce22213ee5da9fc0a9f7b31ce884429ba758db48abdc62:

docker run --rm -it ruby@sha256:d206c25708a44df6a7ce22213ee5da9fc0a9f7b31ce884429ba758db48abdc62

Qual você vai usar, e porquê vai usar essa em específico vai depender de como o projeto evolui e decisões técnicas e contratuais. Para o meu caso específico, que é ter uma imagem pra fazer um build de um SSG e para rodar uma CLI, vou preferir me ater a o que é mais fácil.

Alterando como o container roda

A primeira coisa a saber sobre um container é: ele nasce quando você o inicia, e ele morre quando o seu processo principal chega ao fim.

Portanto, docker run ruby:3.2-alpine vai iniciar um processo e rapidamente vai terminá-lo:

> docker run ruby:3.2-alpine    
Switch to inspect mode.

> 

Mas por que nesse caso específico acabou tão rápido? Porque não vinculei nada de que estou interagindo com o terminal desse container. Para isso, usamos as flags -it:

> docker run -it ruby:3.2-alpine
irb(main):001:0> puts 'oi'
oi
=> nil                                           
irb(main):002:0> 

Aqui estou falando pro Docker que quero interagir -i com o programa sendo invocado, e também digo ao Docker para que ele aloque uma TTY -t para uma interação melhor.

Nesse caso, o container especificamente chamou o irb e caiu fora. Não tem nada depois do irb. E o irb, se não tiver alocado em uma interface interativa, ele simplesmente se termina.

Agora, para não ficar sujando com vários containeres não mais utilizados, podemos usar a opção para remover assim que terminar, o --rm. Isso indica pro daemon que, ao terminar a execução do programa, remova o container.

Note que nem sempre usar o --rm é a solução! Se você tiver um processo web que precisa ficar sempre ligado, você provavelmente vai querer usar que ele se reinicie. Para isso, você não passa o --rm (que remove o container após parar) e coloca uma flag com a política de restart, como --restart unless-stopped. Note que para aplicativos efêmeros de linha de comando (como o AWS-CLI) é desejável que o container evapore no final da execução, enquanto que para serviços eu peça para que ee se reinicie.

Eu particularmente prefiro ter a opção explícita de poder derrubar o serviço, por isso prefiro por --restart unless-stopped: isso vai dizer que, quando ocorre uma parada no serviço por algum fluxo que levou o servidor a parar de rodar, esse container irá se reiniciar sozinho. Porém caso eu explicite que ele deva parar com docker stop, esse container vai obedecer o comando e vai se desaparecer. Sempre bom ter a opção de poder simplesmente parar o container para atualizar com uma versão mais nova.

Outra coisa sobre a localização de containeres! Eu posso indicar que o meu container está sendo disponibilizado por alguém, como no caso do amazon/aws-cli (imagem aws-cli do usuário amazon), ou então alpine/curl (imagem curl do usuário alpine).

Agora, tem umas paradas interessantes em relação a alguns containeres. Eles já vem prontinhos para serem chamados na linha de comando! Por exemplo:

> docker run --rm alpine/curl:8.21.0 https://www.pudim.com.br -s
<html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <title>Pudim</title>
    <link rel="stylesheet" href="estilo.css">
</head>
<body>
<div>
    <div class="container">
        <div class="image">
            <img src="pudim.jpg" alt="">
        </div>
        <div class="email">
            <a href="mailto:pudim@pudim.com.br">pudim@pudim.com.br</a>
        </div>
    </div>
</div>
<script>
    (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
                (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
            m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
    })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

    ga('create', 'UA-28861757-1', 'auto');
    ga('send', 'pageview');

</script>
</body>
</html>

Aqui estou chamando a imagem curl do usuário alpine na versão 8.21.0 e passando para ele o comando de baixar o https://www.pudim.com.br de modo silencioso -s. Notou que depois do nome da imagem todo o resto foram opções do comando curl?

Pois bem, mas nem toda imagem é assim. Por exemplo, posso pedir para carregar uma shell arbitrária no Ruby:

docker run --rm -it ruby:3.2-alpine sh                            
/ # 

Aqui o sh é o comando que vai ser executado na imagem ruby:3.2-alpine para rodar o container.

A diferença entre essas duas imagens é que no caso do alpine/curl foi definido um entrypoint, e no ruby não. Ao definir um entrypoint para a imagem, o que vier depois na linha de comando se torna simplesmente argumentos. Caso não tenha nenhum entrypoint registrado, os argumentos são simplesmente delegados para o container executar e o container fica ativo apenas até que esse comando termine a execução.

No caso, mesmo tendo um entrypoint tradicional, eu posso alterar o meu entrypoint desejado na hora de subir o container:

> docker run --rm --entrypoint sh -it alpine/curl:8.21.0 
/ # 

Agora, mesmo o alpine/curl definindo que tem um entrypoint, eu estou mandando usar comom entrypoint o sh.

Além do comando run que vai iniciar um novo container, eu posso executar algo dentro de um container pré-existente. Para isso temos o comando docker exec, aí você identifica qual o container deseja executar e qual o comando a ser executado.

Além do que já foi dito, tem mais uma coisa importante, principalmente para quem deseja algo com efeitos colaterais duradouros (como, por exemplo, conseguir se logar com sucesos na AWS): usar volumes para montar um diretório do host no container! Isso permite, por exemplo, que a aplicação CLI possa fazer login, ser efêmera, e na próxima vez que for invocada que ela esteja já pronta com a parte de autenticação resolvida.

Uma fórmula para montar container

O docker compose existe para montar um ecossistema adeqaudo de imagens para poder subir um pequeno sistema. Um exemplo simples para isso:

services:
  web:
    build: .
    ports:
      - "${APP_PORT}:5000"
    environment:
      - REDIS_HOST=${REDIS_HOST}
      - REDIS_PORT=${REDIS_PORT}

  redis:
    image: redis:alpine

Aqui os diversos serviçoes são listados, cada um com as descrições do que precisa pra subir o serviço específico. Aqui, no exemplo tirado diretamente da página do Dokcer Compose, temos a descrição de dois serviços:

  • web
  • redis

O redis simplesmente sobe em cima da imagem padrão. O web, por sua vez, indica que é feito o build a partir do diretório local, abre uma porta para a 5000 e também pega duas variáveis de ambinete.

Como eu posso configurar arbitrariamente o container, vou usar isso para poder manipular o como eu quero que o meu AWS-CLI suba.

Em um primeiro instante:

services:
  aws:
    image: amazon/aws-cli:latest

Isso simplesmente sobe o container. Ok, agora consigo experimentar se eu consigo fazer login na AWS com sucesso através do aws login? Bem, não, não livremente pelo menos. O entrypoint não é uma shell, é o próprio comando aws. Eu poderia declarar no próprio services, mas não… quero ser explícito chamar o shell. Então, vou colocar essa informação na hora de subir o container. Ficando assim por enquanto:

docker compose run -it --rm --entrypoint sh aws

Por hora não parece estar sendo vantajoso usar o docker compose, né? Ok, as coisas melhoram, eu prometo. Estamos prontos para fazer o login! E, após o login, chamar o comando aws sts get-caller-identity. Massa demais! Mas… Como conseguimos tornar esse login perene? Ainda está volátil, basta fechar o container que preciso fazer tudo de novo, o exato oposto do que eu gostaria.

Bem, as coisas da AWS ficam salvas em ~/.aws. Como o usuário é root, o destino é /root/.aws. Então, nada mais justo que eu mapeie também para o .aws da pasta local, né?

services:
  aws:
    image: amazon/aws-cli:latest
    volumes:
      - .aws:/root/.aws

Ok, ao executar o aws login ele preenche as opções do jeito que eu espero. Inclusive, rodar docker compose run -i --rm aws sts get-caller-identity retorna o valor esperado.

Viu por que eu não queria mexer no entrypoint direto no docker-compose.yml? Se pagou, né?

Ok, com AWS logado e com sessões persistentes, qual o próximo passo?

AWS + Terraform

Voltamos ao começo da minha questão. Preciso ter um container que tenha ao mesmo tempo acesso da AWS-CLI e também ao Terraform, no mesmo ambiente!

Ok, AWS está resolvido. Vamos resolver o como subir as descrições do Terraform? Do mesmo jeito que foi feito com a AWS que foi montado um volume para compartilhar uma pasta, vamos botar aqui também:

services:
  aws:
    image: amazon/aws-cli:latest
    volumes:
      - .aws:/root/.aws
      - ./terraform:/terraform

Isso permite que eu edite no VSCode rodando na minha máquina local os arquivos do Terraform, e eles são percebidos pelo container ao serem salvos. Como Terraform depende do estado, obter o estado em que a infra se encontra é um “must have”, e aqui eu obtenho porque o diretório permite a comunicação full-duplex.

Agora, seria bom que toda interação já ocorresse na pasta do /terraform, de modo que essa atividade (que é a principal) ficasse toda lá. Para isso, preciso alterar o workdir do container. E eu poderia fazer isso a nível de docker run? Sim, mas inconveniente. A nível de docker-compose (usando atributo working_dir do service)? Poderia também, mas acho que é mais explícito deixar isso a nível de imagem.

Então vamos começar a customoizar a imagem aws-cli?

FROM amazon/aws-cli:latest

WORKDIR /terraform

Ok, imagem declarada. Agora, como utilizar ela? Bora lá, não posso mais dizer que o meu service aws vai vir direto de uma imagem, mas que vou fazer o build dela. E nesse build, o diretório com o contexto é o local. Posso também especificar o dockerfile específico, e nesse caso eu preferi por via das dúvidas:

services:
  aws:
    #image: amazon/aws-cli:latest
    build:
      context: .
      dockerfile: Dockerfile.aws
    volumes:
      - .aws:/root/.aws
      - ./terraform:/terraform

Tenho o Terraform instalado? Ainda não, mas já começou a tomar forma. Falta-me isso agora. Seguindo o guia de instalação do Terraform em Amazon Linux, temos que o Dockerfile.aws ficou assim:

FROM amazon/aws-cli:latest

RUN yum install -y yum-utils shadow-utils
RUN yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
RUN yum install -y terraform

WORKDIR /terraform

Para demonstrar que funciona, nada como um terraform init && terraform plan. Mas… falhou. Por quê? Não encontrou o comando?

Aqui é um caso em que a imagem derivou da descrição da imagem, e não percebemos isso. Basicamente no passado recente foi feito o build dessa imagem e aconteceu o seu reaproveitamento. Com isso, gerando essa espécie de comportamento não esperado.

A resolução é simples:

docker compose build aws

Ou então tem um atalho pra isso:

docker compose --build run --rm -it --entrupoint sh aws

Aqui o próprio docker compose se encarrega de fazer o build sozinho.

E mais uma coisinha antes que eu me esqueça, se for necessário injetar alguma coisa de variável de ambiente no container, podemos definir isso no docker-compose.yaml. Mas aqui não falo em estar lá em texto puro, mas em menção:

services:
  aws:
    #image: amazon/aws-cli:latest
    build:
      context: .
      dockerfile: Dockerfile.aws
    env_file: .env
    volumes:
      - .aws:/root/.aws
      - ./terraform:/terraform

Aqui o próprio Docker ao subir o container vai injetar as variáveis definidas no .env. Usei o .env, por exemplo, para injetar minhas credenciais da AWS.

Um .gitignore de referência

Bem, alguns arquivos não devemos commitar. Alguns porque são secrets mesmo. Outros porque são assets secundários, como o binário depois do build.

Aqui está o que eu pude discernir:

*.sw[po]
.*.sw[po]
/.aws/
.env
.DS_Store

Esse eu mantive na raiz do repositório. Mas também achei prudente colocar um .gitignore só no diretório do Terraform:

.terraform
.terraform.lock.hcl
.terraform.tfstate.lock.info

# terraform.tfstate não deve estar commitado
terraform.tfstate
terraform.tfstate.backup

Aqui, o .terraform é um diretório. Ele foi criado após o terraform init. O terraform.tfstate representar o estado do sistema.

E aí temos uns arquivos de lock! Bem, o que são eles, né? Basicamente, eles permitem que apenas uma única instância do elemento que estou trabalho, se surgir uma segunda instância ela vai tentar adquirir o lock e não vai conseguir, pois o SO deve travar esse recurso com acesso único. E isso que protege duas execuções em paralelo de alterar o estado em paralelo.

Inclusive, é o mesmo mecanismo que o Git utiliza para não ferrar o banco de dados interno dele: ele tem um arquivo próprio, o .git/index.lock. Esse index.lock que garante que um processo secundário não altere os dados, soltando a célebre mensagem

Erro: Unable to create '/path/to/repo/.git/index.lock': File exists.

If no other git process is currently running, this probably means a
git process crashed in this repository earlier. Make sure no other git
process is running and remove the file manually to continue.