Back-End

4 mai, 2016

Deploy de um aplicativo web em Clojure com Pallet

Publicidade

Pallet é um plataforma/biblioteca Clojure DevOps projetada para automatizar blá, blá, blá. Nós todos sabemos o que essas coisas devem fazer, a questão é como elas fazem, e Pallet toma um rumo agradável de ser apenas uma biblioteca Clojure que você pode executar em um repl e fazer coisas devops.

Essa é a parte boa do Pallet. A parte ruim é que a documentação é extremamente incompleta, pelo menos para pessoas como eu que querem apenas executar algum projeto modesto. Eu espero fornecer neste artigo não apenas um resumo do meu (relativamente pequeno) caso de uso, mas explicar alguns conceitos-chave do Pallet em um nível que os documentos oficiais, bem, apenas não se preocupam em fazer.

A outra parte ruim do Pallet é que ele não está em desenvolvimento ativo e partes dele estão desatualizadas. O website dele fala muito sobre a sua capacidade de automatizar a alocação de recursos do servidor, o que ele faz usando JClouds. Isso provavelmente funciona muito bem se você estiver na AWS, mas quando eu tentei fazer funcionar com o DigitalOcean, a versão do JClouds que suporta a API DO v2, que é agora a única utilizável mas era demasiada nova para o Pallet, nada funcionou. Então, este artigo vai desconsiderar a alocação de recursos e usar apenas o provedor node-list, que é onde você apenas diz ao Pallet onde encontrar os servidores que você mesmo procura. Dessa forma, podemos ficar com as partes sólidas do Pallet que funcionam em infraestruturas com décadas de idade (SSH) e provavelmente não vão ficar mal tão rápido.

A parte mais interessante do Pallet é como você descreve as tarefas que você quer que ele faça, e é nisso que eu irei focar. Primeiro, aqui está uma hierarquia rápida das coisas no Pallet, apresentadas como um glossário útil.

O menor glossário possível

  • Uma ação (action) é uma coisa que você faz em um servidor. Instalar um pacote, criar um arquivo, executar um script etc.
  • Um plano (plan) é uma coleção/sequência de ações. Ele se parece muito com uma função.
  • A fase (phase) é um conjunto de ações e/ou planos, marcado com um identificador que esperamos que descreva o que essas ações fazem (por exemplo, “:install”).
  • O server spec é um conjunto de fases, que realizam tarefas relevantes em um determinado componente de uma implantação global. Por exemplo, um server spec para nginx pode conter uma fase :install para instalar o nginx, uma fase :configure para atualizar a configuração, e uma fase :run para começar o serviço.
  • Caixa (crate) é como Pallet chama um plugin ou biblioteca, ou o server spec invariavelmente contido, porque, presumivelmente, a metáfora é apenas muito bonita para deixar passar.
  • O group spec é um conjunto de especificações de servidor (e talvez algumas fases personalizadas) descrevendo um deploy completo para um servidor.

Isso deve dar a você mais pistas sobre como é o Pallet do que eu consegui vasculhando docs. Você pode combinar ações em fases, em especificações de servidor, em especificações de grupo, e construir a sua configuração final dessa maneira.

Note que as fases podem ser mais ou menos o que você quiser, exceto que um phase :settings sempre é executado automaticamente antes de qualquer outra fase ser executada.

Escrevendo um server spec para boot

Aqui está um pequeno exemplo de início com o Pallet. Meu projeto foi escrito com o Boot, e como eu usei boot-ragtime para migrações, queria ter um boot install funcionando no servidor.

Pallet permite abstrair isso de uma forma muito simples. Aqui está toda a implementação da minha caixa boot:

(ns ops.boot
  (:require
    [pallet.api :as api]
    [pallet.actions :refer [exec-script]]
    [pallet.crate :refer [defplan]]
    ))


(defplan install-boot
  []
  (exec-script
    ("wget" "https://github.com/boot-clj/boot-bin/releases/download/latest/boot.sh"))
  (exec-script ("mv" "boot.sh" "boot"))
  (exec-script ("chmod" "a+x" "boot"))
  (exec-script ("sudo" "mv" "boot" "/usr/local/bin")))


(defn server-spec
  [settings & {:keys [] :as options}]
  (api/server-spec
   :phases {:install (api/plan-fn
                       (install-boot))}
   :default-phases [:install]))

Esse é curto, mas provavelmente denso se você nunca usou Pallet antes. Em primeiro lugar, cada uma das chamadas do exec-script é uma ação. Estas são os nossos blocos de construção básicos, lembra? Aqui, nós baixamos o boot com wget, e executamos a sequência de comandos recomendados pelo boot direto da sua documentação de instalação:

$ mv boot.sh boot && chmod a+x boot && sudo mv boot /usr/local/bin

Note que, se uma ação falhar, a coisa toda falha, e o servidor é deixado em um estado de semiconfiguração, o que pode ou não ter importância, dependendo do que você está fazendo. Isso torna && implícito. A beleza de automatizar esse material é, naturalmente, a repetibilidade; eu destruí a imagem do meu servidor pelo menos duas vezes enquanto brigava com tudo isso, e não perdi nenhum progresso real como resultado.

(Se exec-script lhe dá arrepios, você pode fazer o mesmo com exec-script*, que executa uma cadeia de caracteres que você fornece.)

Tendo definido um plano, vamos agora criar um server spec com uma única fase: install boot. O server spec é uma coisa que podemos parafusar com a nossa configuração final para fornecer uma capacidade ou outra.

Note também que o server spec aceita alguns argumentos (configurações e opções, ugh), e a função do plano install-boot tem a capacidade de fazer isso também. Você poderia, por exemplo, aceitar como argumento o diretório bin em que pretende instalar o boot, e passar isso por todo o caminho para o exec-script final no plano.

Um serviço mais complexo

Vamos para o exemplo atual. Aqui, eu irei definir um group spec personalizado para meu aplicativo. Aqui está o que precisamos:

  • Boot
  • Java (obviamente)
  • Postgres 9.3 (mais configuração)
  • Nginx (mais configuração)
  • Configuração Upstart (para parada/arranque automático)

Como você pode estar esperando, cada um deles pode mais ou menos ser expresso como um server spec. Na verdade, Java, Nginx e Upstart estão disponíveis como caixas: java-crate, upstart-crate, nginx-crate. Note que a caixa nginx não é a partir do projeto Pallet; o Pallet não foi atualizado para suportar tanto o nginx mais recente ou versões mais recentes do Pallet, por isso estamos usando esse fork (que felizmente tem seu próprio jar no clojars).

Postgres tem uma caixa também, mas é também para uma versão antiga do Pallet. Mas rolar a nossa própria pode ser uma experiência instrutiva, então vamos olhar nisso.

Configurando Postgres

Aqui, nós apenas precisamos instalar o Postgres e criar um usuário e um banco de dados. Eu escolhi fazer isso assim:

(require
 '[pallet.api :as api]
 '[pallet.action :refer [with-action-options]]
 '[pallet.actions :as actions :refer [exec-script]])

(defn db-server-spec
  [{:keys [dbname user password] :as settings} & {:keys [] :as options}]
  (api/server-spec
    :phases {:install (api/plan-fn
                        (actions/package "postgresql-9.3")
                        (with-action-options {:sudo-user "postgres"}
                          (let [q (str "CREATE ROLE "
                                       user
                                       " WITH LOGIN PASSWORD '"
                                       password
                                       "'")]
                            (exec-script ("psql" postgres -c ~(str "\"" q "\"")))
                            (exec-script ("createdb" ~dbname -O ~user)))))
             }))

Aqui, nós definimos a nossa única fase inline :install.

  • O pacote action vai usar o gerenciador de pacotes padrão do sistema (aptitude, já que eu estou usando o ubuntu) e obter o pacote solicitado.
  • O contexto with-action-options irá modificar a forma como as ações fechadas são executadas – neste caso, executadas como um usuário postgres que é criado automaticamente com a instalação do pacote postgresql-9.3.
  • Eu construí uma query para criar o role, porque eu não queria ser incomodado para aprender a lidar com o diálogo interativo de senha que createuser exigiria. Note que eu não citei o postgres -c na chamada exec-script, e também que eu não citei a chamada para str que eu uso para envolver a query que eu construi entre aspas. Acontece que exec-script vai transformar em string (stringify) o que você passar para ele, a menos que você marque com ~.
  • Finalmente, eu criei o banco de dados com o usuário que acabei de criar como proprietário.

Configurando Nginx

Nós não precisamos fazer o nosso próprio server spec para isso, mas precisamos construir um mapa de configurações para passar para o server spec nginx para criar o nosso arquivo de configuração. Eles escolheram para criar uma DSL que representa um arquivo de configuração NGINX como uma estrutura de dados Clojure, o que é estranho, mas também acessível. Aqui está a minha:

(def nginx-settings
  {:sites
   [{:action :enable
     :name "default.site"
     :upstreams
     [{:lines
       [{:server "127.0.0.1:8080"}
        {:keepalive 32}]
       :name "http_backend"}]
     :servers
     [{:access-log ["/var/log/nginx/app.access.log"]
       :locations
       [{:path "/"
         :proxy-pass "http://http_backend"
         :proxy-http-version "1.1"
         :proxy-set-header
         [{:Connection "\"\""},
          {:X-Forwarded-For 
           "$proxy_add_x_forwarded_for"}
          {:Host "$http_host"}]}]}]}]})

Configurando Upstart

Isso exigiu uma séria referência ao código-fonte (eu usei a caixa riemann para referência), mas consegui descobrir como configurar isso.

Primeiro: você precisa chamar a função pallet.crate.service/supervisor-config com um identificador e as configurações que você quer usar. Aqui está a minha função de definições:

(defplan app-server-settings
  [& {:keys [user project-dir service-name run-command db-name db-user db-password]}]
  (let [settings {:version "0.1.0"
                  :user user
                  :group user
                  :owner user
                  :run-command run-command
                  :chdir project-dir
                  :service-name service-name
                  :supervisor :upstart
                  :db-name db-name
                  :db-user db-user
                  :db-password db-password
                  }]
    (service/supervisor-config :app-server settings {})))

Em seguida, você precisa definir um método para pallet.crate.service/supervisor-config-map multimétodo, configurando o seu job upstart:

(defmethod supervisor-config-map [:app-server :upstart]
  [_ {:keys [run-command service-name user chdir db-name db-user db-password] :as settings} options]
  {:service-name service-name
   :exec run-command
   :chdir chdir
   :setuid user
   :env [(str "DATABASE_NAME=" db-name)
         (str "DATABASE_USER=" db-user)
         (str "DATABASE_PASSWORD=" db-password)]})

Aqui, eu configuro o meu job para fornecer algumas variáveis de ambiente e executar um determinado comando em um diretório específico como um usuário particular. Então parece que temos algumas configurações a fazer para garantir que os diretórios, os usuários e os comandos existam.

Configurando o aplicativo

Aqui está a configuração que eu usei. Eu criei um usuário com um diretório home porque o boot precisa de um local para se instalar, e usei alguns comandos da caixa git para criar um repositório vazio, e um check-out do referido depósito para esse usuário.

(defplan app-server-install
  [& {:keys [user repo-dir project-dir] :as settings}]

  ; Init user
  (actions/user user
        :system false
        :home (str "/home/" user)
        :shell "/bin/bash"
        :action :create)
  (actions/directory (str "/home/" user) :owner user)

  ; Init repo
  (git/git "init" "--bare" repo-dir)
  (exec-script ("chown" -R ~(str user ":" user) ~repo-dir))
  (actions/directory project-dir :owner user :group user)
  (with-action-options {:script-dir project-dir :sudo-user user}
    (git/clone repo-dir :checkout-dir ".")))

Dessa forma, eu posso usar git para o push do meu projeto e, em seguida, executar o meu :deploy phase para puxar as alterações, executar migrações ou fazer qualquer outra coisa, e depois reiniciar o job upstart. Na verdade, aqui está o plano que faz exatamente isso:

(defplan app-server-deploy
  [& {:keys [project-dir user db-name db-user db-password service-name] :as settings}]
  (with-action-options {:script-dir project-dir
                        :sudo-user user
                        :script-env {"DATABASE_USER" db-user
                                     "DATABASE_NAME" db-name
                                     "DATABASE_PASSWORD" db-password}}
    (git/pull :branch "master" :remote "origin")
    (exec-script ("boot" ragtime -m)))
  (service/service {:supervisor :upstart
                    :service-name service-name}
                   {:action :restart}))

Observe as variáveis env presentes de modo que boot pode encontrá-las e configurar ragtime para acessar o banco de dados.

Isto é tudo que precisamos para a nossa especificação app:

(defn app-server-spec
  [{:keys [repo-dir project-dir user service-name run-command
           db-name db-user db-password] :as settings}
   & {:keys [] :as options}]
  (api/server-spec
    :phases {:settings (api/plan-fn (app-server-settings
                                      :user user
                                      :project-dir project-dir
                                      :run-command run-command
                                      :service-name service-name
                                       :db-name db-name
                                       :db-user db-user
                                       :db-password db-password
                                      ))
             :install (api/plan-fn (app-server-install
                                     :project-dir project-dir
                                     :repo-dir repo-dir
                                     :user user
                                     ))
             :configure (api/plan-fn
                          (service/service {:supervisor :upstart
                                            :service-name service-name}
                                           {:action :enable}))
             :run (api/plan-fn
                    (service/service {:supervisor :upstart
                                      :service-name service-name}
                                     {:action :start}))
             :deploy (api/plan-fn (app-server-deploy
                                    :project-dir project-dir
                                    :user user))
             }))

Observe as duas novas fases, :configure e :run, que apenas atualizam o upstart. Além disso, esse é um bom momento para mencionar que :configure é a fase “default”, e executa se nada mais for mencionado.

Colocando tudo junto

Agora, tudo o que resta é criar o group spec principal que vai completar uma configuração de um servidor com todos esses diferentes aspectos que já definimos. Fazemos isso por meio de um grupo saudável de server spec na chave :extends do group spec. Veja como isso ficou:

(defn app-group
  [& {:keys [repo-dir project-dir user service-name run-command
           db-name db-user db-password group-name]}]
  (api/group-spec
    group-name
    :extends [(upstart/server-spec {:service-dir "/etc/init"
                                    :bin-dir "/usr/bin"
                                    })
              (git/server-spec {})
              (java/server-spec {:version [7] :os :linux})
              (nginx/nginx nginx-settings)
              (boot/server-spec {})
              (db-server-spec {:dbname db-name
                               :user db-user
                               :password db-password})
              (app-server-spec {:repo-dir repo-dir
                                :project-dir project-dir
                                :user user
                                :service-name service-name
                                :run-command run-command
                                :db-name db-name
                                :db-user db-user
                                :db-password db-password
                                })
              ]
    :phases {
             :test (api/plan-fn (exec-script ("touch" "pallet.txt")))
             }))

Aqui, nós instanciamos todas essas características que eu mencionei antes, bem como git e Java, e as duas especificações personalizadas que fizemos para a criação de postgres e executar o aplicativo. Finalmente, é hora de tentar um deploy. (Na verdade, IRL provavelmente você vai estar fazendo isso em um REPL e pouco a pouco.)

Executando sua especificação

Em primeiro lugar, alguns itens de configuração:

(require
     '[pallet.core.user :refer [make-user]]
     '[pallet.compute :refer [instantiate-provider]]
     '[pallet.compute.node-list :as nl])

(def root-user
  (make-user "root"
             {:private-key-path "/path/to/.ssh/id_rsa"
              :public-key-path "/path/to/.ssh/id_rsa.pub"}))


(def my-nodelist
  (instantiate-provider
    "node-list"
    :node-list [(nl/make-node "example.com" "example" "example.com" :ubuntu
                              :os-version "14.04")
                ]))

Temos que entregar para o Pallet as nossas chaves SSH para que ele possa se conectar ao servidor como nós, e instanciar o nosso “provedor de computação” (compute provider), que é o que Pallet (bem, na verdade jclouds) chama um servidor que não é uma “blob store”. O provedor de lista de nós é apenas uma lista de nós que você pode fazer com a função make-node. Os argumentos esperados são: name group-name ip os-family, seguidos de quaisquer argumentos de chave-valor que você gostaria de jogar nele. Note que eu usei um nome de host como o IP; isso aparentemente funciona bem. O name e group-name não serão muito usado por nós, a não ser que o group-name deva corresponder ao group-name que você forneceu para o seu group spec, mas se você tem vários servidores que queira configurar da mesma forma, você poderia dar a eles o mesmo nome do grupo e aplicar o group spec para esse grupo.

Pallet fornece duas funções que aplicam especificações a nós: converge e lift. Elas são basicamente a mesma coisa, exceto que converge tem a permissão de criar ou destruir nós e lift, não. Desde que a configuração de lista de nós não faça disso um problema, vamos usar lift.

lift requer, pelo menos, um group spec, um provedor (nossa lista de nós), um usuário (o usuário root), e um argumento :phase. Aqui, eu vou te mostrar:

(def my-group (app-group :group-name "example" :all "that" :other "stuff" :too "...")) 

(api/lift my-group
  :compute my-nodelist
  :user root-user
  :phase :install)

Quando você está executando coisas, você só vai variar a fase (normalmente :install -> :configure -> :deploy -> :run ou algo assim) e pronto.

Configurando seu projeto ops

Eu não posso recomendar o modelo lein Pallet. É tudo antigo e inferior. Eu costumava usar o boot com essas dependências e tudo corria bem:

[[org.clojure/clojure "1.7.0" :scope "provided"]
[environ "1.0.1"]
[com.palletops/pallet "0.8.2"]
[com.palletops/pallet-jclouds "1.7.3"]
[org.apache.jclouds/jclouds-all "1.7.3"]
[org.apache.jclouds.driver/jclouds-sshj "1.7.3"]
[com.palletops/upstart-crate "0.8.0-alpha.2"]
[com.palletops/git-crate "0.8.0-alpha.2"]
[com.palletops/java-crate "0.8.0-beta.4"]
[org.clojars.strad/nginx-crate "0.8.6"]]

Ok, estes são todos os conselhos que eu posso dar. Espero que você tenha uma vida mais fácil do que a que eu tive. Boa sorte!

***

Adam Bard faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: https://adambard.com/blog/deploying-a-clojure-project-with-pallet/