Pipeline IaC com Terraform e GitHub Actions

Pipeline IaC com Terraform e GitHub Actions

Introdução

Com a difusão da cultura DevOps nos últimos anos algumas práticas para entrega de novos produtos ou novas funcionalidades no ambiente produtivo mudaram bastante. Nesse artigo vou exemplificar em um pequeno laboratório um processo de pipeline CD/CD para entrega de uma infraestrutura utilizando Terraform, GitHub Actions e AWS como cloud pública.

Terraform 🌎

Terraform é uma ferramenta de infraestrutura como código (IaC) que permite criarmos recursos em clouds públicas ou privadas utilizando uma linguagem simples e declarativa, podendo assim reutilizar e versionar o código da sua infraestrutura assim como é feito com a aplicação. Você pode então usar um workflow consistente para provisionar e gerenciar toda a sua infraestrutura ao longo de seu ciclo de vida.

GitHub Actions 🤖

GitHub Actions é uma plataforma de integração contínua e entrega contínua (CI/CD) que permite automatizar sua compilação de código, testar e entregar de forma simples e rápida.

Mão na massa! 💻

A ideia do laboratório é provisionar uma instância EC2 com ip público executando um webserver nginx rodando o famoso jogo da cobrinha 🐍.

Preparando o GitHub para acessar a AWS com o Terraform

A primeira etapa que precisa ser feita é configura as secrets AWS_ACCESS_KEY_ID e AWS_SECRET_ACCESS_KEY para que o GitHub Actions consiga acessar esses valores durante a execução do workflow e ter acesso a sua conta da AWS.

Dentro do repositório vá em: Settings -> Secrets -> Actions e adicionei as chaves de acesso da AWS.

pipeline-step-1

Criando os workflows do GitHub Actions

Para termos as actions do GitHub rodando de acordo com cada evento precisamos criar o diretório .github/workflows na raiz do projeto. Neste caso vamos criar 3 arquivos de workflow, um para o executar o terraform plan quando for criado um pull request, outro para executar o terraform apply quando o merge for feito na branch main e o último e não menos importante para executar o terraform destroy, esse será executado manualmente quando for necessário destruir a infraestrutura.

Eu utilizei como base para a criação dos workflows a action oficial da HashiCorp - Setup Terraform

💡Dica: No site HashiCorp Learn temos um tutorial de como criar uma pipeline com GitHub Actions utilizando Terraform Cloud.

pipeline-step-2

plan.yml

name: "Plan"

on:
  pull_request:

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
            <details><summary>Show Plan</summary>
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
            </details>
            *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

apply.yml

name: "Apply"

on:
  push:
    branches:
      - main

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  terraform:
    name: "Terraform"
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -input=false
        continue-on-error: true

      - uses: actions/github-script@v6
        if: github.event_name == 'pull_request'
        env:
          PLAN: "terraform\n${{ steps.plan.outputs.stdout }}"
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
            #### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
            #### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
            #### Terraform Plan 📖\`${{ steps.plan.outcome }}\`
            <details><summary>Show Plan</summary>
            \`\`\`\n
            ${process.env.PLAN}
            \`\`\`
            </details>
            *Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })
      - name: Terraform Plan Status
        if: steps.plan.outcome == 'failure'
        run: exit 1

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve -input=false

destroy.yml

name: "Destroy"

on:
  workflow_dispatch:

env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

jobs:
  destroy:
    name: "terraform destroy"
    runs-on: ubuntu-latest    
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v1
        with:
          terraform_version: 0.15.5

      - name: Terraform Init
        id: init
        run: terraform init

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Destroy
        run: terraform destroy -auto-approve

Código da infra no Terraform

Não vou me aprofundar muito nos detalhes do código do Terraform, isso pode ficar para outro artigo. Basicamente temos a criação de 3 recursos dentro da AWS são eles: VPC, Security Group e a Instância EC2.

Para a criação da VPC já com a subnet pública, zonas de disponibilidades (AZs) e NAT Gateway estou utilizando o módulo vpc. Abaixo o trecho de código:

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "snake-vpc"
  cidr = "10.200.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.200.101.0/24", "10.200.102.0/24"]

  enable_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = "prod"
  }
}

Para a criação do Security Group estou utilizando o resource aws_security_group, onde configuramos a liberação geral na porta 80, padrão HTTP. Abaixo o trecho de código:

resource "aws_security_group" "game_snake_sg" {
  name        = "instances-snake-sg"
  description = "SG for Instances Snake Security Group"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Game Snake port 80"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Terraform = "true"
    Environment = "prod"
  }
}

Para finalizar criamos a instância EC2 utilizando o módulo ec2_instance, o servidor será provisionado com a imagem Amazon Linux 2, tipo t1.micro, anexamos o Security Group criando anteriormente, anexamos também a instância dentro da subnet pública que foi criada junto com a VPC e por fim passamos um arquivo no userdata, que basicamente é um shell script que será executando quando a instância for iniciada.

module "ec2_instance" {
  source = "terraform-aws-modules/ec2-instance/aws"

  name                   = "snake-game"
  ami                    = "ami-0cff7528ff583bf9a"
  instance_type          = "t1.micro"
  vpc_security_group_ids = [aws_security_group.game_snake_sg.id]
  subnet_id              = module.vpc.public_subnets[0]
  user_data              = file("userdata.sh")
  tags = {
    Name = "snake-game-ec2"
    Terraform = "true"
    Environment = "prod"
    Team = "gamer-development"
    Application = "snake-game"
    Language = "javascript"
  }
}

Desta forma o arquivo do terraform finalizado fica da seguinte maneira:

main.tf

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "snake-vpc"
  cidr = "10.200.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  public_subnets  = ["10.200.101.0/24", "10.200.102.0/24"]

  enable_nat_gateway = true

  tags = {
    Terraform   = "true"
    Environment = "prod"
  }
}

resource "aws_security_group" "game_snake_sg" {
  name        = "instances-snake-sg"
  description = "SG for Instances Snake Security Group"
  vpc_id      = module.vpc.vpc_id

  ingress {
    description = "Game Snake port 80"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port        = 0
    to_port          = 0
    protocol         = "-1"
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }

  tags = {
    Terraform = "true"
    Environment = "prod"
  }
}

module "ec2_instance" {
  source = "terraform-aws-modules/ec2-instance/aws"

  name                   = "snake-game"
  ami                    = "ami-0cff7528ff583bf9a"
  instance_type          = "t1.micro"
  vpc_security_group_ids = [aws_security_group.game_snake_sg.id]
  subnet_id              = module.vpc.public_subnets[0]
  user_data              = file("userdata.sh")
  tags = {
    Name = "snake-game-ec2"
    Terraform = "true"
    Environment = "prod"
    Team = "gamer-development"
    Application = "snake-game"
    Language = "javascript"
  }
}

Enviando um Pull Request, fazendo o Merge e executando o deploy da infraestrutura 🚀

Vamos criar uma nova branch no projeto chamada deployment fazer uma alteração no arquivo README.md e criar um Pull Request. Assim que Pull Request for criado o workflow plan.yml será disparado e poderemos verificar o que o Terraform irá provisionar na AWS.

Pull Request

pipeline-step-3

Veja que após a execução do workflow plan.yml dentro dos comentários da Pull Request o GitHub Actions informa o resultado na execução, incrível né?! 🤩

pipeline-step-4

Merge

Agora vamos aprovar o Merge da Pull Request para a branch main. Com essa estratégia é possível revisar o que exatamente o Terraform irá aplicar antes ser executado.

pipeline-step-5

Após a execução podemos verificar no workflow apply.yml que tudo foi executado com sucesso.

pipeline-step-6

Validando a infraestrutura na AWS

Verificando a conta AWS vemos que toda a infraestrutura foi provisionada com sucesso e a aplicação do jogo da cobrinha 🐍 está online.

pipeline-step-7

Executando o Destroy 🗑

Caso seja necessário deletar toda a infraestrutura que foi provisionada devemos executar o workflow destroy.yml manualmente.

pipeline-step-8

Após a execução todos recursos na AWS foram deletados.

pipeline-step-9

Conclusão

Utilizando pipelines CD/CD é possível entregar ambientes completos de forma automática e rápida. Se você notou criamos todos os recurso dentro da AWS sem precisar dar ao menos um clique dentro do painel administrativo. Use e abuse da infraestrutura como código (IaC). Espero que tenha gostado desse hands-on , até a próxima! ✌🏼