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:
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:
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.