En este post, vamos a ver como implantar Terraform para poder desplegar toda la infraestructura controlada por GitOps automáticamente añadiendo tanto controles de seguridad como de costes.
Para esta PoC, vamos a usar AWS como cloud y Gitlab como repositorio git.
Antes de empezar, vamos a revisar los basics. Por ejemplo como gestionar la parte del estado. terraform.tfstate
es el fichero de estado de Terraform. Esto es crítico y sensible ya que aquí se define el estado real de la infraestructura, incluidas las contraseñas o tokens utilizados, por lo que hay que tener especial cuidado sobre todo cuando varias personas o equipos van a desplegar, ya que este fichero no debe de subirse a ningún repositorio en plano por seguridad. Para esto, Terraform implementa los backends, que es una ubicación donde se guarda el fichero terraform.tfstate
y este puede ser accesible por toda la gente o servicios que vayan a desplegar. En nuestro caso, usarmos un bucket de S3 que debería de estar tanto cifrado como con el versionado activado para cumplir las buenas prácticas
Pero nos surge otro problema, y si 2 personas aplican al mismo tiempo? Se pisarán? Para esto también hay solución. Se debe de implementar un sistema de bloqueos que haga que la primera persona que acceda al terraform.tfstate
bloquee el fichero. ¿Como se hace esto? En AWS se hace con DynamoDB. Cada backend tiene su método de bloqueo.
Otra buena práctica, es dividir los ficheros de estado por entorno. No tener un único terraform.tfstate
si no tener varios, de este modo, el proceso de plan
es mucho mas rápido
De todos modos, una vez implantada la metodología GitOps este problema desaparecería ya que lo único que debería de poder desplegar debería de ser Atlantis.
El código quedaría de la siguiente manera
terraform {
backend "s3" {
bucket = "test-tfstate" # Nombre del Bucket S3
key = "prueba/terraform.tfstate" # Path donde se quiere guardar el terraform.tfstate
region = "eu-west-1" # Región donde se encuentra el bucket
dynamodb_table = "terraform_state" # La tabla de DynamoDB que vamos a usar para el lock
}
}
Una vez entendido esto, vamos manos a la obra con como empezar a usar GitOps. En este post no vamos a profundizar sobre que es GitOps, solo destacar que son una serie de practicas para gestionar las configuraciones en la que todas las iteracciones se realizan mediante git.
Atlantis
Atlantis es una herramienta que permite desplegar Terraform utilizando GitOps desde el propio repositorio del proyecto. El flujo sería el siguiente:
En mi local commiteo un cambio en una rama nueva –> hago un push hacia el repo–> Abro un Merge Request de mi rama contra la rama principal (master por ejemplo) y esto desencadenará un webhook hacia Atlantis que lanzará un terraform plan
. Una vez termine el plan, nos mostrará un comentario con los cambios que se van a aplicar –> escribimos en un comentario de la Merge Request atlantis apply
para aplicar los cambios. Y Atlantis se encargará de aplicar los cambios, mergear nuestra rama contra la principal y cerrar el Merge Request automáticamente.
En este flujo, es recomendable implantar las siguientes restricciones sobre el Merge Request y las Ramas:
- Que nadie pueda hacer un push directamente contra la rama principal a excepción de Atlantis.
- Que las Merque Request tengan que ser aprobadas por una persona diferente al autor.
- Que la persona que apruebe no tenga ningún commit en esa rama.
- Que el pipeline sea correcto.
En este post tampoco vamos a centrarnos en como instalar Atlantis. Esto es bastante fácil y está explicado en su web. Nos vamos a centrar en la parte de configuración. Para esto vamos a crear en el repositorio del proyecto de Terraform un fichero llamado atlantis.yaml
. Aquí estará todo el flujo y lógica que queramos que ejecute Atlantis cuando se cree la Merge Request
version: 3
automerge: true # Mergear automáticamente la rama
delete_source_branch_on_merge: true # Borrar la rama una vez mergeada
parallel_plan: true
parallel_apply: true
projects:
- name: terraform_test
dir: test
workspace: default
autoplan: # Que se lance el plan automáticamente
when_modified:
- "*.tf"
- "*.tfvars"
enabled: true
apply_requirements: [mergeable, approved] # Condiciones para aplicar
workflow: custom
workflows:
custom:
plan:
steps:
- init
- plan
apply:
steps:
- apply
La estructura de directorios sería la siguiente:
.
|
|--atlantis.yaml
|
|--test
|--main.tf
|--backend.tf
Tip: En la configuración del servicio de Atlantis, en el fichero repos.yaml
, añadid lo siguiente:
repos:
- id: /.*/
apply_requirements: []
workflow: default
allowed_overrides: [apply_requirements, workflow, delete_source_branch_on_merge]
allow_custom_workflows: true
workflows:
default:
plan:
steps: [init, plan]
apply:
steps: [apply]
Y si utilizáis módulos de terraform privados, hay que hacer el siguiente cambio para que pueda descargarselos con el token de gitlab en el fichero de gitconfig (esto es extrapolable al resto de repositorios git)
[url "https://gitlab-ci-token:GITLAB_TOKEN@gitlab.com"]
insteadOf = https://gitlab.com
[url "https://gitlab-ci-token:GITLAB_TOKEN@gitlab.com"]
insteadOf = ssh://git@gitlab.com
Para la autenticación de Terraform contra AWS, cada uno implementará la que mas le convenga. Mi recomendación es el utilizar roles. En el caso de desplegar Atlantis en un EKS, se puede generar un role IRSA asociado y en el role de Terraform-Role
en la sección relationship_principal_identifiers
permitir el Role de Atlantis. Esto le permitirá a Atlantis asumir el Role correspondiente de Terraform
provider "aws" {
region = var.region
assume_role {
role_arn = "arn:aws:iam::XXXXXXXXX:role/Terraform-Role"
session_name = "test-terraform"
}
}
Una vez con esto, Atlantis ya podrá desplegar el proyecto automáticamente.
Infracost
Ahora vamos con la otra parte del post que es Infracost. Esta herramienta lo que hace es revisar lo que se va a aplicar en el Merge Request y te da una estimación de costes. Evidentemente no puede calcular temas de cuota de networking, peticiones… Se limita al coste extra mensual fijo del servicio.
Dockerfile
Para utilizar Infracost con Atlantis, es necesario modificar el Dockerfile de Atlantis para que lo instale.
ARG ATLANTIS_VERSION=latest
FROM ghcr.io/runatlantis/atlantis:${ATLANTIS_VERSION}
ARG INFRACOST_VERSION
ENV INFRACOST_VERSION_SET=$INFRACOST_VERSION
RUN apk add --no-cache
\ ca-certificates
\ openssl
\ openssh-client
\ curl
\ git
\ jq
\ && rm -f /var/cache/apk/*
RUN curl -s -L https://github.com/infracost/infracost/releases/download/$INFRACOST_VERSION_SET/infracost-linux-amd64.tar.gz | tar xz -C /tmp &&
\ mv /tmp/infracost-linux-amd64 /usr/bin/infracost
Con esto, habrá que pasarle 2 --build-arg
a docker a la hora de hacer el docker build
. El ATLANTIS_VERSION
con la versión que queramos de Atlantis y la de INFRACOST_VERSION
con la versión que queramos de Infracost.
APIKEY
Ahora tenemos que obtener el APIKEY de Infracost, esto lo que hace es conectarse a su servidor para obtener los precios de los recursos actualizados. Para esto hay que lanzar un comando. O bien se puede instalar la tool en local o bien se puede levantar la imagen que hemos creado un con docker run
y ejecutar esto. Esto nos devolverá un APIKEY que usaremos mas tarde.
infracost register
Atlantis config
Una vez tengamos instalado Infracost en la imagen de Atlantis, hay que volver a editar el fichero de atlanis.yaml
y añadir la parte correspondiente de Infracost
version: 3
automerge: true
delete_source_branch_on_merge: true
projects:
- name: terraform_test
dir: test
workspace: default
autoplan:
when_modified:
- "*.tf"
- "*.tfvars"
enabled: true
apply_requirements: [mergeable, approved]
workflow: custom
workflows:
custom:
plan:
steps:
- env:
name: INFRACOST_API_KEY
command: "XXXXXXXXXXXXX" # Aquí va el APIKEY que hemos obtenido previamente.
- env:
name: INFRACOST_OUTPUT
command: 'echo "/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}-infracost.json"'
- env:
name: INFRACOST_COMMENT_TAG
command: 'echo "$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}"'
- env:
name: INFRACOST_CURRENCY
command: 'echo "EUR"'
- init
- plan
- show
- run: rm -rf /tmp/$BASE_REPO_OWNER
- run: mkdir -p /tmp/$BASE_REPO_OWNER
- run: infracost breakdown --path=$SHOWFILE --format=json --log-level=info --out-file=$INFRACOST_OUTPUT
- run: infracost comment gitlab --repo $BASE_REPO_OWNER/$BASE_REPO_NAME --merge-request $PULL_NUM --path $INFRACOST_OUTPUT --gitlab-token $ATLANTIS_GITLAB_TOKEN --tag $INFRACOST_COMMENT_TAG --behavior new
- run: rm -rf /tmp/$BASE_REPO_OWNER
apply:
steps:
- apply
Y con esto ya estaría. Ahora cada vez que ejecutemos un Merge Request, nos saldrá un comentario con el precio extra que nos costará lo que despleguemos.
Bonus Tip
Esto ha sido referente a la parte de GitOps, ahora vamos con la parte de Pipeline. Vamos hacer que antes de poder desplegar, compruebe que tanto el formato de terraform como la parte de seguridad y buenas practicas se cumplan.
Para la parte de formato, terraform implementa 2 comandos validate
que valida que la configuración esté bien sin consultar al backend y fmt
que comprueba que la estructura de la sintaxis sea correcta y para la parte de seguridad, vamos a usar Tfsec que verifica que se estén implementando las buenas prácticas.
En este caso estamos usando Gitlab CI, así que en el caso de usar otro CI habrá que adaptarlo. el Pipeline quedaría así:
#==============
# STAGES
#==============
stages:
- code_quality_security
# ========== CHECKS ==========
# ------ EXTEND ------
.ckecks:
stage: code_quality_security
image:
name: hashicorp/terraform:${TERRAFORM_VERSION}
entrypoint: [""]
before_script:
- apk add curl jq
- wget https://github.com/aquasecurity/tfsec/releases/download/${TFSEC_VERSION}/tfsec-linux-amd64
- chmod +x tfsec-linux-amd64
- git config --global url."https://gitlab-ci-token:${CI_JOB_TOKEN}@gitlab.com".insteadOf ssh://git@gitlab.com
- >
terraform
-chdir=${ENV} init
script:
- echo "================= VALIDATE ================="
- >
terraform
-chdir=${ENV}
validate
- echo "================= CHECK FORMAT ================="
- >
terraform
-chdir=${ENV}
fmt -check
- echo "================= TFSEC ================="
- >
./tfsec-linux-amd64
--tfvars-file ${ENV}/${ENV}.auto.tfvars
--config-file ${ENV}/tfsec.yaml
--exclude-downloaded-modules
${ENV}/
# ------ JOBS ------
ckecks_test:
extends:
- .ckecks
variables:
ENV: test
only:
changes:
- test/*
El Pipeline está preparado para que en el caso de que haya diferentes carpetas con diferentes entornos, no se ejecute en todos cada vez que se hace un Merge Request, si no solo cuando se realice algún cambio en alguno de sus ficheros.
También tendremos que crear un fichero tfsec.yaml
en el caso de que queramos excluir alguna de las reglas.
---
exclude:
- aws-ecr-repository-customer-key
- aws-vpc-no-public-egress-sg
Otra opción de hacer esto, es en el mismo código de Terraform con un comentario:
# tfsec:ignore:aws-iam-no-policy-wildcards
data "aws_iam_policy_document" "test_user" {
statement {
sid = "test_user"
effect = "Allow"
resources = ["*"]
actions = [
"ec2:Describe*",
"eks:DescribeCluster",
"eks:ListClusters"
]
}
}
En el caso de que alguna de las reglas fallé, nos aparecerá cuando se ejecute el pipeline
Y en los links que nos da, nos dice como solucionar los errores de una forma bastante clara
Con esto deberíamos de ser capaces de tener un flujo bastante solido de despliegue de infraestructura. Nos aseguraríamos que todos los cambios sean validados por compañeros (con las Merge Request), quedaría un histórico de los cambios y costes que se aplican en cada Merge Request (con Atlantis e Infracost) y nos aseguraríamos que cumplimos tanto las buenas practicas (con Tfsec) como el formato (con validate y fmt)
Vault Bonus Tip
Si encima utilizas Hashicorp Vault para gestionar secretos e identidades en terraform (bien hecho!), y además has desplegado Atlantis en Kubernetes, para obtener un VAULT_TOKEN tienes que generar un script en el repo del proyecto
#!/bin/bash
KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -s --request POST \
--data '{"jwt": "'"${KUBE_TOKEN}"'", "role": "atlantis"}\' \
${VAULT_ADDR}/v1/auth/kubernetes/login | jq -r '.auth.client_token'
Y en el fichero de atlantis.yaml
añadir la siguiente variable:
...
workflows:
vault-token:
plan:
steps:
- env:
name: VAULT_TOKEN
command: "../vault-token.sh"
apply:
steps:
- env:
name: VAULT_TOKEN
command: "../vault-token.sh"
...
Esto obtendrá el token usando el engine de kubernetes de vault.
Y si tienes guardado el APIKEY de infracost en un KV de Vault, también habría que generar otro script con lo siguiente:
#!/bin/bash
KUBE_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
VAULT_TOKEN=$(curl -s --request POST \
--data '{"jwt": "'"${KUBE_TOKEN}"'", "role": "atlantis"}\' \
${VAULT_ADDR}/v1/auth/kubernetes/login | jq -r '.auth.client_token')
curl -s -X GET -H "X-Vault-Token:${VAULT_TOKEN}" ${VAULT_ADDR}/v1/test/data/Infracost | jq -r ".data.data.API_KEY"
Y añadir en el atlantis.yaml
la nueva variable
...
workflows:
vault-token:
plan:
steps:
- env:
name: VAULT_TOKEN
command: "../vault-token.sh"
- env:
name: INFRACOST_API_KEY
command: "../infracost-apikey.sh"
...
Quedaría todo así:
workflows:
vault-token:
plan:
steps:
- env:
name: VAULT_TOKEN
command: "../vault-token.sh"
- env:
name: INFRACOST_API_KEY
command: "../infracost-apikey.sh"
- env:
name: INFRACOST_OUTPUT
command: 'echo "/tmp/$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}-infracost.json"'
- env:
name: INFRACOST_COMMENT_TAG
command: 'echo "$BASE_REPO_OWNER-$BASE_REPO_NAME-$PULL_NUM-$WORKSPACE-${REPO_REL_DIR//\//-}"'
- env:
name: INFRACOST_CURRENCY
command: 'echo "EUR"'
- init
- plan
- show
- run: rm -rf /tmp/$BASE_REPO_OWNER
- run: mkdir -p /tmp/$BASE_REPO_OWNER
- run: infracost breakdown --path=$SHOWFILE --format=json --log-level=info --out-file=$INFRACOST_OUTPUT
- run: infracost comment gitlab --repo $BASE_REPO_OWNER/$BASE_REPO_NAME --merge-request $PULL_NUM --path $INFRACOST_OUTPUT --gitlab-token $ATLANTIS_GITLAB_TOKEN --tag $INFRACOST_COMMENT_TAG --behavior new
- run: rm -rf /tmp/$BASE_REPO_OWNER
apply:
steps:
- env:
name: VAULT_TOKEN
command: "../vault-token.sh"
- apply
Si surge alguna duda, ponedlo en los comentarios para intentar aclararla y añadirlo al post.
Also published on Medium.