Home

Escrevendo Dockerfiles em Haskell

por Pedro Tacla Yamada

Nesse post, anuncio o language-dockerfile, um parser, pretty-printer, linter e EDSL para escrever Dockerfiles em Haskell. Tentarei explicar sua motivação, como o usar e onde gostaria de chegar no futuro. Fica para um outro post discutir sua implementação usando Monads livres.


Dia 27/09/2016, teremos o 8º Encontro de Haskellers de São Paulo na Amazon. Reserve sua vaga no Meetup.com e envie sua palestra e/ou ideia para https://goo.gl/forms/5Xh3VbziQld29pFw1.


Minha agenda será:

Dockerfiles seguindo as normas

A plataforma de “containerização” Docker emergiu recentemente, como uma forma simples de esconder a complexidade de criar e orquestrar aplicações isoladas em “containers” dos times de desenvolvimento.

Junto com seu cliente de linha de comando, daemon, registro de imagens e outras funcionalidades, o Docker se utiliza de uma linguagem declarativa para a definição de imagens para rodar aplicações por meio das Dockerfiles. A sintaxe dessa linguagem é muito simples, começamos declarando qual é a imagem “base” para nossa construção, usando a diretiva FROM e seguimos adicionando arquivos e executando comandos sobre ela com as diretivas RUN e ADD.

Dockerfiles para linguagens interpretadas

Vamos analisar um exemplo de uma Dockerfile escrita para criar uma imagem de um projeto Node.js, adicionando seu código-fonte e instalando suas dependencias.

FROM node:6
ADD . /app
RUN cd /app && npm install

Para cada “etapa” ou diretiva nessa definição, o Docker criará uma imagem intemediária que ficará em cache na sua máquina até que suas dependências mudem. Se escrevermos essa declaração em um arquivo Dockerfile no root do nosso projeto, podemos construir e rodar uma imagem com ele, rodando:

docker build -t myapp:latest .
docker run -it --rm myapp:latest bash -c 'cd /app && node .'

Se você é familiar com o Docker e já sabia disso, sabe que esse exemplo não é idiomático. Isso é porque, toda vez que o código do nosso projeto mudarem, teremos que reconstruir tudo desde a segunda diretiva, que copia o projeto para o diretório /app da imagem, mesmo se nossas dependencias não mudarem, o que pode ser proibitivamente demorado.

A prática comum seria tentar instalar as dependencias primeiro, depois copiar o código do projeto; assim, somente quando as dependencias mudarem, precisaríamos as re-instalar:

FROM node:6
ADD ./package.json /app/package.json
RUN cd /app && npm install
ADD . /app

Também seria uma prática comum, usar as diretivas CMD e WORKDIR, para que a imagem em si contivesse qual o comando necessário para subir esse container e qual seu diretório de trabalho:

FROM node:6
ADD ./package.json /app/package.json
WORKDIR /app
RUN npm install
ADD . /app
CMD node .

Com isso, podemos encurtar o script para construir e rodar a imagem para:

docker build -t myapp:latest .
docker run -it --rm myapp:latest

Sem a parte bash -c 'cd ...' que deixa vazar essa informação para o “callee”/”end-user” da imagem.

Também poderíamos usar uma imagem menos pesada, como uma versão do Node.js rodando sobre a seca distribução do Linux Alpine.


Como nossa única forma de compartilhar lógica desses arquivos de geração de imagens é a herança a partir de uma imagem base (a diretiva FROM), esse tipo de “melhor prática” deve ser lembrada de novo e de novo e re-codificada para cada serviço construído.

Você poderia escrever scripts que compartilha entre Dockerfiles, mas isso se limita de algumas formas. Por exemplo, não há como escrever um script que encapsule a ideia de que vou adicionar o manifesto das minhas dependencias, executar um comando e então adicionar meu projeto, já que um script não tem acesso às diretivas do Docker como ADD, CMD etc.

Dockerfiles para linguagens compiladas

No exemplo acima, olhamos para JavaScript, plataforma na qual, para o servidor, o artefato entregue envolve o código fonte e suas dependencias, em geral, sem uma etapa de compilação. Em contraste, no caso de uma linguagem como Haskell ou Go, o artefato entregue é um binário, gerado a partir do código-fonte e as dependencias para gerar esse binário e o executar são diferentes.

Poderíamos criar uma imagem para compilar e executar um projeto Haskell da seguinte forma:

FROM haskell:7
ADD ./myapp.cabal /app/myapp.cabal
WORKDIR /app
RUN cabal update
RUN cabal sandbox init
RUN cabal install --only-dep -j
ADD . /app
RUN cabal install
CMD myapp

Nesse exemplo, poderíamos usar a mesma imagem para compilar o projeto e executar o binário gerado. Apesar de simples, essa estratégia é lenta e custosa e não é recomendada, porque podemos gerar imagens muito menores só incluindo os resultados da compilação e suas dependencias, sem ferramentas como o ghc, cabal, stack etc.

Idealmente gostaríamos de ter duas imagens:

Scripts que geram Dockerfiles e Imagens

Outra abordagem é a de ter um script gerando nossos artefatos e então suas imagens equivalentes.

A nova ferramenta stack do Haskell resolve parte desse problema oferecendo integração built-in com o Docker para compilar projetos dentro de um container, compartilhando o cache de compilação com o resto do sistema.

Podemos escrever um script como:

stack docker pull
stack build --docker
# ^ Compila o projeto e todas suas dependencias em um container

install_root=`stack path --local-install-root --docker`
# ^ O path para o diretório contendo os arquivos gerados pelo build

cat > Dockerfile <<EOF
# Partimos de uma imagem muito pequena do Alpine Linux
FROM alpine
# Adicionamos as bibliotecas compartilhadas que nosso executável precisa
RUN apk add gmp glib musl musl-dev
# Adicionamos o binário
ADD $install_root /app/
# Registramos o binário como o ponto de entrada com a localização das
# bibliotecas
CMD LD_LIBRARY_PATH=/usr/local/lib:/lib /app/bin/myapp
EOF
# ^ Um Template para criar a imagem com o binário que geramos

Apesar do fato de que o toolchain de Haskell não produz binários completamente estáticos, trazendo um pouco de complexidade, há algumas coisas das quais não conseguiríamos fugir mesmo em uma linguagem que gerasse um único artefato executável independente.

Primeiro, precisamos de uma etapa de construção do artefato orquestrada com uma etapa de construção da imagem.

Segundo, a definição da construção imagem depende da primeira etapa, ou, ao menos, precisa saber onde encontrar o binário e arquivos de suporte a serem entregues.

De novo, a abstração de herança por meio imagens base é insuficiente para chegar no resultado final de forma modular. O conhecimento codificado nesse script está isolado e só pode ser re-utilizado se copiado de um lugar para o outro.


Os problemas em suma

Nesses dois exemplos encontramos dois problemas com o uso isolado de Dockerfiles:


“The Dockerfile Explosion and the need for higher level tools”

Antes de apresentar minha solução para essas questões, devo mencionar que comecei a pensar nesse problema depois de assistir a palestra “The Dockerfile Explosion and the need for higher level tools” da DockerCon 2016.

Na palestra, Gareth Rushgrove, um engenheiro trabalhando no sistema manejamento de infraestrutura Puppet, tenta apontar essa questão da dificuldade de compartilhar conhecimento de uma receita de construção de uma imagem para outra, dentro de uma grande organização ou da comunidade como um todo. Olhando para algumas das alternativas.

Rocker - Um formato alternativo para Dockerfiles

Outra ferramenta mencionada é o rocker, um formato extendido da sintaxe de Dockerfiles que permite, entre outras coisas, suporte a mais de uma diretiva FROM em uma única declaração, o que resolve de certa forma o problema de ter imagens intermediárias entre o código-fonte e o resultado.

Do post linkado acima:

# O primeiro FROM descreve a imagem de compilação
FROM google/golang:1.4
ADD . /src
WORKDIR /src
# Gera app.o no diretório /src
RUN CGO_ENABLED=0 go build -a -installsuffix cgo -v -o app.o app.go
# Exporta app.o para o próximo FROm
EXPORT app.o

# O segundo FROM descreve a imagem de runtime
FROM busybox
# Importa app.o da primeira imagem
IMPORT app.o /bin/app
CMD ["/bin/app"]
TAG app:latest

Ainda assim, não podemos modularizar as instruções de compilação ou construção do container de runtime. Tal conhecimento deve ser codificado de novo e de novo.

Apesar de que a biblioteca de templates do Go é exposta em alguma medida, isso não é o suficiente, ao meu ver, para criar abstrações fáceis de manter e compartilhar.

Uma EDSL em OCaml

Ele menciona o ocaml-dockerfile, uma EDSL para o OCaml com o mesmo propósito do language-dockerfile.

Dois problemas são levantados:

Uma diferença entre a solução do language-dockerfile e esse projeto, é que o primeiro consegue ler Dockerfiles para dentro da EDSL assim como imprimir a EDSL em Dockerfiles, sem nenhuma perda de informação, então você não precisa começar convertendo todas suas Dockerfiles para Haskell, mas, ao invés disso, incrementalmente adicionar inteligência às definições (mais sobre isso abaixo).


Haskell ao nosso resgate

O language-dockerfile é um fork do linter de Dockerfiles hadolint, que expõe uma EDSL em Haskell para contornar esses problemas. Com o bom suporte para facilmente nomear blocos de código e compartilhar módulos que já temos em Haskell, temos uma linguagem para expressar a construção de imagens que não se limita a uma sequência de diretivas.

Além disso, já que Haskell pode isolar side-effects no sistema de tipos, explicitamos a diferença entre “templates” e definições de imagens que podem fazer qualquer tipo de coisa.

Trabalhando na imagem de Node.js

Vejamos um exemplo base, algo algo que gere a primeira Dockerfile apresentada nesse post. Começamos amarrando o que já escrevemos na EDSL:

{-# LANGUAGE QuasiQuotes #-}
import Language.Dockerfile
main = writeFile "./Dockerfile" $ toDockerfileStr $
  [edockerfile|
FROM node:6
ADD ./package.json /app/package.json
WORKDIR /app
RUN npm install
ADD . /app
CMD node .
  |]

Porque Haskell tem suporte a QuasiQuotes (blocos de código escritos em outras linguagens embedados), não precisamos traduzir nada a princípio.

Nesse exemplo:


Podemos começar a quebrar essas 6 linhas em algo que seja genérico:

nodejsImage = do
    [edockerfile|
FROM node:6
ADD ./package.json /app/package.json
WORKDIR /app
RUN npm install
ADD . /app
CMD node .
    |]

Então em todos nossos projetos de Node.js poderíamos nos resumir a:

-- [...] importa a definição acima de alguma forma
import Language.Dockerfile
main = writeFile "./Dockerfile" $ toDockerfileStr nodejsImage

Poderíamos, também, ter criado algo mais extensível como:

nodejsImage extraSteps = do
    from ("node" `tagged` "6")
    extraSteps
    [edockerfile|
ADD ./package.json /app/package.json
WORKDIR /app
RUN npm install
    |]
    add "." "/app"

Que então poderia ser usado tanto como no exemplo base:

-- [...] importa a definição acima de alguma forma
import Language.Dockerfile
main = writeFile "./Dockerfile" $ toDockerfileStr $
    nodejsImage $ return ()

Quanto com mais instruções, executadas em um momento sensível da definição:

-- [...] importa a definição acima de alguma forma
import Language.Dockerfile
main = writeFile "./Dockerfile" $ toDockerfileStr $ 
    nodejsImage $ do
        run "apt-get install imagemagick"

Cabe notar que esse código é publicável e modular, assim como qualquer outro módulo escrito Haskell.

Criando mais diretivas, dessa vez usando o sistema de arquivos no host

Outro exemplo, seria criar um combinador addGlob; algo que falta em Dockerfiles, para copiar todos os arquivos que batem um glob para uma imagem:

{-# LANGUAGE FlexibleContexts #-}
module DockerGlob where

import           Control.Monad        (forM_)
import           Language.Dockerfile
import qualified System.Directory     as Directory
import qualified System.FilePath      as FilePath
import qualified System.FilePath.Glob as Glob

addGlob pattern destination = do
    fs <- liftIO $ do
        cwd <- Directory.getCurrentDirectory
        fs <- Glob.glob pattern
        return (map (FilePath.makeRelative cwd) fs)
    comment $ "Added by globbing for " ++ pattern
    forM_ fs $ \f ->
        add f destination

E então usar esse combinador onde preciso:

import Language.Dockerfile
import DockerGlob
main = do
    df <- toDockerfileStrIO $ do
        -- ...
        addGlob "_posts/*.md" "/posts"
        -- ...
    writeFile "./Dockerfile" df

No repositório desse blog, o código acima gera algo como:

# Added by blogging for _posts/*.md
ADD _posts/2015-12-04-implementando-fibonacci-em-haskell.md /posts
ADD _posts/2015-12-08-aperitivos-de-haskell-24-dias-de-hackage-2015-dia-1-introducao-e-stack.md /posts
# ...

Note que nesse caso, precisamos substituir toDockerfileStr por toDockerfileStrIO. Isso é porque a EDSL por si só não o restringe a ter uma definição pura do seu Dockerfile, mas prove essas duas formas de se certificar do tipo de efeito que pode ser feito na execução de blocos.

Se toDockerfileStr é usado, o bloco que segue não pode ser mais do que um template; o sistema de tipos nos assegura que ele é um bloco puro e sempre se comportará da mesma forma independente do contexto em que for chamado.

Usando toDockerfileStrIO, nós executamos a EDSL no tipo IO do Haskell, o que nos deixa executar ações arbitrárias.

Um framework em um nível mais alto, poderia restringir o tipo de ação acessível para um gerador de imagens.

Controlando o Docker a partir do Haskell (scripting)

Também podemos facilmente escrever helpers como:

import           Crypto.Hash                         (hash, MD5)
import qualified Data.ByteString.Char8 as ByteString (pack)
import           System.Process                      (callCommand)

dockerBuild img = do
    let imgStr = toDockerfileStr img
        imgSum = show (hash (ByteString.pack imgStr) :: Digest MD5)
        imgTag = "haskell-language-dockerfile-autogen:" ++ imgSum
        imgFp = "autogen-" ++ imgSum ++ ".dockerfile"
    writeFile imgFp imgStr
    callCommand ("docker build --tag " ++ imgTag ++ " -f " ++ imgFp ++ " .")
    return imgTag

Aqui, usamos os pacotes cryptohash, process e bytestring, para criar uma imagem dada uma Dockerfile e retornar seu tag automaticamente gerado.

Seu uso seria:

main = do
    image1Tag <- dockerBuild $ do
        from "node"
        -- ...
    -- ...
    image2Tag <- dockerBuild $ do
        from "haskell"
        -- ...
    -- ...

Sem muito mais esforço poderíamos no meio de qualquer código Haskell, criar uma imagem (se ainda não existir) e a executar.

Indo mais longe

Até onde sei, o language-dockerfile é único nas estratégias de geração de Dockerfiles no sentido de que para qualquer bloco puro rodando no seu tipo interno EInstructionM, nós necessariamente temos um Dockerfile equivalente. Então, podemos, como demonstrado, embedar instruções lidas de um Dockerfile normal em meio a EDSL.

Não seria impossível, transformar essa EDSL em um sistema de plugins ao invés de um sistema embedado em Haskell (apesar de que gosto o suficiente de Haskell).

No lugar de rejeitar diretivas inválidas de cara durante o parsing, ele poderia simplesmente as ler, para uma estrutura de dados como UnknownInstruction { uiName :: String, uiArgs :: [String] }.

Uma vez lido, nós poderíamos pesquisar em um mapa de funções Map String ([String] -> EInstructionTM IO () e executar essa substituição.

Conclusão

O pacote language-dockerfile é uma adição simples para pessoas já familiares com Haskell, que pode fazer do manejamento de construção de imagens mais modular e re-utilizável.

Ele é implementado usando a biblioteca free e a EDSL é completamente desacoplada do fato de que gera texto (com outro interpretador, poderia criar imagens, por exemplo), e permite a extensão incremental das declarações já existentes por meio de um QuasiQuoter.

Todo o código está sendo distribuido sob a licença GPLv3 no GitHub em:

https://github.com/beijaflor-io/haskell-language-dockerfile

E no Hackage em:

https://hackage.haskell.org/package/language-dockerfile


Dia 27/09/2016, teremos o 8º Encontro de Haskellers de São Paulo na Amazon. Reserve sua vaga no Meetup.com e envie sua palestra e/ou ideia para https://goo.gl/forms/5Xh3VbziQld29pFw1.

Share Comente no Twitter