Desenvolvimento

7 mar, 2016

Deploy ágil com Docker

Publicidade

Com vários projetos usando tecnologias diferentes, surgiu a necessidade de uma maneira mais ágil de organizar o deploy em produção, desses e de outros novos projetos que surgem. Uma dessas maneiras, que escolhi explicar nesse artigo, é rodar cada aplicação dentro de um contêiner. Quando fazemos isso, basta fazer uma única configuração, uma única vez, para garantir o mesmo funcionamento nas máquinas de desenvolvimento, homologação e produção. Isso permite que cada time de desenvolvimento seja responsável por decidir e gerenciar quais versões de bibliotecas e dependências seus projetos vão utilizar.

Na arquitetura abaixo, cada aplicação web roda dentro de um contêiner, expondo uma porta específica. Cada contêiner rodando uma aplicação web notifica ao serviço do etcd (que também roda dentro de um contêiner) seu próprio IP e a porta exposta. Certo? Agora vamos ao passo a passo.

Configurando o Docker Host

Instalação

Vamos utilizar um servidor com o Debian Jessie 64 bit instalado. Pode ser qualquer distro Linux com Kernel maior ou igual a 3.1.0. Se você possui interesse em montar um cluster Docker, sugiro distros específicas como CoreOS ou RancherOS.

Todos os comandos serão executados como root nesse artigo apenas para facilitar o entendimento. No ambiente de produção, eu aconselho a criar outros usuários e definir suas políticas de segurança.

As instruções para instalação de acordo com a documentação são:

$ sudo apt-get purge lxc-docker*
$ sudo apt-get purge docker.io*
$ sudo apt-key adv --keyserver hkp://p80.pool.sks-keyservers.net:80 --recv-keys 58118E89F3A912897C070ADBF76221572C52609D
$ sudo echo "deb https://apt.dockerproject.org/repo debian-jessie main" > /etc/apt/sources.list.d/docker.list
$ sudo apt-get update
$ sudo apt-cache policy docker-engine
$ sudo apt-get install -y docker-engine

Estrutura de diretórios

Usaremos a seguinte estrutura:

  • /src – códigos de cada aplicação web.
  • /static – arquivos estáticos como páginas html, imagens, css e javascript.
  • /data – arquivos de dados do MySQL ou Postgres, configuração do servidor web e logs.

Com isso, crie os diretórios no servidor:

$ sudo mkdir /src /static /data

Criando os contêineres

Para seguir as boas práticas, é importante ter apenas um serviço em execução por contêiner criado. Por isso, vamos criar os seguintes contêineres:

  • etcd: para armazenar nome, ip e porta dos contêineres rodando aplicações web;
  • dnsdock: para permitir comunicação entre contêineres por meio do hostname;
  • postgres: banco de dados;
  • goapp: aplicação web escrita em Go;
  • caddy: servidor web em Go, rápido e performático, para servir estáticos e fazer proxy reverso com as aplicações web.

Agora, vamos falar detalhadamente sobre cada um desses contêineres e criá-los efetivamente.

etcd

etcd é um sistema distribuído para armazenamento de chave/valor. Sua utilidade nessa arquitetura é armazenar como chave o nome do contêiner e como valor o ip e a porta. Cada contêiner rodando uma aplicação web vai ser responsável por atualizar esses valores no etcd e o servidor web vai ler esses valores para criar e configurar um proxy reverso, como mostraremos depois.

$ ETCD_DATA_DIR=/data/etcd
$ DOCKER_IP=172.17.0.1
$ ETCD_PORT=2379
$ sudo mkdir $ETCD_DATA_DIR
$ sudo docker run -d --name etcd \
  -p $ETCD_PORT:$ETCD_PORT \
  -v $ETCD_DATA_DIR:$ETCD_DATA_DIR \
  -e ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:$ETCD_PORT \
  -e ETCD_DATA_DIR=$ETCD_DATA_DIR \
  -e ETCD_ADVERTISE_CLIENT_URLS=http://$DOCKER_IP:$ETCD_PORT \
  quay.io/coreos/etcd:v2.2.3

Com esse comando, criamos um contêiner usando a imagem quay.io/coreos/etcd:v2.2.2 escutando na porta 2379. Você pode testar se o etcd está funcionando corretamente:

$ curl http://172.17.0.1:2379/v2/keys

E o resultado será:

{"action":"get","node":{"dir":true}}

dnsdock

dnsdock é um serviço de descoberta para contêineres similar a um dns. Quando criamos um contêiner, não conseguimos garantir qual IP que o Docker vai fornecer a ele. Isso se torna um problema quando um contêiner precisa se comunicar com o outro, por exemplo, uma aplicação web e um banco de dados. O dnsdock é muito útil nesse ponto.

$ sudo docker run -d --name dnsdock \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -p 172.17.0.1:53:53/udp \
  tonistiigi/dnsdock -nameserver="8.8.8.8:53"

Esse comando executa o dnsdock dentro de um contêiner escutando na porta 53 UDP (porta padrão DNS) e usando o DNS do Google como secundário, caso ele não consiga resolver um nome. Você pode testar executando o dig para obter o ip do contêiner do etcd que foi criado anteriormente:

$ nslookup etcd.docker 172.17.0.1

E o resultado será:

Server:    172.17.0.1
Address 1: 172.17.0.1
 
Name:      etcd.docker
Address 1: 172.17.0.2 etcd.docker

postgres

O Postgres é um banco de dados robusto e popular devido às suas funcionalidades. A aplicação web vai utilizar esse banco para armazenar os dados.

$ cat > /tmp/initial.sql <<EOF
DROP DATABASE IF EXISTS mydb;
CREATE DATABASE mydb
   WITH OWNER postgres
   TEMPLATE template0
   ENCODING 'UTF8'
   TABLESPACE  pg_default
   LC_COLLATE  'en_US.utf8'
   LC_CTYPE  'en_US.utf8'
   CONNECTION LIMIT  -1;
\c mydb;
CREATE TABLE hello (
    message varchar(50) not null,
    created_at date default current_date
);
INSERT INTO hello (message) VALUES ('Hello World');
EOF

$ sudo mkdir /data/postgres
$ sudo docker run -d --name postgres \
  -v /data/postgres:/var/lib/postgresql/data \
  -v /tmp/initial.sql:/tmp/initial.sql \
  -h postgres \
  -e POSTGRES_PASSWORD=postgres \
  -p 5432:5432 \
  postgres:9.5
$ sudo docker exec postgres bash -c "PGPASSWORD=postgres psql -U postgres -f /tmp/initial.sql"

goapp

Exemplo de uma aplicação web com acesso ao banco de dados Postgres. Como já implementamos o dnsdock, é possível acessar o contêiner usando o host postgres.docker. A aplicação web deve retornar a mensagem que foi armazenada no banco de dados.

$ sudo mkdir /src/goapp && cd /src/goapp

$ sudo cat > app.sh <<EOF
IP=`ip add | grep global | awk -F ' ' '{print \$2}' | awk -F '/' '{print \$1}'`
echo "Registering \$SERVICE_NAME:\$SERVICE_PORT in \$ETCD_ADDR"
curl -XPUT \$ETCD_ADDR/v2/keys/containers/\$SERVICE_NAME -d value="\$IP:\$SERVICE_PORT"
go get github.com/lib/pq && go install && /usr/lib/go/bin/goapp
EOF

$ sudo chmod +x app.sh

$ sudo cat > Dockerfile <<EOF
FROM alpine
RUN apk add --update curl bash gcc go musl-dev git openssh-client ca-certificates && rm -rf /var/cache/apk/*
ENV GOPATH /.go
ENV PATH \$PATH:\$GOPATH/bin
RUN go get github.com/tools/godep
WORKDIR /usr/lib/go/src/goapp
EOF

$ sudo cat > main.go <<EOF
package main
import (
    "net/http"
    "fmt"
    "database/sql"
    _ "github.com/lib/pq"
)

func hello(w http.ResponseWriter, r *http.Request) {
    db, _ := sql.Open("postgres", "user=postgres password=postgres dbname=mydb host=postgres.docker sslmode=disable")
    if db.Ping() != nil {
        fmt.Println("Could not connect to database.")
    }
    rows, err := db.Query("select message from hello limit 1")
    defer rows.Close()
    if err != nil {
        fmt.Println("Error running the query.", err)
    }

    rows.Next()
    var message string
    err = rows.Scan(&message)
    if err == nil {
        w.Header().Set("Content-type", "text/plain")
        w.Write([]byte(message))
    } else {
        fmt.Println("Error trying to scan.", err)
    }
}

func main() {
    http.HandleFunc("/", hello)
    fmt.Println("Server has been started.")
    http.ListenAndServe(":8001", nil)
}
EOF

O código acima cria os arquivos app.sh, Dockerfile e main.go dentro do /src/goapp. Agora é necessário criar uma imagem a partir do Dockerfile e depois criar um contêiner para compilar e executar o main.go.

$ cd /src/goapp
$ sudo docker build -t=goapp .
$ sudo docker run -d --name goapp \
    -e ETCD_ADDR=http://etcd.docker:2379 \
    -e SERVICE_NAME=goapp \
    -e SERVICE_PORT=8001 \
    -p 8001:8001 \
    -v /src/goapp:/usr/lib/go/src/goapp --dns 172.17.0.1 \
    goapp bash -c "/usr/lib/go/src/goapp/app.sh"

As variáveis ETCD_ADDR, SERVICE_NAME e SERVICE_PORT são utilizadas pelo script app.sh para armazenar esses valores no etcd. O parâmetro –dns 172.17.0.1 é para que o contêiner utilize o dnsdock e consiga se comunicar com o host postgres.docker. No final, a aplicação web é executada na porta 8001.

caddy

caddy é um servidor web que será utilizado para proxy reverso com a aplicação web. Ele é usado em conjunto com o confd para obter todas as aplicações web registradas no etcd e, a partir de um template, criar o arquivo de configuração Caddyfile, no qual cada aplicação web vai ser configurada como sub-domínio. Por exemplo, goapp.meudominio.com vai fazer um proxy para o contêiner goapp.

Antes de tudo, precisamos criar os arquivos que o confd utilizará para criar o Caddyfile:

$ sudo mkdir -p /data/caddy/logs
$ sudo mkdir -p /data/confd/conf.d
$ sudo mkdir -p /data/confd/templates
 
$ sudo cat > /data/confd/conf.d/caddy.toml <<EOF
[template]
src = "caddy.tmpl"
dest = "/etc/caddy/Caddyfile"
keys = [
    "/containers",
]
owner = "root"
mode = "0644"
EOF
 
$ sudo cat > /data/confd/templates/caddy.tmpl <<EOF
# Generated by confd {{datetime}}
meudominio.com {
    root /static
    browse
    tls off
    errors /etc/caddy/logs/static.log
    cors
}
{{range gets "/containers/*"}}
{{\$name := base .Key}}
{{\$name}}.meudominio.com {
    proxy / {{.Value}} {
        proxy_header Host {host}
    }
    errors /etc/caddy/logs/{{base .Key}}.log
    tls off
}
{{end}}
EOF

Quando o confd for executado, ele vai ler o arquivo de configuração caddy.toml e informar que o template utilizado será o caddy.tmpl, além de que o resultado renderizado será salvo em /etc/caddy/Caddyfile. Nesse caso, os diretórios /etc/caddy e /etc/confd serão volumes montados a partir do host.

O próximo passo é criar o script que vai usar o confd para consultar o etcd, gerar o Caddyfile e executar o caddy:

$ mkdir /tmp/caddy-confd-docker && cd /tmp/caddy-confd-docker
 
$ cat > start.sh <<EOF
CONFD_TOML="/etc/confd/conf.d/caddy.toml"
CADDYFILE="/etc/caddy/Caddyfile"
confd -onetime -node \$ETCD_ADDR -config-file \$CONFD_TOML && caddy --conf \$CADDYFILE
EOF
 
$ chmod +x start.sh

Depois, vamos criar o Dockerfile para gerar uma imagem:

$ cd /tmp/caddy-confd-docker
 
$ cat > Dockerfile <<EOF
FROM alpine
RUN apk add --update openssh-client git tar \
&& mkdir /caddysrc \
&& curl -sL -o /caddysrc/caddy_linux_amd64.tar.gz "https://caddyserver.com/download/build?os=linux&arch=amd64&features=cors&git&ipfilter&jsonp&search" \
&& tar -xf /caddysrc/caddy_linux_amd64.tar.gz -C /caddysrc \
&& mv /caddysrc/caddy /usr/bin/caddy \
&& chmod 755 /usr/bin/caddy \
&& rm -rf /caddysrc \
&& printf "0.0.0.0\nbrowse" > /etc/Caddyfile \
&& curl -sL -o /usr/bin/confd "https://github.com/kelseyhightower/confd/releases/download/v0.11.0/confd-0.11.0-linux-amd64" \
&& chmod 755 /usr/bin/confd
EXPOSE 2015
EXPOSE 443
VOLUME /etc/caddy
VOLUME /etc/confd
ADD start.sh /bin/start.sh
CMD /bin/start.sh
EOF

Agora, vamos criar a imagem e executar o contêiner:

$ cd /tmp/caddy-confd-docker
$ sudo docker build -t=caddy-confd .
$ sudo docker run -d --name caddy \
    -v $HOME/.caddy:/root/.caddy \
    -v /data/caddy:/etc/caddy \
    -v /data/confd:/etc/confd \
    -v /static:/static \
    -e ETCD_ADDR=http://etcd.docker:2379 \
    -p 80:2015 -p 443:443 \
    --dns 172.17.0.1 \
    caddy-confd

Se tudo funcionou corretamente, a aplicação vai estar acessível via http://goapp.meudominio.com. Lembrando que você deve substituir meudominio.com pelo seu domínio verdadeiro, certo?!

Bônus

Execução dos contêineres durante o boot

É possível configurar os contêineres para serem iniciados durante o boot do servidor. Na documentação existem exemplos utilizando Systemd, Upstart e Supervisor. Para configurar no Debian (systemd):

$ sudo cat > /etc/systemd/system/goapp.service <<EOF
[Unit]
Description=My API written in Go
Requires=docker.service
After=docker.service
 
[Service]
Restart=always
ExecStart=/usr/bin/docker start -a goapp
ExecStop=/usr/bin/docker stop -t 2 goapp
 
[Install]
WantedBy=multi-user.target
EOF
 
$ sudo systemctl enable /etc/systemd/system/goapp.service

Monitoramento

A ferramenta New Relic possui um plano free e é uma ótima opção para monitorar o host. Para monitorar os contêineres existe o cAdivisor. Execute o comando abaixo e acesse http://meudominio.com:8080:

$ sudo docker run -d --name cadvisor \
    -v /:/rootfs:ro \
    -v /var/run:/var/run:rw \
    -v /sys:/sys:ro \
    -v /var/lib/docker/:/var/lib/docker:ro \
    -p 8080:8080 \
    google/cadvisor:latest

Conclusão

Deploy utilizando Docker é uma das práticas de DevOps que trazem mais segurança e agilidade aos times. Outras ferramentas como Docker Compose e Docker Machine agilizam ainda mais a vida do desenvolvedor.

Na arquitetura que usamos nesse artigo foram utilizados alguns patterns para desenvolvimento de microservices. Percebam que para registrar o serviço foi utilizado um script que envia ao etcd o IP e a porta do contêiner por meio do curl. Para novos projetos web teremos que adicionar esse script também. Há um detalhe: se o contêiner for finalizado,é preciso criar algum mecanismo para remover as informações desse contêiner do etcd.

Existe um projeto chamado Registrator que automaticamente registra e desregistra todos os contêineres que estão rodando. Até a versão atual não é possível filtrar quais contêineres devem ser registrados.

A cada novo contêiner web criado é necessário destruir e executar o contêiner caddy para atualizar as definições de proxy reverso, pois o caddy não suporta reload. Se preferir, pode utilizar o nginx que possui essa funcionalidade.

Consul é uma alternativa ao etcd e possui uma interface web. O confd usado junto com o caddy como exemplo também possui uma alternativa chamada consul-template.

Por último, se você pensa em escalabilidade, balanceamento de carga, cache ou criar um cluster, por exemplo, existem outras distros que podem ajudar, como CoreOSRancherOS. Vale a pena estudá-las. Lembrem-se sempre de bloquear no firewall as portas que não devem ser expostas publicamente.

E é isso! Ficou alguma dúvida ou tem alguma observação? Fique à vontade para usar os campos abaixo. Até a próxima!