Acompanhe online o evento final da Maratona Behind the Code 2020 Inscreva-se já!

Provisionando containers do Docker com o Ansible

O Docker é tão popular porque ele criou uma maneira de compactar, executar e manter containers a partir das convenientes ferramentas de interface da linha de comandos (CLI) e de API HTTP. Essa simplificação diminuiu a barreira para entrar nessa tecnologia, pois tornou mais factível a compactação dos aplicativos e seus ambientes de tempo de execução em imagens autocontidas, em um único Dockerfile simples. O Docker permite entregar projetos mais complexos, mas ainda é necessário configurar esses containers. Neste artigo, eu mostro como o Ansible pode oferecer recursos de gerenciadores de configuração com um uma sintaxe mais clara. Você aprenderá como desenvolver qualquer pilha, apenas com o Python e o Docker instalados.

Antes de entrar nos detalhes do Ansible, examine estes pontos que foram mencionados em uma análise sobre o Ansible:

  • Apesar do surgimento de novos fluxos de trabalho trazidos pelos containers, estão florescendo ferramentas de orquestração e configuração.
  • Novas opções como o Ansible e o Salt estão desafiando as ferramentas existentes como o Chef e o Puppet.
  • Muitos desenvolvedores envolvidos com o Docker também estão interessados nessas ferramentas.

Para esclarecer, com o Docker é possível ativar ambientes de pilha totalmente isolados em questão de segundos ou replicar uma configuração exata entre servidores. No entanto, o Docker não inclui ferramentas potentes que forneçam uma experiência de ponta a ponta, para desenvolvimento e produção. A equipe do Docker atendeu essas crescentes necessidades com novas ferramentas de armazenamento em cluster, tentando transformar o Docker em uma solução confiável para executar containers em escala. No entanto, o Docker ainda requer a codificação permanente de tarefas e a repetição de configurações comuns. Portanto, os principais processos do Docker de orquestração e gerenciamento de configuração de containers ainda precisam ser resolvidos. Neste artigo, você aprenderá como usar o Ansible com o Docker para ajudar a solucionar essas questões.

O surgimento do DevOps

Os aplicativos modernos geralmente envolvem um canal de implementação complexo antes de passarem para produção. As melhores práticas sugerem a liberação antecipada e frequente do código, após cada pequena iteração. A execução manual das tarefas não é escalável, e as organizações começaram a refinar os processos em uma posição mediana entre os desenvolvedores e os administradores, assim, surgiu o DevOps. Desde então, as equipes ágeis estão tentando fortalecer e automatizar a maneira que o código é testado e entregue a seus usuários.

Ao implementar tecnologias e metodologias com o nível mais alto de desenvolvimento, as empresas ganham confiança para o código em seus servidores. No entanto, os desenvolvedores e administradores continuam a enfrentar inúmeros desafios, conforme os aplicativos aumentam em tamanho e complexidade. Mais do que nunca, se fazem necessários os conjuntos de ferramentas orientados pela comunidade que suportam produtos.

O design extensível do Ansible

Neste ambiente, o Ansible oferece uma estrutura interessante para gerenciar infraestruturas. É possível ter controle sobre a definição do servidor, como os pacotes a serem instalados ou os arquivos a serem copiados e dimensionar a configuração para milhares de servidores. Os playbooks do Ansible constituem uma representação segura do estado desejado do cluster. Sua sintaxe YAML e sua ampla lista de módulos produzem arquivos de configuração legíveis que qualquer desenvolvedor pode entender rapidamente. Diferentemente do Chef ou do Puppet, o Ansible não tem agente, o que significa que para executar comandos em hosts remotos basta uma conexão SSH. Concluindo, fica evidente que o Ansible manipula facilmente toda a complexidade do DevOps.

No entanto, o Ansible foi projetado antes da rápida ascensão dos containers e de sua revolução no ambiente de desenvolvimento em nuvem. Portanto, o Ansible ainda é relevante? Os paradigmas e os ambientes de desenvolvimento complexos dos microserviços introduziram novos requisitos:

  • Imagens leves. Para facilitar o transporte e economizar custos, as imagens são reduzidas às suas mínimas dependências.
  • Propósito único, processo único. O daemon de SSH não precisa ser executado caso não seja estritamente necessário para o aplicativo.
  • Efêmero. Espera-se que os containers sejam finalizados, movidos e ressuscitados o tempo todo.

Nesse contexto, a arquitetura extensível do Ansible soluciona essas questões. Os módulos do Docker gerenciam hosts e containers em um nível mais alto. Embora você possa argumentar sobre qual ferramenta de orquestração (Kubernetes, da Google ou Centurion, da New Relic) é a mais adequada para esse ambiente, o módulo do Docker executa com eficiência, e por isso escolhi usá-lo neste artigo. No entanto, também é possível desenvolver containers que comecem a partir de suas imagens oficiais do Ansible e executar playbooks no modo local, de dentro. Embora essa abordagem seja realmente adequada ao Packer e, certamente, seja adequada a vários casos de uso, geralmente, suas desvantagens são obstáculos consideráveis:

  • Você fica bloqueado com uma imagem base e não pode mais usufruir de fórmulas especiais ou de outras pilhas.
  • O artefato resultante instala o Ansible e suas dependências, que não têm relação com o aplicativo real e deixam o artefato mais pesado.
  • Embora o Ansible possa gerenciar centenas de servidores, ele somente provisona uma único container.

Essa abordagem considera os containers como pequenas VMs, em que uma solução específica seria usada. Felizmente, o Ansible tem um design modular. Os módulos são espalhados em diferentes repositórios, e a maioria das capacidades do Ansible pode ser ampliada usando plug-ins.

Na próxima seção, você configurará um ambiente efetivo para adaptar o Ansible às suas necessidades.

Configurando um ambiente Ansible

Digamos que você deseje uma ferramenta que seja muito fácil de implementar e que configure os ambientes de aplicativos em containers leves. Separado desses containers, digamos que você precise de um nó cliente com o Ansible instalado, a ser usado para enviar comandos a um daemon do Docker. Essa configuração é mostrada abaixo.

Componentes necessários para provisionar containers com o Ansible

alt

As dependências que devem ser gerenciadas nessa configuração são minimizadas pela execução do Ansible a partir de um container. Essa arquitetura limita o host a uma ponte de comunicação entre containers e comandos.

Várias opções estão disponíveis para instalar o Docker no servidor:

  • Use o docker-machine para instalá-lo em hosts remotos.
  • Instale localmente. Como observação adicional, provavelmente, você não deseje gerenciar sozinho uma infraestrutura séria baseada em container e, nesse caso, considere recorrer a provedores externos.
  • Conte com provedores externos.
  • Use o boot2docker, que é uma distribuição leve do Linux que executa os containers do Docker no Windows e no Mac.

Independentemente da solução escolhida, assegure-se de que ela implemente a versão 1.3 do Docker ou mais recente (a versão 1.3 introduziu a injeção de processo). Também é necessário executar um servidor SSH para processar os comandos do Ansible com segurança.

Os comandos em abaixo configuram um método de autenticação fácil e completo.

Comandos para configurar a autenticação usando chaves públicas

# install dependencies
sudo apt-get install -y openssh-server libssl-dev
# generate private and public keys
ssh-keygen -t rsa -f ansible_id_rsa
# allow future client with this public key to connect to this server
cat ansible_id_rsa.pub >> ~/.ssh/authorized_keys
# setup proper permissions
chmod 0700  ~/.ssh/
chmod 0600  ~/.ssh/authorized_keys
# make sure the daemon is running
sudo service ssh restart

As questões de configuração de SSH e de segurança não são abordadas neste artigo. O leitor curioso pode explorar o arquivo /etc/ssh/sshd_config para saber mais sobre as opções disponíveis para configurar SSH.

A próxima etapa é carregar a chave pública no container do cliente que executa o Ansible e provisionar o container do construtor. Use um Dockerfile para provisionar o construtor.

Dockerfile que provisiona o construtor
FROM python:2.7

# Install Ansible from source (master)
RUN apt-get -y update && \
    apt-get install -y python-httplib2 python-keyczar python-setuptools python-pkg-resources
git python-pip && \
    apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN pip install paramiko jinja2 PyYAML setuptools pycrypto>=2.6 six \
    requests docker-py  # docker inventory plugin
RUN git clone http://github.com/ansible/ansible.git /opt/ansible && \
    cd /opt/ansible && \
    git reset --hard fbec8bfb90df1d2e8a0a4df7ac1d9879ca8f4dde && \
    git submodule update --init

ENV PATH /opt/ansible/bin:$PATH
ENV PYTHONPATH $PYTHONPATH:/opt/ansible/lib
ENV ANSIBLE_LIBRARY /opt/ansible/library

# setup ssh
RUN mkdir /root/.ssh
ADD ansible_id_rsa /root/.ssh/id_rsa
ADD ansible_id_rsa.pub /root/.ssh/id_rsa.pub

# extend Ansible
# use an inventory directory for multiple inventories support
RUN mkdir -p /etc/ansible/inventory && \
    cp /opt/ansible/plugins/inventory/docker.py /etc/ansible/inventory/
ADD ansible.cfg  /etc/ansible/ansible.cfg
ADD hosts  /etc/ansible/inventory/hosts

Essas instruções são adaptadas da compilação oficial e automatizam uma instalação ativa a partir da confirmação fbec8bfb90df1d2e8a0a4df7ac1d9879ca8f4dde na ramificação principal do Ansible.

Os hosts e os arquivos de configuração ansible.cfg são compactados. Usando um container, é possível garantir o compartilhamento do mesmo ambiente. Neste exemplo, o Dockerfile instala o Python versão 2.7.10 e o Ansible 2.0.0.

Arquivo de configuração de hosts
# hosts
# this file is an inventory that Ansible is using to address remote servers.
Make sure to replace the information with your specific setup and variables that you
don't want to provide for every command.
[docker]
# host properties where docker daemon is running
192.168.0.12 ansible_ssh_user=xavier
Arquivo de configuração do Ansible
# ansible.cfg

[defaults]

# use the path created from the Dockerfile
inventory = /etc/ansible/inventory

# not really secure but convenient in non-interactive environment
host_key_checking = False
# free you from typing `--private-key` parameter
priva_key_file = ~/.sh/id_rsa

# tell Ansible where are the plugins to load
callback_plugins   = /opt/ansible-plugins/callbacks
connection_plugins = /opt/ansible-plugins/connections

Para que seja possível desenvolver o container do Ansible, deve-se exportar a variável de ambiente DOCKER_HOST, pois o Ansible a usará para conectar-se ao daemon do Docker remoto. Ao usar um terminal HTTP, é necessário modificar /etc/default/docker.

Modificando /etc/default/docker
# make docker to listen on HTTP and default socket
DOCKER_OPTS="-H tcp://0.0.0.0:2375 -H unix:///var/run/docker.sock"

Insira o comando sudo service docker restart para reiniciar o daemon do Docker para que sejam reconhecidas as mudanças em seu arquivo de configuração.

Os comandos a seguir desenvolvem e validam o container do Ansible a partir do qual os comandos são inseridos.

Comandos para desenvolver e validar o container do Ansible
# you need DOCKER_HOST variable to point to a reachable docker daemon
# pick the method that suits your installation

# for boot2docker users
eval "$(boot2docker shellinit)"
# for docker-machine users, provisioning the running VM was named "dev"
eval "$(docker-machine env dev)"
# for users running daemon locally
export DOCKER_HOST=tcp://$(hostname -I | cut -d" " -f1):2375
# finally users relying on a remote daemon should provide the server's public ip
export DOCKER_HOST=tcp://1.2.3.4:2375

# build the container from Dockerfile
docker build -t article/ansible .

# provide server API version, as returned by `docker version | grep -i "server api"`
# it should be at least greater or equal than 1.8
export DOCKER_API_VERSION=1.19

# create and enter the workspace
docker run -it --name builder \
    # make docker client available inside
    -v /usr/bin/docker:/usr/bin/docker \
    -v /var/run/docker.sock:/var/run/docker.sock \
    # detect local ip
    -e DOCKER_HOST=$DOCKER_HOST \
    -e DEFAULT_DOCKER_API_VERSION=DOCKER_API_VERSION \
    -v $PWD:/app -w /app \  # mount the working space
    article/ansible bash

# challenge the setup
$ container > ansible docker -m ping
192.168.0.12 | SUCCESS => {
    "invocation": {
        "module_name": "ping",
        "module_args": {}
    },
    "changed": false,
    "ping": "pong"
}

Até aqui tudo bem. Você sabe incluir comandos a partir de um container. Na próxima seção, você usará as extensões específicas do Docker para o Ansible.

Ampliando o ambiente Ansible com playbooks e plug-ins

Fundamentalmente, o Ansible automatiza sua execução por meio de playbooks, que são arquivos YAML que especificam cada tarefa a ser executada e suas propriedades.

O Ansible também consulta inventários para mapear os hosts fornecidos pelo usuário a terminais concretos na infraestrutura. Diferentemente do arquivo hosts estático usado na seção anterior, o Ansible também suporta conteúdo dinâmico. As listas integradas incluem um plug-in do Docker que pode consultar o daemon do Docker e compartilhar uma quantia significativa de informações com os playbooks do Ansible.

Um playbook do Ansible

# provision.yml

- name: debug docker host
  hosts: docker
  tasks:
  - name: debug infrastructure
    # access container data : print the state
    debug: var=hostvars["builder"]["docker_state"]

# you can target individual containers by name
- name: configure the container
  hosts: builder
  tasks:
   - name: run dummy command
     command: /bin/echo hello world

O comando em consulta o host do Docker, importa fatos, imprime alguns e usa-os para executar a segunda tarefa com relação ao container do construtor.

Comando para consultar o host do Docker

ansible-playbook provision.yml -i /etc/ansible/inventory
# ...
TASK [setup] ********************************************************************
fatal: [builder]: FAILED! => {"msg": "ERROR! SSH encountered an unknown error during the
connection. Re-run the command using -vvvv, which enables SSH debugging
output to help diagnose the issue", "failed": true}
# ...

O Ansible não pode atingir o container porque ele não é executado como um servidor SSH. O servidor SSH é um processo adicional para gerenciar e não tem relação alguma com o aplicativo real. Na próxima seção, removeremos essa dificuldade usando um plug-in de conexão.

Os plug-ins de conexão são classes que implementam comandos para transporte, como SSH ou execução local. O Docker 1.3 é fornecido com o docker exec e a capacidade de executar tarefas dentro do namespace do container. E, como você já aprendeu como destinar containers específicos, será possível usar essa capacidade para processar o playbook.

Como outros tipos de plug-in, os ganchos de conexão herdam uma classe abstrata e ficam disponíveis automaticamente ao serem apresentados no diretório esperado (/opt/ansible-plugins/connections que foi definido no arquivo de configuração ansible.cfg).

Plug-in de conexão
# saved as ./connection_plugins/docker.py

import subprocess
from ansible.plugins.connections import ConnectionBase

class Connection(ConnectionBase):

   @property
    def transport(self):
        """ Distinguish connection plugin. """
        return 'docker'

   def _connect(self):
        """ Connect to the container. Nothing to do """
        return self

   def exec_command(self, cmd, tmp_path, sudo_user=None, sudoable=False,
                     executable='/bin/sh', in_data=None, su=None,
                     su_user=None):
        """ Run a command within container namespace. """

    if executable:
        local_cmd = ["docker", "exec", self._connection_info.remote_addr, executable, '-c', cmd]
    else:
        local_cmd = '%s exec "%s" %s' % ("docker", self._connection_info.remote_addr, cmd)

    self._display.vvv("EXEC %s" % (local_cmd), host=self._connection_info.remote_addr)
    p = subprocess.Popen(local_cmd,
        shell=isinstance(local_cmd, basestring),
        stdin=subprocess.PIPE, stdout=subprocess.PIPE,
        stderr=subprocess.PIPE)

    stdout, stderr = p.communicate()
    return (p.returncode, '', stdout, stderr)

    def put_file(self, in_path, out_path):
        """ Transfer a file from local to container """
        pass

    def fetch_file(self, in_path, out_path):
        """ Fetch a file from container to local. """
        pass

    def close(self):
        """ Terminate the connection. Nothing to do for Docker"""
        pass

Esse código conecta-se aos métodos do Ansible para executar comandos por meio de um docker exec, em vez do ssh padrão. Será necessário reorganizar algumas etapas de configuração para instruir o Ansible a usar esse plug-in.

Plug-in de conexão para o docker exec
# modify the builder Dockerfile to upload the plugin code
where Ansible is expecting connection plugins
echo "ADD connection_plugins/docker.py /opt/ansible-plugins/connections/docker.py" >> Dockerfile

# then, you need to explicitly tell which connection hook to use
when executing playbooks.
# you can achieve this by inserting the 'connection' property at the top
of provision tasks in provision.yml

- name: configure the container
  connection: docker
  hosts: builder

# you are ready to redeploy the builder container
# (providing DOCKER_HOST and DOCKER_API_VERSION are still set like before)

# rebuild the image
docker build -t article/ansible .

# restart the builder environment
docker run -it --name builder \
    # make docker client available inside
    -v /usr/bin/docker:/usr/bin/docker \
    -v /var/run/docker.sock:/var/run/docker.sock \
    # detect local ip
    -e DOCKER_HOST=$DOCKER_HOST \
    -e DEFAULT_DOCKER_API_VERSION=DOCKER_API_VERSION \
    -v $PWD:/app -w /app \  # mount the working space
    article/ansible bash

# rerun provisioning from inside
ansible-playbook -i /etc/ansible/inventory provision.yml
# ... Hurrah, full green output ...

Até agora, você executou as tarefas do Ansible em containers sem muitos requisitos nos containers ou no host. Embora essa implementação atenda aos requisitos iniciais, restam imprecisões que ainda precisam ser solucionadas.

O código anterior executou uma tarefa no mesmo nó. Um fluxo de trabalho mais realista ativaria uma nova imagem base, a provisionaria e, finalmente, confirmaria, realizaria push e encerraria o artefato resultante. Graças ao módulo integrado do Docker no Ansible, essas etapas podem ser realizadas sem o uso de código adicional.

Módulo do Docker no Ansible que ativa uma nova imagem base
---
- name: initialize provisioning
  hosts: docker

  - name: start up target container
    docker:
      image: python:2.7
      name: lab
      pull: missing
      detach: yes
      tty: yes
      command: sleep infinity
      state: started
  # dynamically update inventory to make it available down the playbook
  - name: register new container hostname
    add_host: name=lab

- name: provision container
  connection: docker
  hosts: lab
  tasks:
      # ...

- name: finalize build
  hosts: docker
  tasks:
    - name: stop container
      docker:
        name: lab
        image: python:2.7
        state: stopped

Como mencionado, seria conveniente nomear e armazenar automaticamente a imagem desenvolvida com uma provisão bem-sucedida. Infelizmente, o módulo do Docker no Ansible não implementa métodos para identificar e realizar push em imagens. Essa limitação pode ser superada com comandos shell simples.

Comandos shell para nomear e armazenar imagens
# name the resulting artifact under a human readable image tag
docker tag lab article/lab:experimental

# push this image to the official docker hub
# make sure to replace 'article' by your own Docker Hub login (https://hub.docker.com)
# (this step is optional and will only make the image available from any docker host.
You can skip it or even use your own registry) docker push article/lab:experimental

Nossa ferramenta está ganhando forma, mas ainda falta um recurso essencial: armazenamento de camada em cache.

O desenvolvimento de containers com Dockerfiles geralmente envolve várias iterações para que fique correto. Para acelerar o processo significativamente, as etapas bem-sucedidas são armazenadas em cache e reutilizadas em execuções subsequentes.

Para replicar esse comportamento, nossa ferramenta confirma o estado do container após cada tarefa bem-sucedida. Caso ocorram erros de desenvolvimento, a ferramenta reinicia o processo de provisão a partir da última captura instantânea. O Ansible promete tarefas idempotentes, ou seja, as tarefas bem-sucedidas não são processadas duas vezes.

Com o Ansible, é possível conectar-se a eventos de tarefa com plug-ins de retorno de chamada. Essas classes devem implementar retornos de chamada específicos, que são acionados em várias etapas do ciclo de vida do playbook.

Plug-in de retorno de chamada que se conecta a eventos de tarefa
# save as callback_plugins/docker-cache.py
import hashlib
import os
import socket

# Hacky Fix `ImportError: cannot import name display`
# pylint: disable=unused-import
import ansible.utils
import requests
import docker

class DockerDriver(object):
    """ Provide snapshot feature through 'docker commit'. """

    def __init__(self, author='ansible'):
        self._author = author
        self._hostname = socket.gethostname()
        try:
            err = self._connect()
        except (requests.exceptions.ConnectionError, docker.errors.APIError), error:
            ansible.utils.warning('Failed to contact docker daemon: {}'.format(error))
            # deactivate the plugin on error
            self.disabled = True
            return

        self._container = self.target_container()
        self.disabled = True if self._container is None else False

    def _connect(self):
        # use the same environment variable as other docker plugins
        docker_host = os.getenv('DOCKER_HOST', 'unix:///var/run/docker.sock')
        # default version is current stable docker release (10/07/2015)
        # if provided, DOCKER_VERSION should match docker server api version
        docker_server_version = os.getenv('DOCKER_VERSION', '1.19')
        self._client = docker.Client(base_url=docker_host,
                                     version=docker_server_version)
        return self._client.ping()

    def target_container(self):
        """ Retrieve data on the container you want to provision. """
        def _match_container(metadatas):
            return metadatas['Id'][:len(self._hostname)] == self._hostname

        matchs = filter(_match_container, self._client.containers())
        return matchs[0] if len(matchs) == 1 else None

    def snapshot(self, host, task):
        tag = hashlib.md5(repr(task)).hexdigest()
        try:
            feedback = self._client.commit(container=self._container['Id'],
                                           repository='factory',
                                           tag=tag,
                                           author=self._author)
        except docker.errors.APIError, error:
            ansible.utils.warning('Failed to commit container: {}'.format(error))
            self.disabled = True

# pylint: disable=E1101
class CallbackModule(object):
    """Emulate docker cache.
    Commit the current container for each task.

    This plugin makes use of the following environment variables:
        - DOCKER_HOST (optional): How to reach docker daemon.
          Default: unix://var/run/docker.sock
        - DOCKER_VERSION (optional): Docker daemon version.
          Default: 1.19
        - DOCKER_AUTHOR (optional): Used when committing image. Default: Ansible

    Requires:
        - docker-py >= v0.5.3

    Resources:
        - http://docker-py.readthedocs.org/en/latest/api/
    """

    _current_task = None

    def playbook_on_setup(self):
        """ initialize client. """
        self.controller = DockerDriver(self.conf.get('author', 'ansible'))

    def playbook_on_task_start(self, name, is_conditional):
        self._current_task = name

    def runner_on_ok(self, host, res):
        if self._current_task is None:
            # No task performed yet, don't commit
            return
        self.controller.snapshot(host, self._current_task)

Como você transferiu o código para o local esperado e recriou o container do construtor, é possível registrar esse plug-in da mesma forma que o plug-in de conexão docker exec.

Comando para registrar o plug-in de retornos de chamada
# modify the builder Dockerfile to upload the code where Ansible is expecting callback plugins
echo "ADD callback_plugins/docker-cache.py /opt/ansible-plugins/callbacks/docker-cache.py" >> Dockerfile

Após recriar o container do construtor e executar novamente o playbook do Ansible, o módulo é carregado automaticamente e é possível ver como os containers intermediários foram criados (consulte Imagens do Docker).

Imagens do Docker
REPOSITORY          TAG                     IMAGE ID            CREATED             VIRTUAL SIZE
factory             bc0fb8843e88566c    bbdfab2bd904        32 seconds ago      829.8 MB
factory             d19d39e0f0e5c133    e82743310d8c        55 seconds ago      785.2 MB

Conclusão

A provisão é um processo complexo e a implementação seguida neste tutorial criou a base para um desenvolvimento mais aprofundado. O código em si foi simplificado e algumas etapas ainda requerem intervenção humana. A implementação de cache certamente merece mais atenção, com nomenclaturas de confirmação ou qualificações de limpeza mais específicas, por exemplo.

Ainda assim, você aprendeu usar uma ferramenta que pode executar playbooks do Ansible que gerenciam a configuração de containers. Com essa implementação, é possível usar a potência total do Ansible combinando, reutilizando e configurando arquivos de desenvolvimento declarativos para os microserviços de uma infraestrutura. Essa solução ajuda a evitar problemas de bloqueio. Os plug-ins desenvolvidos agrupam playbooks que podem ser reutilizados para diferentes destinos, e os requisitos mínimos tornam o projeto compatível com a maioria dos provedores.

Aviso

O conteúdo aqui presente foi traduzido da página IBM Developer US. Caso haja qualquer divergência de texto e/ou versões, consulte o conteúdo original