Terraform + AWS + OpenShift

Publicado por Alejandro Nieto el

DevOpsTerraformAmazon AWSOpenShift

En este artículo nos vamos a centrar en cómo encadenar el aprovisionamiento de la  infraestructura necesaria para un despliegue de Openshift sobre AWS y su configuración de forma automática. Para ello utilizaremos Terraform y la receta oficial de openshift-ansible (utilizar rama release-3.9).

Para repasar conceptos básicos de Terraform recomiendo la lectura del siguiente artículo, así como de otros ya publicados en este blog: http://enmilocalfunciona.io/infraestructura-como-codigo-iii-terrafom-y-aws/

Vamos a crear la infraestructura basándonos en la arquitectura de referencia de Red Hat:

Ref Arch

Terraform

Según Hashicorp, para escribir un módulo de Terraform es recomendable utilizar tres ficheros para definir los recursos

  • main.tf
  • outputs.tf
  • variables.tf

Como este módulo es un poco más complejo vamos a definir ficheros por tipo de recursos:

  • 00-main.tf
  • 01-security.tf
  • 02-lb.tf
  • 03-network.tf
  • outputs.tf
  • variables.tf

Para la fácil transición de entornos y aislar el estado de nuestra infraestructura en función del entorno es recomendable utilizar la función "workspaces" que nos proporciona la herramienta Terraform. Los creamos de la siguiente forma:

$ terraform workspace new dev
$ terraform workspace new pre
$ terraform workspace new pro

Si trabajamos con workspaces podemos utilizar la variable ${terraform.workspace} para transicionar fácilmente entre entorno:

resource "aws_vpc" "openshift" {  
  cidr_block = "${var.cidr_block}"
  enable_dns_hostnames = true
  tags {
    Name = "${var.cluster_id}-${terraform.workspace}"
  }
}

Incluyendo esta variable diferenciamos los recursos de un entorno a otro sin necesidad de reescribir código, tan sólo cambiando de workspace:

terraform workspace select pre  

También vamos a utilizar un fichero .tfvars (donde se indican las variables) por entorno:

  • dev.tfvars
  • pre.tfvars
  • pro.tfvars Las variables que se van a ir mencionando a lo largo del post deberían ser definidas en estos ficheros.
Elementos de red

Vamos a definir los primeros recursos. Lo primero que vamos a definir son los elementos de red:

  • VPC
  • Subnets
  • Nat Gateway
  • Internet Gateway
  • Tablas de enrutamiento

Las variables a utilizar para los elementos de red serían las siguientes:

region                = "eu-west-1"  
azs                   = ["eu-west-1a", "eu-west-1b", "eu-west-1c"]  
cidr_block            = "172.16.0.0/16"  
cidr_master_blocks    = ["172.16.10.0/24","172.16.11.0/24","172.16.12.0/24"]  
cidr_infra_blocks     = ["172.16.20.0/24","172.16.21.0/24","172.16.22.0/24"]  
cidr_node_blocks      = ["172.16.30.0/24","172.16.31.0/24","172.16.32.0/24"]  
cidr_public_blocks    = ["172.16.40.0/24","172.16.50.0/24","172.16.60.0/24"]  

El elemento base es el vpc:

resource "aws_vpc" "openshift" {  
  cidr_block = "${var.cidr_block}"
  enable_dns_hostnames = true
  tags {
    Name = "${var.cluster_id}-${terraform.workspace}"
  }
}

Se van a crear subredes por rol y AZ. Es decir, una subnet_master por cada AZ y lo mismo para cada una de las variables que hemos especificado arriba. Las subredes se pueden crear con la máscara que se desee, no es necesario utilizar la indicada en las variables.

resource "aws_subnet" "master" {  
  count                = "${length(var.cidr_master_blocks)}"
  vpc_id               = "${aws_vpc.openshift.id}"
  cidr_block           = "${var.cidr_master_blocks[count.index]}"
  availability_zone    = "${element(var.azs, count.index)}"

  tags {
    Name = "${var.cluster_id}-master${format("%02d", count.index + 1)}-${terraform.workspace}"
  }
}

El Internet Gateway es la pieza por la que las instancias con ip pública van a salir a internet, en este caso, tenemos un bastión, la única máquina con una ip pública). El Internet Gateway está asociado al VPC:

  vpc_id = "${aws_vpc.openshift.id}"

  tags {
    Name = "${var.cluster_id}-${terraform.workspace}"
  }
}

Posteriormente, debemos crear una ruta y en la que el gateway sea el "Internet Gateway" que acabamos de crear. Esta ruta servirá para que las máquinas con direccionamiento público puedan salir a internet y será la tabla por defecto:

resource "aws_default_route_table" "openshift_igw" {  
  default_route_table_id = "${aws_vpc.openshift.main_route_table_id}"

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = "${aws_internet_gateway.openshift.id}"
  }

  tags {
    Name = "${var.cluster_id}-${terraform.workspace}"
  }
}

Para que las instancias con ips privadas puedan acceder a internet debemos crear un Nat Gateway (con su correspondiente ip pública). Este nat gateway hay que desplegarlo en las subredes públicas que hemos creado anteriormente, es decir, serían tres Nat Gateway:

resource "aws_nat_gateway" "openshift_nat_gw" {  
  count          = "${length(var.azs)}"
  allocation_id  = "${aws_eip.nat_gateway.*.id[count.index]}"
  subnet_id      = "${aws_subnet.public.*.id[count.index]}"
  depends_on     = ["aws_internet_gateway.openshift"]
}

Al igual que con el Internet Gateway, hay que crear rutas de encaminamiento y asociarlas con las subredes privadas (master, infra y node):

resource "aws_route_table" "openshift_nat_route" {  
  count  = "${length(var.azs)}"
  vpc_id = "${aws_vpc.openshift.id}"

  route {
    cidr_block = "0.0.0.0/0"
    nat_gateway_id = "${aws_nat_gateway.openshift_nat_gw.*.id[count.index]}"
  }

  tags {
    Name = "openshift-nat-${aws_subnet.public.*.id[count.index]}-${terraform.workspace}"
  }
}

:

resource "aws_route_table_association" "route_nat_master" {  
  count          = "${length(var.cidr_master_blocks)}"
  subnet_id      = "${aws_subnet.master.*.id[count.index]}"
  route_table_id = "${aws_route_table.openshift_nat_route.*.id[count.index]}"
}
Seguridad

Las políticas de seguridad que vamos a utilizar en el post son más permisivas de lo que en realidad deben ser pero la finalidad del post no es la securización de la infraestructura así que vamos a simplificarlo centrándonos en el acceso que vamos a permitir desde fuera a nuestra vpc, que serán los puertos 22 (bastion) y los puertos para acceder a consolas y aplicaciones (8443, 443 y 80).

Al crear una vpc, automáticamente se crea un grupo de seguridad que se aplicará por defecto que permitirá todo el tráfico desde los elementos internos de esta vpc.
En este caso solo necesitamos una variable en la que definiremos la cantidad y los nombres de los grupos de seguridad que vamos a crear:

security_groups = ["bastion","master","infra","node"]  

En Terraform también se puede hacer referencia a recursos ya creados y con un ciclo de vida diferente al de los recursos de nuestro módulo. Se pueden referenciar de la siguiente forma, donde la variable :

data "aws_security_group" "default" {  
  name = "default"
  vpc_id = "${aws_vpc.openshift.id}"
}

A continuación, vamos a crear los grupos de seguridad. Como apunte, en AWS estos no están asociados a subredes, se asocian a instancias; por lo tanto podrías tener dos instancias en la misma subred con diferentes políticas de seguridad.

resource "aws_security_group" "sg_tf" {  
  count          = "${length(var.security_groups)}"
  name           = "${var.security_groups[count.index]}-${terraform.workspace}"
  description    = "${var.security_groups[count.index]}"
  vpc_id         = "${aws_vpc.openshift.id}"

  tags {
    "kubernetes.io/cluster/openshift-cluster" = "${var.aws_label_value}"
  }
}

Las reglas necesarias son las siguientes:
Permitir el puerto 22 para acceder al bastion:

resource "aws_security_group_rule" "bastion_ssh_tf" {  
  type            = "ingress"
  from_port       = 22
  to_port         = 22
  protocol        = "tcp"
  cidr_blocks     = ["0.0.0.0/0"]
  security_group_id = "${element(aws_security_group.sg_tf.*.id, 0)}"
}

Sobre master solo necesitamos acceso a la consola web de openshift, que se expone por defecto en el puerto 8443:

resource "aws_security_group_rule" "master_8443_tf" {  
  type            = "ingress"
  from_port       = 8443
  to_port         = 8443
  protocol        = "tcp"
  cidr_blocks     = ["0.0.0.0/0"]
  security_group_id = "${element(aws_security_group.sg_tf.*.id, 1)}"
}

Los nodos "infra" de Openshift son los que exponen las aplicaciones al exterior, así que esto podría hacerse más al gusto pero vamos a incluir los puertos más usuales:

resource "aws_security_group_rule" "infra_443_tf" {  
  type            = "ingress"
  from_port       = 443
  to_port         = 443
  protocol        = "tcp"
  cidr_blocks     = ["0.0.0.0/0"]
  security_group_id = "${element(aws_security_group.sg_tf.*.id, 2)}"
}

resource "aws_security_group_rule" "infra_80_tf" {  
  type            = "ingress"
  from_port       = 80
  to_port         = 80
  protocol        = "tcp"
  cidr_blocks     = ["0.0.0.0/0"]
  security_group_id = "${element(aws_security_group.sg_tf.*.id, 2)}"
}
Instancias

En la creación de instancias ya vamos a hacer referencia a recursos ya definidos, como subredes y grupos de seguridad.
Con el uso de variables y utilizando "count" en los recursos podemos desplegar dinámicamente el número de instancias que deseemos de cada rol.
Variables a utilizar:

bastion_instance_type   = "t2.micro"  
instance_type           = "m4.xlarge"  
image                   = "ami-3548444c"  
key_name                = "your_key"  
key_file                = "certs/your_key"  
master_count            = 3  
infra_count             = 1  
node_count              = 1  
etcd_count              = 0  
osdisk_size             = "30"  

Nuestro bastión va a ser desde donde vamos a lanzar la instalación de Openshift, así que necesitará varias herramientas instaladas, lo haremos utilizando la opción "provisioner" de Terraform, que te permite tanto transferir ficheros como ejecutar una shell. En este caso es sencillo, así que lo haremos ejecutando una shell y la máquina quedará preparada para ejecutar el playbook de ansible cuando tengamos listo el inventario (esto lo haremos más adelante):

resource "aws_instance" "bastion" {  
  ami                         = "${var.image}"
  instance_type               = "${var.bastion_instance_type}"
  availability_zone           = "${element(var.azs, 0)}"
  key_name                    = "${var.key_name}"
  subnet_id                   = "${element(aws_subnet.public.*.id, 0)}"
  associate_public_ip_address = true
  vpc_security_group_ids      = ["${data.aws_security_group.default.id}","${element(aws_security_group.sg_tf.*.id, 0)}"]

  tags {
    Name = "bastion-tf-${terraform.workspace}"
  }

  provisioner "remote-exec" {

    connection {
      user = "centos"
      private_key = "${file("${path.module}/certs/your_key")}"
    }

    inline = [
      "sudo yum install -y epel-release",
      "sudo yum install -y git python-pip pyOpenSSL python-cryptography python-lxml python-passlib httpd-tools java-1.8.0-openjdk-headless",
      "sudo pip install --upgrade pip",
      "sudo pip install 'ansible==2.5.5'",
      "git clone -b release-3.9 https://github.com/openshift/openshift-ansible.git",
    ]
  }
}

A continuación, crearemos tantas instancias de X rol en función de las variables que hemos definido para esto. En este caso hemos optado por desplegar etcd en los mismos "master" así que dejamos la variable "etcd_count" a 0.

Para desplegar un master sería de la siguiente forma:

resource "aws_instance" "master" {  
  count                       = "${var.master_count}"
  ami                         = "${var.image}"
  instance_type               = "${var.instance_type}"
  availability_zone           = "${element(var.azs, count.index)}"
  key_name                    = "${var.key_name}"
  subnet_id                   = "${element(aws_subnet.master.*.id, count.index)}"
  associate_public_ip_address = false
  vpc_security_group_ids      = ["${data.aws_security_group.default.id}","${element(aws_security_group.sg_tf.*.id, 1)}"]

  root_block_device {
    volume_size = "${var.osdisk_size}"
  }

  tags {
    Name = "master-tf${format("%02d", count.index + 1)}-${terraform.workspace}"
    "kubernetes.io/cluster/openshift-cluster" = "${var.cluster_id}"
  }

}

Como veis, se utilizan las variables referente al rol master, como por ejemplo 'master_count'. Para desplegar nodos de infra o aplicación, tan sólo hace falta hacer referencia a las variables de cada rol.

Balanceadores de carga

Para disponer de alta disponibilidad, vamos a exponer los masters y los nodos de infra utilizando los balanceadores de carga que nos proporciona AWS. Estos balanceadores deben estar situados en las subredes públicas y aplicarle los grupos de seguridad que hemos definido anteriormente en función del rol.
Las variables a utilizar por estos recursos son las siguientes:

hosted_zone = "yourdomain.com."  
domain = "yourdomain.com"  
company = "atsistemas"  
elb_names = ["openshift-lb-master","openshift-lb-infra"]  

Los puertos que expondrán los balanceadores deben de ser los mismos que hemos definido en los grupos de seguridad:

resource "aws_elb" "master" {  
  name               = "${element(var.elb_names, 0)}-${terraform.workspace}"
  security_groups    = ["${data.aws_security_group.default.id}","${element(aws_security_group.sg_tf.*.id, 1)}"]
  subnets            = ["${aws_subnet.public.*.id}"]


  listener {
    instance_port      = 8443
    instance_protocol  = "tcp"
    lb_port            = 8443
    lb_protocol        = "tcp"
  }

  health_check {
    healthy_threshold   = 2
    unhealthy_threshold = 2
    timeout             = 3
    target              = "TCP:8443"
    interval            = 30
  }

  instances                   = ["${aws_instance.master.*.id}"]
  cross_zone_load_balancing   = true
  idle_timeout                = 400
  connection_draining         = true
  connection_draining_timeout = 400

  tags {
    Name = "${element(var.elb_names, 0)}-${terraform.workspace}"
    "kubernetes.io/cluster/openshift-cluster" = "${var.aws_label_value}"
  }
}

Como podéis ver, en "instances" se puede referenciar de forma fácil a las instancias que hemos creado anteriormente sin importar el número de máquinas que se hayan creado.
Para crear el balanceador de carga correspondiente a infra tan solo necesitamos cambiar las variables para que hagan referencia a éstos. igual que hemos hecho con las instancias, para crear el

resource "aws_elb" "infra" {  
  name               = "${element(var.elb_names, 1)}-${terraform.workspace}"
  security_groups    = ["${data.aws_security_group.default.id}","${element(aws_security_group.sg_tf.*.id, 2)}"]
  subnets            = ["${aws_subnet.public.*.id}"]


  listener {
    instance_port     = 80
    instance_protocol = "http"
    lb_port           = 80
    lb_protocol       = "http"
  }

  listener {
    instance_port      = 443
    instance_protocol  = "tcp"
    lb_port            = 443
    lb_protocol        = "tcp"
  }

  health_check {
    healthy_threshold   = 2
    unhealthy_threshold = 2
    timeout             = 3
    target              = "TCP:443"
    interval            = 30
  }

  instances                   = ["${aws_instance.infra.*.id}"]
  cross_zone_load_balancing   = true
  idle_timeout                = 400
  connection_draining         = true
  connection_draining_timeout = 400

  tags {
    Name = "${element(var.elb_names, 1)}-${terraform.workspace}"
    "kubernetes.io/cluster/openshift-cluster" = "${var.aws_label_value}"
  }
}

También vamos a definir los registros dns dinámicamente para que los recursos sean accesibles desde fuera utilizando Route53 de AWS. En este ejemplo la "hosted zone" escapa del ciclo de vida, así que obtenemos los datos de la siguiente forma:

data "aws_route53_zone" "hosted_zone" {  
  name         = "${var.hosted_zone}"
}

Creamos dos registros en la hosted_zone, que serán para acceder a la consola y a las aplicaciones:

resource "aws_route53_record" "openshift" {  
  zone_id = "${data.aws_route53_zone.hosted_zone.zone_id}"
  name    = "console.${var.company}.${var.domain}"
  type    = "CNAME"
  ttl     = "300"

  records = ["${aws_elb.master.dns_name}"]
}

resource "aws_route53_record" "openshift-apps" {*  
  zone_id = "${data.aws_route53_zone.hosted_zone.zone_id}"
  name    = "*.apps.${var.company}.${var.domain}"
  type    = "CNAME"
  ttl     = "300"

  records = ["${aws_elb.infra.dns_name}"]
}
Conexión entre Terraform y Ansible

Si sabemos jugar con las variables y la información que nos proporciona Terraform sobre los recursos creados, podemos encadenar el aprovisionamiento de la infraestructura con la generación de un inventario que podemos utilizar como objetivo en la ejecución de un playbook de Ansible. Terraform te permite crear templates en los que volcar datos que nos puedan resultar útiles

Bien, el inventario para instalar openshift sería algo así:

[OSEv3:children]
masters  
nodes  
etcd

[masters]
ip-172-16-10-x.eu-west-1.compute.internal openshift-ip=172.16.10.x openshift_schedulable=true


[etcd]
ip-172-16-10-x.eu-west-1.compute.internal openshift-ip=172.16.10.x openshift_schedulable=true


[nodes]
ip-172-16-10-x.eu-west-1.compute.internal openshift-ip=172.16.10.x openshift_schedulable=true openshift_node_labels="{'region': 'primary', 'zone': 'east'}"

ip-172-16-20-x.eu-west-1.compute.internal openshift-ip=172.16.20.x openshift_schedulable=true openshift_node_labels="{'region': 'infra', 'zone': 'default'}"

ip-172-16-30-x.eu-west-1.compute.internal openshift-ip=172.16.30.x openshift_schedulable=true openshift_node_labels="{'region': 'primary', 'zone': 'east'}"  
...

Como podéis ver, en función del grupo al que pertenezcan, deben incluir una información u otra. Para tratar con esto, más adelante vamos a definir varios templates para generar la información necesaria en función del grupo de hosts o rol.

El template final del inventario quedará de la siguiente forma:

[OSEv3:children]
masters  
nodes  
etcd

[masters]
${master_hosts}

[etcd]
${etcd_hosts}

[nodes]
${master_node_hosts}
${infra_hosts}
${node_hosts}

Como se puede ver, las variables en los templates son referencias de forma diferente: ${variable}. En un fichero .tf sería así: ${var.variable}

Para construir los datos necesarios a incluir en el inventario vamos a definir un template con ciertas variables para generar la información necesaria:

${name} openshift-ip=${ip} ${data}

Bien, en función del rol de la máquina vamos a generar datos diferentes en la variable ${data}. Para generar los datos necesarios en el grupo [masters] pasaremos el texto "openshift_schedulable=true" a la variable ${data}:

data "template_file" "master-inventory" {  
  count = "${var.master_count}"
  template = "${file("templates/hostname.tpl")}"
  vars {
    name  = "${aws_instance.master.*.private_dns[count.index]}"
    ip    = "${aws_instance.master.*.private_ip[count.index]}"
    data = "openshift_schedulable=true"
  }
}

Los masters también hay que incluirlos en el grupo de hosts [nodes] así que generamos la información necesaria:

data "template_file" "master-node-inventory" {  
  count = "${var.master_count}"
  template = "${file("templates/hostname.tpl")}"
  vars {
    name  = "${aws_instance.master.*.private_dns[count.index]}"
    ip    = "${aws_instance.master.*.private_ip[count.index]}"
    data = "openshift_schedulable=true openshift_node_labels=\"{'region': 'primary', 'zone': 'east'}\""
  }
}

Para generar el template final hay que definir varios recursos bloques como el anterior pero podéis generar el código a partir del anterior modificando los recursos de los que se obtienen los datos. Para infra será:

name  = "${aws_instance.infra.*.private_dns[count.index]}"  
ip    = "${aws_instance.infra.*.private_ip[count.index]}"  

Para los nodos será:

name  = "${aws_instance.node.*.private_dns[count.index]}"  
ip    = "${aws_instance.node.*.private_ip[count.index]}"  

Finalmente, vamos a pasar todas las variables que hemos generado al template final, que será el que genere el inventario de openshift:

data "template_file" "openshift-inventory" {  
  template = "${file("templates/${var.inventory_template_name}")}"
  vars {
    master_hosts            = "${join(" ",data.template_file.master-inventory.*.rendered)}"
    master_node_hosts       = "${join(" ",data.template_file.master-node-inventory.*.rendered)}"
    etcd_hosts              = "${join(" ",data.template_file.master-inventory.*.rendered)}"
    infra_hosts             = "${join(" ",data.template_file.infra-inventory.*.rendered)}"
    node_hosts              = "${join(" ",data.template_file.node-inventory.*.rendered)}"
    aws_access_key_id       = "${var.aws_access_key}"
    aws_secret_access_key   = "${var.aws_secret_key}"
    cluster_id              = "${var.cluster_id}"
    domain                  = "${var.domain}"
    console_master_dns      = "${aws_route53_record.openshift.name}"
    subdomain_openshift_dns = "apps.${var.company}.${var.domain}"
  }
}
Ejecución

Ya hemos definido todos los recursos necesarios para crear la infraestructura y encadenar la ejecución de Ansible con el inventario generado a partir de Terraform. Para ello:

terraform plan -var-file='pre.tfvars' -var-file='aws-pre.tfvars' -out='aws-pre.tfplan'  
terraform apply "aws-pre.tfplan"  
terraform output openshift-inventory > ../openshift-inventory  

Una vez generado el inventario lo pasaremos al bastión y desde esta máquina ejecutaremos los playbooks para instalar openshift (el despliegue tardará en función del número de máquinas que se hayan definido):

ansible-playbook -i /home/centos/openshift-inventory openshift-ansible/playbooks/prerequisites.yml  
ansible-playbook -i /home/centos/openshift-inventory openshift-ansible/playbooks/deploy_cluster.yml  

Et voilà, Openshift ya es accesible:

Working
Conclusiones

Por último vamos a hacer un pequeño resumen del post:

  • Hemos utilizado Terraform para describir la infraestructura necesaria en AWS para un despliegue completo de Openshift.
  • Se ha utilizado el uso de 'workspaces' para la transición entre entornos (dev/pre/pro)
  • Se ha construido la infraestructura en función del número de instancias que se hayan obtenido a través de variables.
  • Se ha utilizado una hosted zone ya creada para que el cluster sea accesible desde fuera de nuestra red.
  • Hemos generado el inventario de Ansible utilizando variables output, este paso es el que conecta Terraform con Ansible y hace que la instalación sea dinámica.
  • En el último paso hemos ejecutado los comandos necesarios para el despliegue. Como mejora esto se puede incluir en un script para hacerlo totalmente automático.

Con esto cerramos el post, esperando que os haya gustado. No dudes en comentar cualquier duda o detalle por aquí, o a través de nuestra cuenta de Twitter.