DEV Community

Cover image for Despliega un servidor Nginx en AWS con Terraform
Leonel Gareis
Leonel Gareis

Posted on

Despliega un servidor Nginx en AWS con Terraform

Hoy vamos a aprender como podemos desplegar una instancia EC2 e instalar Nginx de forma rápida y sencilla con Terraform.

Desde el inicio: Que es Terraform? 🤔

Terraform es una herramienta que nos permite crear, modificar y versionar la infraestructura de nuestro proyecto de forma fácil a partir de manifiestos (archivos). A esto se le conoce como IaC (Infrastructure as Code)

Terraform no es la única herramienta para IaC, pero si me arriesgo a decir que es la más popular.

Terraform vs Manual ❗🟰

Características Terraform Configuración Manual
Sintaxis y Lenguaje HCL (HashiCorp Configuration Lang.) Interfaz gráfica o CLI proveedor
Declarativo vs. Imperativo Declarativo Imperativo
Gestión de Estado Sí (Archivo de Estado) No (Depende de documentación)
Escalabilidad Escala bien con infraestructuras Propenso a complejidad y errores
Control de Versiones Sí (Archivo de Configuración) No (Depende de documentación)
Gestión de Cambios Planificación antes de aplicar Riesgo de cambios no documentados
Reproducibilidad Alta Variable
Curva de Aprendizaje Moderada Baja (Interfaz gráfica)
Colaboración Facilita la colaboración en equipos Dificultad para compartir contextos
Flexibilidad y Abstracción Permite abstracciones complejas Limitado a la interfaz del proveedor
Automatización Totalmente automatizable Limitado a capacidades de proveedor

Que seria el "versionado"? 🤔

Al declarar toda la infraestructura en archivos, estos pueden estar versionados tranquilamente con Git.

Es conveniente hacer todo con Terraform? 🤔

Esto depende mucho de lo que estes haciendo. A veces, para proyectos muy chicos/personales es mas rápido utilizar la consola del proveedor.


Instalación 🖥️

Si no tenes Terraform instalado, te sugiero que entres aca y sigas la guía.


Creando nuestra infraestructura ☁️

Empecemos creando nuestro archivo main.tf con el siguiente contenido (voy a ir explicando para que es cada bloque):

provider "aws" {
    region = "us-east-1"
}
Enter fullscreen mode Exit fullscreen mode

El archivo main.tf es el archivo de configuración principal. Acá vamos a definir todo lo que queremos crear.

El bloque provider le permite saber a Terraform con que proveedor de la nube vamos a trabajar. Existen varios proveedores (podes ver la lista completa en este link)

Cada proveedor establece que valores deberemos indicar en este bloque. Hay cosas que son obligatorias, y otras que son opcionales.

Autenticación 🔐

Si, es necesario que Terraform se identifique con el proveedor para crear los recursos solicitados. Como logramos esto?

Para el caso de AWS existen 3 formas principales (en la doc hay mas):

  1. Colocar los valores directamente en el bloque del proveedor (no recomiendo esta forma):

        provider "aws" {
            region = "us-east-1"
            access_key = "clave-de-acceso"
            secret_key = "clave-secreta"
        }
    
  2. Usando variables de entorno (AWS_ACCESS_KEY_ID y AWS_SECRET_ACCESS_KEY y opcionalmente AWS_SESSION_TOKEN)

  3. Configurando el archivo de credenciales. Podes hacerlo con la herramienta de awscli y la instrucción aws configure. Terraform buscara el archivo donde, según la documentación, deberían estar. Pero podemos indicarle nosotros mismos donde buscar con lo siguiente:

        provider "aws" {
            shared_config_files = ["/tf_user/.aws/conf"]
            shared_credentials_files = ["/tf_user/.aws/creds"]
            profile = "mi-perfil-para-test"
        }
    

En mi caso, yo tengo las credenciales como variables de entorno así que no voy a colocar parámetros extras en el bloque del proveedor.

Creamos una VPC ☁️

Lo primero que vamos a crear es una VPC.

Si estas viendo Terraform, es porque algo de conocimiento tenés sobre AWS y ciertos recursos como VPC, EC2, etc. Si no es el caso, tranquilo/a que repasamos algunos conceptos clave para que entiendas 😃

Una VPC nos permite crear una red virtual privada para aislar recursos de forma lógica en la nube. Con esto podremos lanzar y gestionar recursos como instancias EC2, bases de datos en RDS, etc.

Escribamos esto en nuestro main.tf y analicemos que es:

resource "aws_vpc" "my-vpc" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Name = "aws-nginx-server-with-terraform"
    Environment = "test"
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

En Terraform debemos crear un bloque de resource por cada recurso que deseemos. Se define así:

resource "tipo_de_recurso" "nombre_del_recurso" {
    atributo = valor
}
Enter fullscreen mode Exit fullscreen mode

Donde:

  • tipo_de_recurso: indica el tipo de recurso que estamos creando (esto se define en la documentación del proveedor). Por ejemplo: aws_instance, aws_vpc, aws_eks_cluster, etc.
  • nombre_del_recurso: nombre que le damos a ese recurso para usarlo en los siguientes bloques en caso de ser necesario (spoiler)

Los Tags

El atributo tags es algo que se encuentra presente en casi todos los recursos que podemos crear. Yo normalmente agrego estas 3 etiquetas:

  1. El nombre del proyecto para el que estoy creando la infra. Es mejor si es el nombre del repositorio donde estas por almacenar los manifiestos.
  2. El ambiente para el que esta siendo desplegado (dev, stg, prd).
  3. El tag de Terraform = true para saber que ese recurso esta siendo gestionado por esa herramienta y comunicarle así a mi equipo (o a mi yo del futuro) que evite modificarlo manualmente.

Creamos un Internet Gateway 🌐

Este recurso es fundamental en la infraestructura de red de AWS, ya que proporciona una conexión entre una red privada en la nube y la red publica de internet.

resource "aws_internet_gateway" "internet-gw" {
  vpc_id = aws_vpc.dev-vpc.id
  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Fijate en el siguiente detalle: No estamos escribiendo el id del VPC porque no lo tenemos ya que no existe. Así que le estamos pidiendo a Terraform que lo defina por nosotros cuando cree ese recurso.
Siempre que tengamos que referirnos a un recurso que va a ser creado en el mismo manifiesto, lo vamos a hacer de esta forma: tipo_de_recurso.nombre_del_recurso.atributo

Creamos el Route Table 📄

El Route Table nos permite definir como se dirige el trafico entre las subredes de la VPC y, en particular, como se enruta el trafico desde y hacia internet mediante el Internet Gateway.

resource "aws_route_table" "route-table" {
  vpc_id = aws_vpc.my-vpc.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.internet-gw.id
  }

  route {
    ipv6_cidr_block = "::/0"
    gateway_id = aws_internet_gateway.internet-gw.id
  }

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Este bloque de configuración está estableciendo reglas para decirle a AWS cómo manejar el tráfico dentro de una VPC, permitiendo que las cosas en esa VPC se comuniquen con el Internet y viceversa. La tabla de rutas es como un mapa que dice a las instancias de la VPC hacia dónde enviar o recibir datos.

Creamos una subnet en la VPC 🌐

Una subnet es una porción de una red IP mas grande. En AWS, una subnet esta asociada a una VPC y se usa para organizar y segmentar los recursos de red.

resource "aws_subnet" "subnet-1a" {
  vpc_id            = aws_vpc.my-vpc.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1a"

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Creamos la asociación entre la subnet y la route table 🌐

resource "aws_route_table_association" "association-1a" {
  subnet_id      = aws_subnet.subnet-1a.id
  route_table_id = aws_route_table.route-table.id
}
Enter fullscreen mode Exit fullscreen mode

Este bloque nos va a permitir establecer una relación entre la subred que creamos mas arriba y la tabla de enrutamiento también creada con anterioridad. Esto significa, básicamente, que la subred va a usar esas reglas de enrutamiento.

Creamos un security group 🔓

Los "security group" son como un firewall. Estos permiten definir que conexiones entrantes y salientes se aceptan, y cuales no.

resource "aws_security_group" "security-group" {
  name        = "sg-internet"
  description = "Allow ingress and egress connection to the internet"
  vpc_id      = aws_vpc.my-vpc.id

  ingress {
    description = "Allow HTTPS connection"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow HTTP connection"
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow SSH connection"
    from_port   = 22
    to_port     = 22
    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"]
  }

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Este bloque nos permite definir las reglas necesarias para permitir las conexiones hacia los puertos 80 (HTTP), 443 (HTTPS) y 22 (SSH) desde cualquier IP. Si, se que no es seguro permitir conexiones desde cualquier lado por SSH, pero es solo para el ejemplo.

Creamos una network interface 🌐

Las "network interfaces" (interfaces de red) son un componente clave para permitir la conexión de instancias EC2 a la red.

resource "aws_network_interface" "network-interface" {
  subnet_id       = aws_subnet.subnet-1a.id
  private_ips     = ["10.0.1.50"]
  security_groups = [aws_security_group.security-group.id]

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Este recurso nos permite configurar también la IP privada que asignaremos a la interfaz.

Creamos una elastic IP 🌐

En AWS, una elastic IP es una dirección IP publica estática que sera asociada a una instancia EC2. Lo que la diferencia de las IPs publicas tradicionales que son asignadas dinámicamente a nuestras instancias EC2, es que las IPs otorgadas por este recurso persisten aun cuando las instancias son reiniciadas o detenidas. Increíble, no?

resource "aws_eip" "eip" {
  domain                    = "vpc"
  network_interface         = aws_network_interface.network-interface.id
  associate_with_private_ip = "10.0.1.50"

  depends_on = [aws_internet_gateway.internet-gw]

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Algo interesante de este bloque es el depends_on que nos permite decirle a Terraform que debe crear primero de forma explícita. En nuestro caso, este atributo asegura que la Elastic IP se cree después de que el Internet Gateway asociado esté disponible.

Finalmente: creamos el servidor 🖥️

Las instancias EC2 (Elastic Cloud Computing) son básicamente maquinas virtuales con recursos de computo limitados según el tipo de instancia elegida. Como es una maquina virtual, debemos seleccionar el sistema operativo que correrá en la misma. Es lo que en AWS se conocen como AMI (Amazon Machine Image).
Tenemos AMI's para Windows, Linux y MacOS.
Para este tutoria, voy a usar una imagen de Ubuntu Server 20.04 aunque vos podes usar el que quieras.

El recurso necesita que le indiquemos el ID de la AMI. Como lo obtenemos? Existen dos formas: buscar en la consola de AWS, o definir un bloque de data para que haga la búsqueda por nosotros.

Si vamos por la consola, al darle "Crear instancia" podremos seleccionar el tipo de AMI y copiar el ID. También podes usar esta guía de AWS.

El bloque data

Este bloque se usa para obtener y utilizar información existente antes de la creación o modificación de los recursos.

data "aws_ami" "ubuntu-ami" {
  most_recent = true 
  filter {
    name = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-focal-20.04-amd64-server-*"]
  }

  filter {
    name = "virtualization-type"
    values = ["hvm"]
  } 
}
Enter fullscreen mode Exit fullscreen mode

Es importante que si vamos a usar esta opción, aceptemos los términos y condiciones de AWS Marketplace. Si no lo hacemos, obtendremos un error como este:

creating EC2 Instance: OptInRequired: In order to use this AWS Marketplace product you need to accept terms and subscribe. To do so please visit https://<URL>
Enter fullscreen mode Exit fullscreen mode

Por último, creamos la instancia de EC2

resource "aws_instance" "nginx-server" {
  ami           = data.aws_ami.ubuntu-ami.id
  instance_type = "t2.micro"

  network_interface {
    device_index         = 0
    network_interface_id = aws_network_interface.network-interface.id
  }

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

Si no usaste data, podes hacerlo de esta forma:

resource "aws_instance" "nginx-server" {
  ami           = "ami-0fc5d935ebf8bc3bc"
  instance_type = "t2.micro"

  network_interface {
    device_index         = 0
    network_interface_id = aws_network_interface.network-interface.id
  }

  tags = {
    Name        = "aws-nginx-server-with-terraform",
    Environment = "test",
    Terraform = true
  }
}
Enter fullscreen mode Exit fullscreen mode

En este bloque estamos definiendo: el tipo de instancia, la AMI que debe usar, la interfaz de red (que creamos mas arriba) y los tags de siempre.

Que nos falta? 🤔
Esto nos creara una instancia con un sistema operativo y nada mas. Que pasa con Nginx? Debemos instalarlo a mano a través de SSH? 🤔
Por suerte no. Si has trabajado con la consola, te acordaras de un input llamado "user data". Este input nos permitía definir un script de bash (en su mayoría) que seria ejecutado cuando la instancia de EC2 sea creada.
Para agregar un user_data en el bloque, tenemos dos formas:

Forma 1️⃣:

  ...
  user_data=<<EOF
#!/bin/bash
sudo apt update
sudo apt install nginx -y
sudo systemctl start nginx
sudo systemctl enable nginx
EOF
  ...
Enter fullscreen mode Exit fullscreen mode

Forma 2️⃣ (mi favorita)

Creamos un archivo llamado init.sh (o como prefieras) y definimos el script ahi:

#!/bin/bash
sudo apt update
sudo apt install nginx -y
sudo systemctl start nginx
sudo systemctl enable nginx
Enter fullscreen mode Exit fullscreen mode

Ahora, en el main.tf definimos el user_data de la siguiente forma:

  ...
  user_data = file('init.sh')
  ...
Enter fullscreen mode Exit fullscreen mode

El archivo de init.sh debe estar al mismo nivel que el archivo main.tf

Despliegue 🚀

Ahora que tenemos toda la infraestructura definida, solo nos resta decirle a Terraform que aplique lo solicitado.

El primer comando que vamos a ejecutar donde se encuentra el main.tf es: terraform init. Este comando incializará los archivos de estado y descargara los archivos necesarios para interactuar con el proveedor (AWS en nuestro caso).

Al ejecutarlo, notaremos que se han creado algunas cosas en el directorio donde estamos:

  1. .terraform.lock.hcl: este archivo registra la version del proveedor que vamos a usar para el despliegue.
  2. .terraform: este directorio contiene varios directorios, pero en resumen, acá se guarda el binario del proveedor.

Si no estamos seguro de que es lo que va a hacer Terraform, este nos provee de un comando para la visualización del plan de ejecución. Simplemente debemos ejecutar terraform plan.

Si estamos seguros, podemos ejecutar terraform apply para aplicar el plan. Este comando primero nos mostrara todo lo que va a hacer (misma salida que el terraform plan), y nos pedirá que ingresemos una palabra para confirmar los cambios.

Mientras el plan se ejecuta, notaremos cambios en nuestro directorio. Se ha creado un archivo llamado terraform.tfstate. Este es MUY importante para Terraform ya que con el rastrea y gestiona el estado de los recursos que fueron creados por la herramienta.

Cuidado! En este terraform.tfstate, Terraform muestra secretos y cosas sensibles en texto plano. Así que si estas recuperando secretos de Secrets Manager para inyectarlos en los recursos que estes creando/modificando, tené por seguro que van a aparecer ahi sin protección alguna.

Abordaremos cuestiones de seguridad y buenas practicas en otro post.

Revisamos la consola de AWS

Para saber que todo fue bien, entramos a la consola y nos vamos al apartado de EC2

Información de la instancia EC2 creada

Ya tenemos una instancia creada!
Copiemos la IP y vayamos al navegador para corroborar que todo funciona:

Captura del navegador mostrando la respuesta exitosa de Nginx

is alive

Destruir lo creado

Para borrar todos los recursos creados y evitar un costo extra en nuestra factura de AWS, apliquemos terraform destroy

Explosiones

Extras (lo que no vimos)

Existen mas cosas de Terraform que no cubrimos en este mini tutorial.

Bloque output

Este bloque se utiliza para declarar valores que deben ser mostrados después de que Terraform haya aplicado los cambios. Puede ser información útil, identificadores, direcciones IP, o cualquier otro dato que deseemos exponer después de que la infraestructura ha sido creada o modificada.

En nuestro caso podriamos agregar el siguiente bloque para obtener la IP pública y evitar acceder a la consola

output "public_ip_nginx_server" {
  value = aws_instance.nginx-server.public_ip
}
Enter fullscreen mode Exit fullscreen mode

Archivos

Si bien el main.tf es el mas importante, existen otros archivos que podemos definir para mantener el main lo mas limpio posible:

  • variables.tf : acá definimos variables que pueden ser usadas para parametrizar la configuracion de los recursos. Por ejemplo: el environment y el tipo de instancia

    # variables.tf
    variable "instance_type" {
        type = string
        default = "t2.micro"
    }
    
    variable "environment" {
        type = string
        default = "test"
    }
    
    # main.tf
    resource "aws_instance" "nginx-server" {
      ami           = "ami-0fc5d935ebf8bc3bc"
      instance_type = var.instance_type # <-- esto cambió
    
      network_interface {
        device_index         = 0
        network_interface_id = aws_network_interface.network-interface.id
    }
    
      user_data = file("init.sh")
    
      tags = {
        Name        = "aws-nginx-server-with-terraform",
        Environment = var.environment, # <-- esto cambió
        Terraform = true
      }
    }
    
  • outputs.tf: Acá definimos todas las salidas que necesitamos

    output "public_ip_nginx_server" {
      value = aws_instance.nginx-server.public_ip
    }
    
  • terraform.tfvars: Este archivo se usa para asignar valores a las variables definidas en variables.tf. Por ejemplo, podemos darle un valor de la variable aws_region (si la tuvieramos definida)

        aws_region = "us-east-1"
    
  • backend.tf: Este archivo se usa para definir un backend remoto, es decir, el lugar donde se va a almacenar el terraform.tfstate, pero esto mejor dejémoslo para otro post.


Hasta acá el post de hoy! Espero realmente que te haya gustado.
Todo comentario o sugerencia es mas que bienvenido.

Te dejo el link al repositorio por si queres dar un vistazo mas general, forkearlo (spanglish++) o revisar mis otros repositorios.

No te olvides de seguirme en redes (Twitter: LeoGareis)

Hasta la próxima coder!

Top comments (0)