Pipeline de Deploy Automatizado com GitHub Actions e Docker para EC2
Published: 2023-05-01 19:58:08

O github actions é uma ferramenta que vem substituindo várias outras do mercado, como Jenkins e Circle CI, pela sua facilidade de utilização, já que não existe a necessidade de toda uma infraestrutura direcionada para a execução de pipelines, sem contar o fato de que muitas empresas ja utilizam o github como repositorio oficial de seus projetos.

Existem diversos propositos na criação de um pipeline, dentre eles o deploy automatizado, que ao inserir um novo commit no repositório ele executa, tornando assim o _continuos deploy_ uma realidade.

Então chega de papo, e vamos entender como fazer isso na prática.

Irei usar uma aplicação escrita em nodejs com apenas um Hello world na rota [**GET /**].

Primeiramente precisamos de uma instancia EC2 linux com o docker instalado. Além disso, é necessário que tenha sido guardado o key-pair usado para proteger a instância.

Agora precisamos de um repositório com nosso código da aplicação no github. usarei o seguinte repo como base do nosso guia (https://github.com/fabiomaciel/actions-deploy).

Como dito anteriormente, usaremos uma aplicação rest simples em nodejs.

```javascript
const express = require('express');
const app = express();

const PORT = 80
app.get('/', (req, res) => {
  res.send('Hello!');
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
```

O build da nossa aplicação será feito utilizando Docker, portanto criaremos um **Dockerfile** simples e pratico, que basicamente realizará a instalação das dependencias do nosso projeto ( que no nosso caso é somente o express)  e iniciará o servidor http na porta 80.

```Docker
FROM node:18-alpine

WORKDIR ~/hello-world
COPY . .
RUN npm ci
EXPOSE 80
CMD ["npm", "start"]
```

Com isso ja podemos fazer o build da nossa aplicação e fazer o deploy manualmente utilizando ssh por exemplo, mas queremos aqui automatizar esse processo, e o github actions vai nos ajudar com isso.

Existem alguns cuidados que devemos tomar ao configurar builds e deploys em repositórios, principalmente quando estes são públicos (como no nosso caso). Todo tipo de dado sensivel, como passwords, e dados de acesso a ambientes devem ser guardados fora do alcance público, onde apenas pessoas autoriazadas tem acesso. Precisaremos aqui de todos os dados de acesso ao servidor EC2 para podermos fazer o deploy da aplicação na nossa máquina. E para guardarmos com segurança nossos dados, utilizaremos o secrets do proprio github.

Na aba settings temos a seção "Secrets and variables", que possui uma lista de paginas, e usaremos a "Actions".


![screenshot da seção no github](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/d4ip08kjegxmaxnmj0fi.png)

Nessa seção usaremos 4 secrets:

**_PUBLIC_KEY_**: necessário para adicionar ao known hosts as informações do servidor.

_**DEPLOY_KEY**_: chave de acesso ao servidor, é uma chave privada gerada na hora da criação do ec2 para o acesso ssh.

**_HOST_**: ip público da máquina hospedada no aws ec2

_**USER**_: usuário usado na máquina ec2.


Agora vamos setar cada um dos secrets:
Para conseguir o **PUBLIC_KEY** precisamos acessar a máquina ec2 e rodar o seguinte comando: 

```bash
ssh-keyscan -t ecdsa [ip-públic]
```
**onde [ip-público] deverá ser substituído pelo ip da maquina**

Ao rodar esse commando, copie a saída e cole no secret do github.

Para conseguir o DEPLOY_KEY basta apenas copiar o contúdo de dentro do arquivo key-pair gerado na hora da criação da maquina ec2 no console da aws. 
Esse arquivo segue um padrão parecido com o seguinte:

```
-----BEGIN RSA PRIVATE KEY-----
Sed ut perspiciatis unde omnis iste natus error sit voluptatem
 accusantium doloremque laudantium totam rem aperiam eaque 
ipsa quae ab illo inventore veritatis et quasi architecto 
beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem
 quia voluptas sit aspernatur aut odit aut fugit sed quia 
consequuntur magni dolores eos qui ratione voluptatem sequi 
nesciunt. Neque porro quisquam est qui dolorem ipsum quia 
dolor sit amet consectetur adipisci velit sed quia non numquam
 eius modi tempora incidunt ut labore et dolore magnam aliquam
 quaerat voluptatem. Ut enim ad minima veniam quis nostrum 
exercitationem ullam corporis suscipit laboriosam nisi ut
 aliquid ex ea commodi consequatur Quis autem vel eum iure 
reprehenderit qui in ea voluptate velit esse quam nihil 
molestiae consequatur vel illum qui dolorem eum fugiat quo 
voluptas nulla pari
-----END RSA PRIVATE KEY-----
```

HOST e USER podemos pegar tanto pelo console da aws, por padrão o usuário de acesso ao ec2 é o "ec2-user", mas isso é configurável, e o host é o ip publico da sua máquina.

Agora que temos todas as informações que precisamos, vamos a acão.

Criaremos um arquivo com todas as informações do deploy no repositório .github/workflows/deploy.yml, e o conteúdo do arquivo é o seguinte:

```yml
name: Docker EC2 Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Add EC2 instance to known_hosts
        env:
          PUBLIC_KEY: $\{{ secrets.PUBLIC_KEY }}
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          echo $PUBLIC_KEY >> ~/.ssh/known_hosts

      - name: Setting private key
        env:
          PRIVATE_KEY: $\{{ secrets.DEPLOY_KEY }}
        run: |
          echo "$PRIVATE_KEY" > /tmp/private_key.pem
          chmod 600 /tmp/private_key.pem

      - name: Copy repository files to EC2
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |
          ssh -i /tmp/private_key.pem $USER@$HOST "mkdir -p ~/app"
          scp -i /tmp/private_key.pem -r ./* $USER@$HOST:~/app

      - name: Build Docker image and run container on EC2
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |        
          ssh -i /tmp/private_key.pem $USER@$HOST "\
          cd ~/app \
          && docker build -t hello . \
          && docker run -d -p 80:80 --name hello-container hello"

      - name: Cleanup
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |
          ssh -i /tmp/private_key.pem $USER@$HOST "rm -rf ~/app"
          rm -f /tmp/private_key.pem


```

A estrutura do arquivo segue alguns padrões que iremos analizar.

A estrtutura principal esta dividida em 3 partes: **name**, **on**, **jobs**

**name**: é simplesmente um identificador da pipeline

**jobs**: possui os eventos nos quais essa pipeline vai ser conectada, como no nosso exemplo um **push** na branch _main_, mas podemos ter variados eventos, como abertura de pr,abertura de uma issue, dentre outras coias. 
(_para mais informações acesse a documentação oficial https://docs.github.com/en/actions/using-workflows/triggering-a-workflow_)

**jobs**: aqui é onde a execução da lógica da nossa pipeline vai rodar de fato.

A seção **jobs** é onde fazemos a magia acontecer, aqui nós separamos nossa execução da pipeline em _steps_, onde podemos nomear os steps, e ter uma melhor vizualização na tela de acompanhamento da pipeline, ajudando a encontrar possíveis erros com mais facilidade. Além disso precisamos definir qual será o ambiente onde nossa pipeline será executada, nesse exemplo usaremos um ambiente _linux_ com a distro _ubunutu_.

Agora vamos para os steps. Como dito anteriormente, é sempre bom separar de forma a facilitar um possivel troubleshooting, tendo isso em mente vamos criar alguns steps, e usaremos nomes que referenciam exatamente oque está sendo feito.

- Add EC2 instance to known_hosts
- Setting private key
- Copy repository files to EC2
- Build Docker image and run container on EC2
- Cleanup

Antes de mergulharmos dentro de cada step, vale notar que aqui é onde usamos nossos _secrets_ que configuramos anteriormente, em cada step que iremos usar os secrets, o configuramos como uma variavel de ambiente dentro da estrutura _env_.
Aqui dizemos que a variavel de ambiente PUBLIC_KEY tem o valor configurado no secrete **PUBLIC_KEY**, e faremos a mesma coisa com os outros secrets em cada step quando necessário.

**Add EC2 instance to known_hosts**:

```Bash
mkdir -p ~/.ssh
chmod 700 ~/.ssh
echo $PUBLIC_KEY >> ~/.ssh/known_hosts
```
Aqui configuramos o nosso **known_hosts** com as informacões da nossa maquina ec2, para que possamos acessá-la usando ssh.

**Setting private key**:

```Bash
echo "$PRIVATE_KEY" > /tmp/private_key.pem
chmod 600 /tmp/private_key.pem
```
Aqui criaremos um arquivo **.pem** e no conteúdo desse arquivo colocamos o conteúdo que pegamos do arquivo key-pair gerado na criação da instancia do ec2, e configuramos as permissões READ e WRITE para o usuário dono do arquivo, e nenhuma permissão para o grupo e outros usuários. (https://www.computerhope.com/unix/uchmod.htm)


**Copy repository files to EC2**:

```Bash
ssh -i /tmp/private_key.pem $USER@$HOST "mkdir -p ~/app"
scp -i /tmp/private_key.pem -r ./* $USER@$HOST:~/app
```

Aqui criamos um diretório app na home do usuário, e copiamos todos os arquivos do repositório para o ec2.

**Build Docker image and run container on EC2**:

```Bash
ssh -i /tmp/private_key.pem $USER@$HOST "\
cd ~/app \
&& docker build -t hello . \
&& docker run -d --rm -p 80:80 --name hello-container hello"
```

Aqui conectamos via ssh na máquina ec2, criamos uma imagem docker da aplicação e em seguida executamos o docker run para subir o container fazendo bind da porta 80 do container com a porta 80 do host.

**Cleanup**:

```Bash
ssh -i /tmp/private_key.pem $USER@$HOST "rm -rf ~/app"
rm -f /tmp/private_key.pem
```

Por fim fazemos uma limpeza nos arquivos do repo dentro do host ec2, e limpamos o nosso arquivo com as credenciais de acesso ssh.

Pronto, nosso pipeline de deploy automatizado está funcionando, mas ainda temos alguns problemas para resolver.
estamos apenas criando novas imagens docker, e nunca deletando as antigas, hora ou outra iremos acabar estourando o armazenamento do nosso host ec2, apesar de usarmos uma imagem bem enxuta "node:18-alpine", é sempre bom limparmos o "lixo", para evitar problemas no armazenamento ou até mesmo custos indevidos na nosa infra. Além disso, na segunda vez que rodarmos a nossa pipeline, a nossa aplicação falhará, pois ja existe um container rodando com o mesmo nome, e fazendo bind na mesma porta que gostariamos, então é necessário fazermos mais 2 coisas no nosso step de Build and Run:

- _Parar o container_
- _Deletar as imagens antigas_

Para isso vamos adicionar 2 comandos bash

```Bash
#deleta todas as imagens docker salvas na máquina
docker images -q | xargs -r docker rmi -f

# deleta todos os containers do host
docker ps -q | xargs -r docker container rm -f 
```


Vamos adicionar esses comandos no nosso arquivo deploy.yml


```Bash
name: Docker EC2 Deploy

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v2

      - name: Add EC2 instance to known_hosts
        env:
          PUBLIC_KEY: $\{{ secrets.PUBLIC_KEY }}
        run: |
          mkdir -p ~/.ssh
          chmod 700 ~/.ssh
          echo $PUBLIC_KEY >> ~/.ssh/known_hosts

      - name: Setting private key
        env:
          PRIVATE_KEY: $\{{ secrets.DEPLOY_KEY }}
        run: |
          echo "$PRIVATE_KEY" > /tmp/private_key.pem
          chmod 600 /tmp/private_key.pem

      - name: Copy repository files to EC2
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |
          ssh -i /tmp/private_key.pem $USER@$HOST "mkdir -p ~/app"
          scp -i /tmp/private_key.pem -r ./* $USER@$HOST:~/app

      - name: Build Docker image and run container on EC2
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |        
          ssh -i /tmp/private_key.pem $USER@$HOST "\
          cd ~/app \
          && (docker ps -q | xargs -r docker container rm -f ) \
          && (docker images -q | xargs -r docker rmi -f) \
          && docker build -t hello . \
          && docker run -d -p 80:80 --name hello-container hello"

      - name: Cleanup
        env:
          HOST: $\{{ secrets.HOST }}
          USER: $\{{ secrets.USER }}
        run: |
          ssh -i /tmp/private_key.pem $USER@$HOST "rm -rf ~/app"
          rm -f /tmp/private_key.pem

```

Pronto, agora a nossa pipeline de deploy está 100% funcional, e não teremos problemas de armazenamento e nem de custos indevidos no nosso servidor.

Em resumo, utilizamos o GitHub Actions para automatizar o processo de deploy de uma aplicação Node.js simples em um ambiente EC2 da AWS, utilizando Docker. Através da criação de um pipeline, conseguimos automatizar etapas como a conexão SSH, transferência de arquivos, construção e execução de imagens Docker. Além disso, foram abordadas práticas de segurança, como o uso de secrets para armazenar informações sensíveis, e estratégias para evitar problemas de armazenamento, como a remoção de contêineres e imagens antigas. Com essas ferramentas em mãos, desenvolvedores podem se concentrar no desenvolvimento de suas aplicações, sabendo que o processo de deploy está devidamente automatizado e otimizado..