Distribuindo uma aplicação Go sem o Docker
Tags: go, docker, linux, braziliandevs
Published: 2023-10-27 17:55:47


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_):

```go
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`:

```go
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`:
```sh
go build -o movies ./cmd/movies
```

Com o binário em mãos, já podemos copiar e executar no servidor.
```sh
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](https://github.com/goreleaser/goreleaser), essa ferramenta maravilhosa, que internamente faz uso da é [nFPM](https://github.com/goreleaser/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](https://github.com/faabiosr/go-movies-demo/blob/v0.0.2/.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`:

```yaml
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](https://github.com/faabiosr/go-movies-demo/blob/main/.github/workflows/release.yml) 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6j94078vx93hkxjru026.png)

_Todas as versões criadas estarão em [releases](https://github.com/faabiosr/go-movies-demo/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_).
```sh
#!/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.
```sh
#!/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
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](https://systemd.io/), 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`:
```ini
[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`:
```env
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
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
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)`:
```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
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`:
```ini
[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
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
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
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
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](https://github.com/coreos) desenvolveu o pacote [go-systemd](https://github.com/coreos/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
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](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](https://m.do.co/c/67aea006f19f) 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:

- [Understanding Systemd Units and Unit Files](https://www.digitalocean.com/community/tutorials/understanding-systemd-units-and-unit-files)
- [Systemd system and service manager](https://www.freedesktop.org/software/systemd/man/latest/systemd.html#)
- [deb-prerm](https://manpages.debian.org/bullseye/dpkg-dev/deb-prerm.5.en.html), [deb-postinst](https://manpages.debian.org/bullseye/dpkg-dev/deb-postinst.5.en.html), [deb-postrm](https://manpages.debian.org/bullseye/dpkg-dev/deb-postrm.5.en.html)