Quando pensamos em escalabilidade de software, invariavelmente, em alguma eventualidade nos deparamos com a necessidade de distribuir nosso software.
A grande maioria dos artigos e tutoriais que encontramos e que falam sobre distribuição de software, acabam descrevendo sobre como distribuir usando Kubernetes, Docker Swarm e às vezes preparando o bom e velho docker-compose.yaml
.
Dependendo do tamanho da sua aplicação, às vezes não é necessário ter um cluster de Kubernetes, também em alguns casos queremos obter todo o potencial do servidor, como memória, disco e rede, evitando ter uma camada extra entre a aplicação e a máquina.
Nesse artigo, apresento um compilado de como distribuir a sua aplicação para ser executada diretamente no servidor, por exemplo na sua instância AWS EC2, Google Compute Engine, e quem sabe talvez no seu droplet na Digital Ocean (no final do artigo compartilho um cupom de crédito para você brincar).
Tudo será apresentado em passos que serão incrementais, o empacotamento de uma aplicação em Golang, a preparação das dependências requeridas, a execução, e finalizamos com a atualização e tempo de inatividade.
De forma simplificada, uma aplicação será desenvolvida, e para cada mudança, novas tags
serão criadas, e iremos focar o empacotamento e distribuição usando Ubuntu Linux (server). Ao final deixo o link onde você encontrará o projeto completo.
A aplicação
Antes de tudo, precisaremos de uma aplicação,e ela guardará os nomes dos filmes e seu dia de lançamento. Na parte que cabe ao banco de dados, eles serão armazenados em um banco de dados em arquivo, e é requerido que seja definida uma variável de ambiente chamada MOVIES_DB_PATH
, onde contém a localização deste arquivo.
Vamos para a aplicação em si (de momento não se preocupe com as libs usadas, tudo estará no repositório no final do artigo):
package main
import (
"context"
"os"
"os/signal"
"path/filepath"
"time"
"github.com/labstack/echo/v4"
bolt "go.etcd.io/bbolt"
"github.com/faabiosr/go-movies-demo/internal/movies"
)
const (
appAddr = "0.0.0.0:8000"
appName = "moviez"
dbName = "catalog.db"
dbPathEnv = "MOVIES_DB_PATH"
)
const timeout = 10 * time.Second
func main() {
e := echo.New()
if os.Getenv(dbPathEnv) == "" {
e.Logger.Fatalf("env '%s' was not defined", dbPathEnv)
}
dbPath := filepath.Join(os.Getenv(dbPathEnv), dbName)
// Database connect
db, err := bolt.Open(dbPath, 0o600, nil)
if err != nil {
e.Logger.Fatal(err)
}
ds := movies.NewDatasource(db)
// API Resources
movies.Routes(e.Group("/movies"), ds)
// Start server
e.Logger.Infof("%s service", appName)
go func() {
if err := e.Start(appAddr); err != nil {
e.Logger.Info("shutting down the service")
}
}()
// Graceful shutdown
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
defer cancel()
if err := e.Shutdown(ctx); err != nil {
e.Logger.Fatal(err)
}
}
Note o ponto que informamos anteriormente, a aplicação necessita da variável de ambiente MOVIES_DB_PATH
:
package main
const (
dbName = "catalog.db"
dbPathEnv = "MOVIES_DB_PATH"
)
func main() {
dbPath := filepath.Join(os.Getenv(dbPathEnv), dbName)
// Database connect
db, err := bolt.Open(dbPath, 0o600, nil)
Agora que temos a aplicação de exemplo, é hora de compilar, e é bem simples, o resultado do comando abaixo será um binário com o nome de movies
:
go build -o movies ./cmd/movies
Com o binário em mãos, já podemos copiar e executar no servidor.
MOVIES_DB_PATH=/tmp ./movies
Pronto é isso! Guia finalizado!
WTF?
OK, calma, calma!
Mesmo com o binário criado, a distribuição não é fácil, copiar para o servidor todas as vezes que houver uma nova atualização e executar manualmente pode tornar-se complexo e chato.
Vale ressaltar que não há uma forma consistente para a instalação, e não há como evitar que seja usada outra pasta no servidor, tornando o processo difícil de controlar e passível a erros.
2. Empacotando o binário
Sabendo que a aplicação será executada em um Ubuntu Linux, temos a possibilidade de distribuir como um pacote Debian (deb)
, ou até mesmo como snap
, mas neste exemplo iremos focar no Debian.
Empacotar como .deb
nos dá algumas vantagens, como:
- Poder controlar a versão do pacote.
- Executar scripts antes e depois da instalação/remoção do pacote.
- Adicionar arquivos extras.
Preparar um pacote Debian “na mão”, não é uma tarefa muito simples, e para facilitar a nossa vida vamos usar o GoReleaser, essa ferramenta maravilhosa, que internamente faz uso da é nFPM, responsável por criar pacotes Linux. Também é importante dizer que o GoReleaser não só nos ajuda a criar os pacotes Debian, mas também pacotes para Windows, MacOS, RPM, APK e muito mais.
Em nosso projeto, vamos definir o arquivo .goreleaser.yaml, que contém as informações do pacote a ser gerado, atente-se para seção compartilhada abaixo e note que em formats
foi definido o .deb
:
nfpms:
- id: movies
package_name: movies
file_name_template: "{{ .ConventionalFileName }}"
description: Manages movie collection through API
license: MIT
formats:
- deb
Adicionalmente, foi configurado o Github Workflows onde contém os passos para compilar e distribuir em .deb
, e o resultado é esse:
Todas as versões criadas estarão em releases.
Pronto! Agora sim temos um maior controle sobre o versionamento e distribuição da aplicação. De maneira simples e consistente podemos instalar em qualquer distro baseada em Debian.
3. Lidando com dependências (banco de dados em arquivo)
Anteriormente foi mencionado que a aplicação necessita de uma pasta onde o banco de dados será criado, a pasta elegida será em /var/lib/movies-demo
, e para a criá-la vamos usar alguns dos hooks que pacote Debian nos fornece:
postinst (post-install.sh)
: é executado após instalar ou atualizar um pacote, esse hook ficará responsável por criar a pasta definida acima e também o usuário/grupo dessa pasta (é recomendável que sempre tenha um usuário/grupo, ficando isolado dos demais).
#!/bin/sh
set -e
MOVIES_DB_PATH=/var/lib/movies-demo
MOVIES_USER=movies-demo
if [ "$1" = "configure" ]; then
# creating user and group
adduser --quiet \
--system \
--home /nonexistent \
--no-create-home \
--disabled-password \
--group "$MOVIES_USER"
# creating database folder
if [ ! -d $MOVIES_DB_PATH ]; then
mkdir -p $MOVIES_DB_PATH
chown $MOVIES_USER:$MOVIES_USER $MOVIES_DB_PATH
fi
exit 0
fi
Da mesma maneira que temos o postinst
, também temos um hook para quando removemos o pacote.
postrm (post-remove-sh)
: é executado quando removemos um pacote, e removerá a pasta apenas quando o arquivo de catalog.db
não existir.
#!/bin/sh
set -e
MOVIES_DB_PATH=/var/lib/movies-demo
if [ "$1" = "remove" ]; then
if [ -f "$MOVIES_DB_PATH/catalog.db" ]; then
echo "Database file found and won't be removed." >&2
else
echo "Removing database folder." >&2
rm -fr $MOVIES_DB_PATH
fi
exit 0
fi
Scripts prontos, agora é só fazer a inclusão deles no .goreleaser.yaml
e quando empacotar e instalar a aplicação novamente, a pasta será criada:
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -46,6 +46,9 @@ nfpms:
license: MIT
formats:
- deb
+ scripts:
+ postinstall: "env/debian/post-install.sh"
+ postremove: "env/debian/post-remove.sh"
4. Rodando a aplicação em background como um serviço (systemd)
A pasta já está pronta, mas a execução pela linha de comando continua sendo manual, e para resolver essa questão, vamos executar a aplicação em background como um serviço. Para isso tiraremos proveito do systemd, que já vem instalado no Ubuntu Linux.
Em poucas palavras, o systemd
é um conjunto de blocos de construção para uma sistema Linux, ele fornece um gerenciador de sistema e serviço, e é justamente do segundo ponto que necessitamos.
Para um serviço é requerido que seja criado um arquivo onde contém as referências para um recurso que o sistema saberá como operar e gerenciar, chamado de unit
.
O unit que usaremos é o service
, que descreve como gerenciar um serviço ou aplicação no servidor:
movies.service
:
[Unit]
Description=Manages movie collection through API
Documentation="https://github.com/faabiosr/go-movies-demo"
[Service]
EnvironmentFile=/etc/default/movies
ExecStart=/usr/bin/movies
Restart=on-failure
User=movies-demo
Group=movies-demo
KillSignal=SIGINT
[Install]
WantedBy=multi-user.target
Na seção Service
:
- EnvironmentFile: arquivo que contém as variáveis de ambiente.
- ExecStart: caminho do binário.
- User/Group: usaremos o mesmo criado anteriormente.
Precisaremos agora criar um arquivo que contém a variável de ambiente usada pela aplicação:
movies.conf
:
MOVIES_DB_PATH="/var/lib/movies-demo"
Uma vez finalizada a criação dos arquivos necessários para o systemd, é imprescindível atualizar os hooks do debian para que o mesmo seja ativado e incializado após a instalação:
postint (post-install.sh)
: ativa o serviço se não foi ativo, e inicia ou reinicia caso já esteja rodando.
diff --git a/env/debian/post-install.sh b/env/debian/post-install.sh
--- a/env/debian/post-install.sh
+++ b/env/debian/post-install.sh
@@ -4,6 +4,7 @@ set -e
MOVIES_DB_PATH=/var/lib/movies-demo
MOVIES_USER=movies-demo
+MOVIES_SERVICE=movies.service
if [ "$1" = "configure" ]; then
# creating user and group
@@ -20,5 +21,25 @@ if [ "$1" = "configure" ]; then
chown $MOVIES_USER:$MOVIES_USER $MOVIES_DB_PATH
fi
- exit 0
+ # enable systemd service
+ deb-systemd-helper unmask $MOVIES_SERVICE >/dev/null || true
+
+ if deb-systemd-helper --quiet was-enabled $MOVIES_SERVICE; then
+ deb-systemd-helper enable $MOVIES_SERVICE >/dev/null || true
+ else
+ deb-systemd-helper update-state $MOVIES_SERVICE >/dev/null || true
+ fi
+
+ # starting service
+ if [ -d /run/systemd/system ]; then
+ systemctl --system daemon-reload >/dev/null || true
+
+ if [ -n "$2" ]; then
+ _dh_action=restart
+ else
+ _dh_action=start
+ fi
+
+ deb-systemd-invoke $_dh_action $MOVIES_SERVICE >/dev/null || true
+ fi
fi
postrm (post-remove.sh)
: a nova adição irá reiniciar o serviço do próprio systemd, e o serviço só será removido caso o usuário opte por uma remoção completa.
diff --git a/env/debian/post-remove.sh b/env/debian/post-remove.sh
--- a/env/debian/post-remove.sh
+++ b/env/debian/post-remove.sh
@@ -3,6 +3,7 @@
set -e
MOVIES_DB_PATH=/var/lib/movies-demo
+MOVIES_SERVICE=movies.service
if [ "$1" = "remove" ]; then
if [ -f "$MOVIES_DB_PATH/catalog.db" ]; then
@@ -12,5 +13,16 @@ if [ "$1" = "remove" ]; then
rm -fr $MOVIES_DB_PATH
fi
- exit 0
+ # disabling service
+ if [ -d /run/systemd/system ]; then
+ systemctl --system daemon-reload >/dev/null || true
+ fi
+
+ deb-systemd-helper mask $MOVIES_SERVICE >/dev/null || true
+fi
+
+if [ "$1" = "purge" ]; then
+ # disabling service
+ deb-systemd-helper purge $MOVIES_SERVICE >/dev/null || true
+ deb-systemd-helper unmask $MOVIES_SERVICE >/dev/null || true
fi
Adicionalmente criamos o prerm
hook, que é executado antes de remover o pacote, e será ele responsável por finalizar a execução da aplicação, assim removemos o pacote de forma segura:
prerm (pre-remove.sh)
:
#!/bin/sh
set -e
MOVIES_SERVICE=movies.service
# stopping service
if [ -d /run/systemd/system ]; then
deb-systemd-invoke stop $MOVIES_SERVICE >/dev/null || true
fi
Agora é só incluir todos os arquivos no pacote Debian atualizado o .goreleaser.yaml
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -46,7 +46,14 @@ nfpms:
license: MIT
formats:
- deb
+ contents:
+ - src: "env/debian/movies.service"
+ dst: "/lib/systemd/system/movies.service"
+ - src: "env/debian/movies.conf"
+ dst: "/etc/default/movies"
+ type: config
scripts:
+ preremove: "env/debian/pre-remove.sh"
postinstall: "env/debian/post-install.sh"
postremove: "env/debian/post-remove.sh"
Quando o pacote for instalado no sistema operacional, automaticamente será copiado os arquivos da seção contents
para os respectivos destinos, fará o registro no systemd e iniciará automaticamente.
Poderia dizer que o empacotamento e a distribuição do aplicativo está finalizado, mas ainda temos um último problema para ser resolvido, vejamos na última parte.
5. Atualização e tempo de inatividade
Instalar ou atualizar ficou extremamente simples e com um controle mais rígido, todavia, a aplicação pode ficar inativa durante a atualização. Isso ocorre pelo simples fato de finalizar o serviço e instalar uma nova versão, e tendo uma degradação da disponibilidade.
Felizmente, isso pode ser contornado ainda usando o systemd, através de um outro unit, o socket
. Esse arquivo de unit codifica a informação sobre um soquete de rede ou arquivo, controlado e supervisionado, para uma ativação baseada em sockets.
Vale lembrar que os unit sockets
não iniciam os serviços por conta própria, em vez disso, eles apenas esperam e escutam um endereço IP:PORT
, ou um Unix
socket, e quando algo se conecta a ele, o serviço ao qual o socket se destina será iniciado e a conexão é entregue a ele. Já que nossa aplicação lida com requisições HTTP, podemos usá-lo.
Alguns passos adicionais precisarão ser concluídos, como a criação e modificação dos units, alteração dos hooks, e uma mudança na aplicação, pois ela precisa suportar essa funcionalidade.
movies.socket
:
[Unit]
Description=Manages movie collection through API
Documentation="https://github.com/faabiosr/go-movies-demo"
[Socket]
ListenStream=8000
SocketUser=movies-demo
SocketGroup=movies-demo
[Install]
WantedBy=sockets.target
Aliás, é necessário atualizar o movies.service
e informar que o unit socket é requerido:
diff --git a/env/debian/movies.service b/env/debian/movies.service
--- a/env/debian/movies.service
+++ b/env/debian/movies.service
@@ -1,6 +1,8 @@
[Unit]
Description=Manages movie collection through API
Documentation="https://github.com/faabiosr/go-movies-demo"
+After=network.target
+Requires=movies.socket
[Service]
EnvironmentFile=/etc/default/movies
Alteramos também os hooks do Debian:
postinst (post-install.sh)
: também fará o registro do unit socket e só reiniciará o serviço caso houver uma atualização no pacote.
diff --git a/env/debian/post-install.sh b/env/debian/post-install.sh
--- a/env/debian/post-install.sh
+++ b/env/debian/post-install.sh
@@ -5,6 +5,7 @@ set -e
MOVIES_DB_PATH=/var/lib/movies-demo
MOVIES_USER=movies-demo
MOVIES_SERVICE=movies.service
+MOVIES_SOCKET=movies.socket
if [ "$1" = "configure" ]; then
# creating user and group
@@ -30,16 +31,24 @@ if [ "$1" = "configure" ]; then
deb-systemd-helper update-state $MOVIES_SERVICE >/dev/null || true
fi
+ # enable systemd socket
+ deb-systemd-helper unmask $MOVIES_SOCKET >/dev/null || true
+
+ if deb-systemd-helper --quiet was-enabled $MOVIES_SOCKET; then
+ deb-systemd-helper enable $MOVIES_SOCKET >/dev/null || true
+ else
+ deb-systemd-helper update-state $MOVIES_SOCKET >/dev/null || true
+ fi
+
# starting service
if [ -d /run/systemd/system ]; then
systemctl --system daemon-reload >/dev/null || true
if [ -n "$2" ]; then
- _dh_action=restart
+ deb-systemd-invoke restart $MOVIES_SERVICE >/dev/null || true
else
- _dh_action=start
+ deb-systemd-invoke start $MOVIES_SOCKET >/dev/null || true
fi
- deb-systemd-invoke $_dh_action $MOVIES_SERVICE >/dev/null || true
fi
fi
prerm (pre-remove.sh)
: quando seja feita uma atualização, apenas o serviço será desligado, finalizará o socket, e o serviço apenas na remoção.
diff --git a/env/debian/pre-remove.sh b/env/debian/pre-remove.sh
--- a/env/debian/pre-remove.sh
+++ b/env/debian/pre-remove.sh
@@ -3,8 +3,18 @@
set -e
MOVIES_SERVICE=movies.service
+MOVIES_SOCKET=movies.socket
-# stopping service
-if [ -d /run/systemd/system ]; then
- deb-systemd-invoke stop $MOVIES_SERVICE >/dev/null || true
+if [ "$1" = "remove" ]; then
+ # stopping service and socket
+ if [ -d /run/systemd/system ]; then
+ deb-systemd-invoke stop $MOVIES_SERVICE $MOVIES_SOCKET >/dev/null || true
+ fi
+fi
+
+if [ "$1" = "upgrade" ]; then
+ # stopping service
+ if [ -d /run/systemd/system ]; then
+ deb-systemd-invoke stop $MOVIES_SERVICE >/dev/null || true
+ fi
fi
No arquivo .goreleaser.yaml
, foi incluído o arquivo .socket
, na seção contents:
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -49,6 +49,8 @@ nfpms:
contents:
- src: "env/debian/movies.service"
dst: "/lib/systemd/system/movies.service"
+ - src: "env/debian/movies.socket"
+ dst: "/lib/systemd/system/movies.socket"
- src: "env/debian/movies.conf"
dst: "/etc/default/movies"
type: config
Independente de configurar o systemd, a aplicação ainda não está preparada para fazer uso de sockets, para isso vamos adicionar o suporte de ativação de sockets.
A equipe do CoreOS desenvolveu o pacote go-systemd, nele contém várias ferramentas para integrar com o systemd, entre eles o activation
.
main.go
: inclusão do activation
e integração com o servidor http.
diff --git a/cmd/movies/main.go b/cmd/movies/main.go
--- a/cmd/movies/main.go
+++ b/cmd/movies/main.go
@@ -7,6 +7,7 @@ import (
"path/filepath"
"time"
+ "github.com/coreos/go-systemd/activation"
"github.com/labstack/echo/v4"
bolt "go.etcd.io/bbolt"
@@ -46,7 +47,7 @@ func main() {
e.Logger.Infof("%s service", appName)
go func() {
- if err := e.Start(appAddr); err != nil {
+ if err := start(e, appAddr); err != nil {
e.Logger.Info("shutting down the service")
}
}()
@@ -64,3 +65,16 @@ func main() {
e.Logger.Fatal(err)
}
}
+
+func start(e *echo.Echo, host string) error {
+ listeners, err := activation.Listeners()
+ if err != nil {
+ return nil
+ }
+
+ if len(listeners) > 0 {
+ e.Listener = listeners[0]
+ }
+
+ return e.Start(host)
+}
Com essa parte final, a aplicação terá garantias de disponibilidade durante uma reinicialização ou atualização, e estará completamente funcional para distribuir.
Conclusão
Agora nós sabemos como empacotar e distribuir o aplicativo, seguindo um modelo onde podemos versionar, preparar as dependências, e garantir disponibilidade.
Acredito que os pontos compartilhados, não tem um curva de dificuldade alta, mas sim, pontos estratégicos para futura manutenção do aplicativo, tal qual, reduzir a complexidade na hora de distribuir a aplicação, e o ponto central é tirar proveito das ferramentas que estão disponíveis no sistema operacional que será rodado.
A aplicação completa usada no artigo está em https://github.com/faabiosr/go-movies-demo, você encontrará tudo lá.
Como prometido, aqui vai um link de créditos para brincar na Digital Ocean e criar os seus droplets.
Recomendo a leitura das referências abaixo para entender um pouco mais sobre o systemd, e os hooks do Debian: