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:

contém um printscreen da lista de arquivos gerados pelo Goreleaser

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: