Resumen
Los produtos habituales para la Infraestructura como código, como Terraform, Ansible, Chef, Puppet, y demás, suelen contar con su propia sintaxis, lo que dificulta en cierta medida su uso. Pulumi permite la creación de la infraestructura usando lenguajes de programación populares, como TypeScript, JavaScript, Python, Go o C#. De esta forma podemos usar nuestro lenguaje habitual o preferido para la creación de la infraestructura de forma sencilla, sin tener que pasar a otro lenguaje y beneficiándonos del manejo de variables y construcciones de programación habituales, como los bucles y condiciones, aspectos básicos pero que no son demasiado sencillos de usar en el resto de productos.
-
Aprender a desplegar infraestuctura mediante código.
-
Realizar cambios sobre la infraestructura a través del código.
-
Organizar el código despliegue.
-
Gestionar errores de despliegues corruptos.
Disponibles los repositorios usados en este tutorial |
1. Introducción
La Infraestructura como código (IaC, Infrastructure as Code) permite la definición y mantenimiento de la infraestructura mediante código. Esto permite la automatización de la creación de la infraestructura en lugar de hacerlo manualmente. Se trata de un proceso declarativo en el que mediante una serie de archivos se especifica de forma declarativa la infraestructura y la configuración que se necesita (p.e. un entorno de backend formado por un volumen de almacenamiento, una máquina virtual con MySQL instalado y conectada al volumen creado, una máquina virtual con un framework instalado como Express, la configuración de red y de grupos de seguridad, ….). De esta forma se evita tener que preparar manualmente las máquinas virtuales, sistemas operativos, almacenamiento y configuración, obteniéndose en su lugar la definición de un entorno que garantiza ser repetible, replicable, documentando las especificaciones y evitando la práctica de realización de cambios sobre la marcha y que no queden documentados. Con IaC, los cambios se hacen sobre el código y se aplican ejecutando el código desarrollado, en lugar de hacerlos en caliente mediante comandos sobre la infraestructura. Además, la combinación de IaC con la práctica de control de versiones permitirá, como en cualquier otro desarrollo software, llevar un control de cada uno de los cambios introducidos en la configuración de la infraestructura.
Entre los productos más populares de IaC se encuentran Terraform, Ansible, Chef, Puppet, … En estas propuestas, la infraestructura se especifica de formas muy variadas, como en formato YAML en el caso de Ansible, o en su propio lenguaje DSL (Domain Specific Language) como el HCL (Hashicorp Configuration Language) de Terraform y el Puppet Language de Puppet, pasando por Chef, que usa Ruby como sintaxis para la definición de la infraestructura.
En este contexto surge Pulumi, un nuevo producto de IaC que se diferencia del resto en que usa lenguajes de programación convencionales para la configuración de la infraestructura, como TypeScript, JavaScript, Python, Go o C#. Esto lo hace especialmente interesante, ya que no se tendrá que aprender un lenguaje extra para el manejo de infraestructura cloud y se facilita el uso de variables y construcciones de programación como bucles y condiciones.
Pulumi ofrece soporte para los principales proveedores cloud, como Amazon, Google Cloud, Microsoft Azure, Digital Ocean, Linode, OpenStack, vSphere, y demás, así como para Kubernetes, proveedores de infraestructura, bases de datos, monitorización y sistemas de control de versiones.
En este tutorial nos centraremos en la creación de un cluster de Kubernetes gestionado con Rancher y usando OpenStack como proveedor de infraestructura. Usaremos TypeScript como lenguaje de desarrollo.
2. Configuración de Pulumi
La instalación la haremos siguiendo la guía oficial de instalación.
-
En macOS la haremos mediante Homebrew con
brew install pulumi
. -
En Windows la haremos mediante Chocolatey con
choco install pulumi
. -
En Linux la haremos ejecutando el script
curl -fsSL https://get.pulumi.com | sh
.
Una vez instalado comprobaremos que todo es correcto con:
$ pulumi version
v3.7.0
3. Flujo de trabajo con Pulumi
El flujo de trabajo habitual en Pulumi consiste en la creación del proyecto, codificación de la infraestuctura inicial, despliegue de cambios. Sin embargo, no se trata de un proceso secuencial, sino que en realidad, tras la creación del proyecto se entra en un ciclo iterativo de codificación de infraestructura y despliegue de cambios.
El proyecto se crea con el comando pulumi new
y normalmente le pasaremos una plantilla como argumento. En la plantilla se configura la plataforma con la que vamos a trabajar (proveedor cloud, base de datos, …) y el lenguaje de programación que vamos a utilizar para codificar la infraestructura. Esto lo veremos en el apartado Creación y configuración inicial del proyecto.
Los cambios introducidos en el ciclo de desarrollo con Pulumi pueden deberse bien a la creación de código para nuevos recursos, bien a la modificación o eliminación del código de recursos creados previamente. La forma de llevar a cabo estos cambios siempre es con pulumi up
. Tras confirmar la acción de despliegue, los cambios se trasladarán a la infraestructura y Pulumi guardará el estado del despliegue, que consiste en anotar cada uno de los recursos configurados.
Cuando más adelante se introduzcan cambios en el código y se incluya nuevo código para nuevos recursos, o se modifique o elimine código de recursos creados previamente, se comparará el último estado guardado del proyecto con el nuevo estado al que se llegaría tras aplicar los cambios. Tras confirmar la acción de despliegue, Pulumi sólo desplegaría los recursos relativos a los cambios realizados, dejando intactos los recursos que no han sido modificados. En este sentido se dice que Pulumi es idempotente ya que no vuelve a crear un recurso que no ha sido modificado. Tras un despliegue exitoso, Pulumi vuelve a guardar el estado del despliegue realizado.
Si eliminamos un recurso del código y ejecutamos |
La infraestructura creada se elimina con pulumi destroy
. Es una operación peligrosa ya que elimina toda la infraestructura. Si hay datos o configuraciones almacenadas en el despliegue se corre el riesgo de pérdida de datos.
4. Caso de uso. Creación de una máquina virtual configurada con Rancher en OpenStack
El caso de uso que estudiaremos parte de un proyecto OpenStack creado previamente. En dicho proyecto se configurarán mediante Pulumi los grupos de seguridad, se creará una instancia que se aprovisionará durante su inicio con Rancher y se finalizará asignándole una dirección IP flotante.
4.1. Recursos del paquete Pulumi para OpenStack
De acuerdo con la documentación del paquete Pulumi para OpenStack, existen gran cantidad de módulos para la gestión de recursos OpenStack con Pulumi, entre los que destacan almacenamiento de bloques para Cinder, instancias de cómputo para Nova, identidades para Keystone, imágenes para Glance, redes para Neutron y shares para Manila.
4.2. Descarga de las credenciales del proyecto OpenStack
Desde la interfaz gráfica Horizon de OpenStack seguiremos estos casos para la descarga de credenciales del usuario en el proyecto OpenStack a utilizar.
-
Seleccionar el proyecto en OpenStack en el desplegable de proyectos del usuario.
-
En el desplegable del menú del usuario seleccionar
OpenStack RC File
. -
Cargar las credenciales descargadas con
source <credentials-filename>
. Introducir la contraseña solicitada de acceso a OpenStack.
Para usuarios de Windows se recomienda tener instalado WSL. |
4.3. Creación y configuración inicial del proyecto
Desde dentro de un directorio vacío creado para el proyecto crearemos el proyecto Pulumi con el comando pulumi new
. Si no indicamos nada más, habrá que seleccionar el tipo de proyecto eligiendo tanto la plataforma como el lenguaje. A esta combinación de tipo de proyecto (AWS, Azure, Google Cloud, Kubernetes, Linode, OpenStack) y lenguaje (Go, JavaScript, TypeScript, Python, C#) se le conoce como plantilla. Una forma más rápida es pasar el parámetro de configuración de la plantilla directamente al crear el proyecto
$ pulumi new openstack-typescript (1)
1 | Nuevo proyecto usando la plantilla con OpenStack como provider y TypeScript como lenguaje. |
A continuación:
-
Aceptaremos el nombre del proyecto, cuyo valor predeterminado es el del directorio en el que se encuentra.
-
Completaremos la descripción con
Configuración de MV OpenStack
. -
Aceptaremos el nombre del stack (
dev
).
Un stack es un concepto similar al de entorno de despliegue de aplicaciones. Podremos tener stacks diferentes para desarrollo, staging y producción. |
Una vez aceptadas las opciones de creación del proyecto se instalarán las dependencias del proyecto y unos instantes después el proyecto estará listo para ejecutarse.
Como resultado tendremos un proyecto con la estructura siguiente:
├── .gitignore
├── index.ts (1)
├── package.json (2)
├── Pulumi.yaml (3)
└── tsconfig.json
1 | Archivo con los recursos a desplegar. Incopora un ejemplo |
2 | Archivo de dependencias. La dependencia de OpenStack aparece como instalada al haber creado el proyecto con la plantilla openstack-typescript |
3 | Configuración del nombre y descripción del proyecto y runtime de ejecución |
4.4. Configuración de las reglas de seguridad
Las reglas de seguridad configuran el cortafuegos del proyecto de OpenStack. Para el ejemplo que nos ocupa, Rancher necesita inicialmente que estén abiertos los puertos TCP 80 y 443 para el tráfico HTTP (HTTP y HTTPS). Para implementarlo podemos incluir estas dos reglas de seguridad en el grupo default
del proyecto o crear un grupo de seguridad específico para estas dos reglas. Posteriormente, al configurar la instancia se le aplicaría el grupo de seguridad default
o el grupo específico creado para las reglas HTTP. En este ejemplo optamos por crear un grupo de seguridad específico.
Crear grupos de seguridad específicos para grupos de reglas de reglas de seguriddad es más laborioso que ir incluyendo las reglas en el grupo |
4.4.1. Creación de un grupo de seguridad
Los grupos de seguridad se crean con el recurso SecGroup
del módulo networking
. Basta con indicar un nombre para el grupo de seguridad y un JSON para las opciones. En nuestro caso incluiremos la descripción del grupo de seguridad.
const webSecGroup = new os.networking.SecGroup("web", {
description: "Web security group"
})
Esto define un grupo de seguridad asignado a una constante webSecGroup
. Asignar el recurso creado a una constante o una variable permite manipularlo posteriormente. En nuestro caso se añadirán reglas de seguridad.
4.4.2. Añadir reglas de seguridad
Las reglas de seguridad se añaden a los grupos de seguridad creando un recurso SecGroupRule
del módulo networking
. Se trata de indicar un nombre para la reglas de seguridad y un JSON para las opciones. En nuestro caso incluiremos una descripción, dirección, si es IPv4 o IPv6, el puerto abierto (definido como un rango), el protocolo, las direcciones IP remotas a las que se les da acceso y el grupo de seguridad al que se asigna la regla creada
const web80 = new os.networking.SecGroupRule("web80", {
description: "HTTP",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 80,
portRangeMin: 80,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id, (1)
});
const web443 = new os.networking.SecGroupRule("web443", {
description: "HTTPS",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 443,
portRangeMin: 443,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id, (2)
});
1 | Asignación de la regla a un grupo de seguridad. |
2 | Asignación de la regla a un grupo de seguridad. |
4.4.3. Despliegue de la configuración de seguridad
La configuración de seguridad completa para un entorno con Rancher y Kubernetes residiendo en el mismo proyecto OpenStack incluye una gran variedad de grupos y reglas de seguridad. La documentación oficial de Rancher especifica la lista de puertos a abrir para cada componente.
Hacer una definición exhaustiva de todos los grupos y reglas de seguridad de un proyecto para producción está fuera del ámbito de este tutorial. Por tanto, aquí nos limitaremos a incluir otro grupo de seguridad a modo de ejemplo para ver cómo configurar varios grupos de seguridad. Tomaremos como ejemplo la configuración de seguridad de los puertos 2379 y 2380 de la base de datos etcd
que usa Kubernetes para el almacenamiento de la configuración.
Finalmente, la configuración inicial de seguridad quedaría definida así en el archivo index.ts
.
import * as os from "@pulumi/openstack";
const cidr = '192.168.129.0/24' (1)
// Create security group (2)
const etcdSecGroup = new os.networking.SecGroup("etcd", {
description: "Kubernetes security group"
})
// Create security rule and assing to a security group (3)
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2379,
portRangeMin: 2379,
protocol: "tcp",
remoteIpPrefix: cidr, (4)
securityGroupId: etcdSecGroup.id, (5)
});
// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2380,
portRangeMin: 2380,
protocol: "tcp",
remoteIpPrefix: cidr,
securityGroupId: etcdSecGroup.id,
});
// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
description: "Web security group"
})
// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
description: "HTTP",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 80,
portRangeMin: 80,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
description: "HTTPS",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 443,
portRangeMin: 443,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
1 | CIDR para permitir el acceso remoto a instancias a las que se apliquen reglas de seguridad para ese CIDR |
2 | Creación de un grupo de seguridad |
3 | Creación de una regla para un grupo de seguridad |
4 | Aplicación del CIDR a la regla de seguridad |
5 | Asignación de la regla de seguridad a un grupo de seguridad |
Los cambios se desplegarían con pulumi up
y seleccionando la opción yes
. La opción details
muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.
Si al realizar el despliegue nos aparece el error |
La figura siguiente muestra el efecto del despliegue con los dos grupos de seguridad creados.
La figura siguiente ilustra las reglas de seguridad del grupo web
. Para ese grupo se permite el acceso a estos puertos desde cualquier dirección de Internet.
4.5. Creación de la instancia
Tras definir los grupos de seguridad aplicables a la instancia continuamos ahora con la creación de un recurso de instancia en OpenStack, lo que nos permitirá tener una máquina virtual desplegada con código mediante Pulumi.
Las instancias de OpenStack en Pulumi se crean con el recurso Instance
del módulo compute
. Basta con indicar un nombre para la instancia y un JSON para las opciones. En nuestro caso incluiremos la zona de disponibilidad, el nombre de la imagen tal y como está definida en OpenStack, el nombre del flavour o sabor a utilizar para crear la instancia, las redes a las que se conectará la instancia, el nombre del par de claves a inyectar en la instancia y los grupos de seguridad que controlan el acceso a la instancia. Además, incluiremos un script de inicialización de la instancia en su creación (lo que se conoce como user data en otros sistemas). En la sección Script de inicialización de Rancher se aportan los detalles de este script. Este script instalará Docker en la máquina virtual y ejecutará Rancher con Docker.
El fragmento siguiente ilustra el código para la creación de una instancia en el archivo index.ts
import * as os from "@pulumi/openstack";
const fs = require('fs') (1)
...
// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
availabilityZone: "stic-prod",
imageName: "Ubuntu 18.04 LTS",
flavorName: "large",
networks: [
{
name: "Sistemas-prod-net",
}
],
keyPair: "os-sistemas",
userData: fs.readFileSync('./rancher-setup.sh', 'utf8'), (2)
securityGroups: [etcdSecGroup.name, webSecGroup.name] (3)
});
...
1 | Paquete TypeScript para la interacción con archivos. |
2 | Carga del archivo que contiene el script de inicialización. Importante usar utf8. |
3 | Lista de grupos de seguridad a aplicar a la instancia. |
Los cambios se desplegarían con pulumi up
y seleccionando la opción yes
. La opción details
muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.
La figura siguiente muestra el efecto del despliegue con la instancia creada.
4.6. Asignación de IP flotante
Para poder acceder a la instancia desde el exterior le asignaremos una dirección IP flotante. En nuestro caso ya tenemos la dirección IP flotante adjudicada al proyecto y está registrada en un DNS para poder realizar una instalación de Rancher con nombre DNS. Por tanto, no será necesario crear la dirección IP flotante en el proyecto, sino que pasaremos directamente al paso de asignar dicha dirección IP flotante a la instancia. No obstante, también veremos cómo sería el script si hubiese que crear la dirección IP flotante.
Las direcciones IP flotantes de OpenStack en Pulumi se asignan con el recurso FloatingIpAssociate
del módulo compute
. Basta con indicar un nombre para la asociación de la IP y un JSON para las opciones. En nuestro caso incluiremos la dirección IP flotante y el identificador de la instancia de Rancher.
El fragmento siguiente ilustra el código para la creación de una instancia en el archivo index.ts
...
const floatingIP = '192.168.129.1' (1)
...
// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
floatingIp: floatingIP, (2)
instanceId: rancherInstance.id, (3)
});
...
1 | Dirección IP flotante a utilizar disponible previamente en el proyecto OpenStack |
2 | String con la dirección IP flotante |
3 | Identificador de la instancia |
Los cambios se desplegarían con pulumi up
y seleccionando la opción yes
. La opción details
muestra los detalles de cada uno de los recursos a crear, modificar o eliminar en la infraestructura.
La figura siguiente muestra el efecto del despliegue con la dirección IP flotante asignada a la instancia.
La figura siguiente ilustra los detalles de la instancia con la dirección IP flotante asignada y los grupos de seguridad configurados.
4.7. Script de inicialización de Rancher
#!/bin/bash
RANCHERPASSWORD='yourpasswordhere' (1)
RANCHERSERVER='https://your.url.here.com' (2)
echo "Instalando Docker" (3)
apt-get update
apt-get install -y \
apt-transport-https \
ca-certificates \
curl \
software-properties-common \
jq
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | apt-key add -
apt-key fingerprint 0EBFCD88
add-apt-repository \
"deb [arch=amd64] https://download.docker.com/linux/ubuntu \
$(lsb_release -cs) \
stable"
apt-get update
apt-get install -y docker-ce
groupadd docker
usermod -aG docker ubuntu
systemctl enable docker
echo "Obteniendo certificados"
mkdir /home/ubuntu/rancherdata
mkdir /home/ubuntu/certificados (4)
wget -O /home/ubuntu/certificados/star_stic_ual_es.crt https://your.certificate.server.here.com/star_stic_ual_es_completa.crt
wget -O /home/ubuntu/certificados/star_stic_ual_es.key https://your.certificate.server.here.com/star_stic_ual_es.key
wget -O /home/ubuntu/certificados/DigiCertCA.crt https://your.certificate.server.here.com/DigiCertCA.crt
docker run \ (5)
--privileged -d \
--restart=unless-stopped \
-p 80:80 -p 443:443 \
-v /home/ubuntu/rancherdata:/var/lib/rancher \
-v /home/ubuntu/certificados/star_stic_ual_es.crt:/etc/rancher/ssl/cert.pem \
-v /home/ubuntu/certificados/star_stic_ual_es.key:/etc/rancher/ssl/key.pem \
-v /home/ubuntu/certificados/DigiCertCA.crt:/etc/rancher/ssl/cacerts.pem \
--name rancher \
rancher/rancher:v2.5.8 \
--features=unsupported-storage-drivers=true (6)
echo "Configurando Rancher"
while ! curl -k https://localhost/ping; do sleep 3; done (7)
# First Rancher Login
LOGINRESPONSE=`curl -s <8> 'https://127.0.0.1/v3-public/localProviders/local?action=login' -H 'content-type: application/json' --data-binary '{"username":"admin","password":"admin"}' --insecure`
LOGINTOKEN=`echo $LOGINRESPONSE | jq -r .token` (9)
# Change password (10)
curl -s 'https://127.0.0.1/v3/users?action=changepassword' \
-H 'content-type: application/json' \
-H "Authorization: Bearer $LOGINTOKEN" \
--data-binary '{"currentPassword":"admin","newPassword":"'$RANCHERPASSWORD'"}' \
--insecure
# Configure server-url (11)
curl -s 'https://127.0.0.1/v3/settings/server-url' \
-H 'content-type: application/json' \
-H "Authorization: Bearer $LOGINTOKEN" \
-X PUT \
--data-binary '{"name":"server-url","value":"'$RANCHERSERVER'"}' \
--insecure
# Activate OpenStack node driver (12)
curl -s 'https://127.0.0.1/v3/nodeDrivers/openstack?action=activate' \
-H 'content-type: application/json' \
-H "Authorization: Bearer $LOGINTOKEN" \
-X POST \
--insecure
exit 0
1 | Variable con la contraseña de administrador |
2 | Variable con nombre DNS a asignar a Rancher |
3 | Instalación de paquetes necesarios para Docker |
4 | Descarga de certificados |
5 | Iniciar un contenedor Rancher con los certificados descargados anteriormente |
6 | Activar los drivers de almacenamiento experimentales para permitir el uso de OpenStack Cinder como proveedor de almacenamiento |
7 | Esperar a que Rancher esté activo |
8 | Usar la API de Rancher con las credenciales admin/admin y capturar la respuesta |
9 | Obtener el token de login a partir de la llamada anterior |
10 | Usar la API de Rancher con el token de login para configurar la nueva contraseña con la variable configurada al inicio del script |
11 | Usar la API de Rancher con el token de login para configurar el nombre DNS con la variable configurada al inicio del script |
12 | Usar la API de Rancher con el token de login para activar el driver de OpenStack |
La figura siguiente muestra Rancher disponible tras el inicio de la instancia
La figura siguiente muestra activada las características de drivers de almacenamiento no soportados para permitir el uso de volúmenes de OpenStack Cinder.
La figura siguiente muestra activado el driver de OpenStack para la creación de nodos Kubernetes
4.8. Reorganización del código
Hasta ahora hemos ido creando recursos poco a poco, comenzando con los grupos de seguridad para centrarnos posteriormente en la creación de la instancia. Actualmente tenemos toda la configuración de la infraestructura en un único archivo index.ts
. A medida que incorporemos nuevos grupos de seguridad, nuevas reglas, nuevas instancias, el código de index.ts
se terminará haciendo inmanejable. Actualmente, el archivo index.js
luce de esta manera.
index.ts
con todos los recursos juntosimport * as os from "@pulumi/openstack";
import * as sg from './security-groups'
const cidr = '192.168.129.0/24'
const floatingIP = '192.168.129.1'
const fs = require('fs')
// Create security group
const etcdSecGroup = new os.networking.SecGroup("etcd", {
description: "Kubernetes security group"
})
// Create security rule and assing to a security group
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2379,
portRangeMin: 2379,
protocol: "tcp",
remoteIpPrefix: cidr,
securityGroupId: etcdSecGroup.id,
});
// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2380,
portRangeMin: 2380,
protocol: "tcp",
remoteIpPrefix: cidr,
securityGroupId: etcdSecGroup.id,
});
// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
description: "Web security group"
})
// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
description: "HTTP",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 80,
portRangeMin: 80,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
description: "HTTPS",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 443,
portRangeMin: 443,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
availabilityZone: "stic-prod",
imageName: "Ubuntu 18.04 LTS",
flavorName: "large",
networks: [
{
name: "Sistemas-prod-net",
}
],
keyPair: "os-sistemas",
userData: fs.readFileSync('./rancher-setup.sh', 'utf8'),
securityGroups: [etcdSecGroup.name, webSecGroup.name]
});
// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
floatingIp: floatingIP,
instanceId: rancherInstance.id,
});
La refactorización que se propone consiste en:
-
Crear un archivo de variables (
values.ts
) en el que se configuren los valores de las variables a utilizar. En este ejemplo configuraremos por un lado el CIDR para permitir el acceso desde direcciones IP remotas y, por otro lado, la dirección IP flotante que tenemos reservada para Rancher. -
Separar la configuración de los grupos y reglas de seguridad en un archivo aparte (
security-groups.ts
) -
Mantener en
index.ts
sólo la configuración de la instancia de Rancher y la asignación a la IP flotante.
A continuación se muestra el código de cada uno de estos archivos tras la refactorización.
values.ts
con los valores de configuración del despliegueconst cidr = '192.168.129.0/24'
const floatingIP = '192.168.129.1'
export {cidr, floatingIP} (1)
1 | Constantes exportadas para ser reutilizadas |
security-groups.ts
con la configuración de los grupos y reglas de seguridad del despliegueimport * as os from "@pulumi/openstack";
import * as values from './values' (1)
// Create security group
const etcdSecGroup = new os.networking.SecGroup("etcd", {
description: "Kubernetes security group"
})
// Create security rule and assing to a security group
const etcd2379 = new os.networking.SecGroupRule("etcd2379", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2379,
portRangeMin: 2379,
protocol: "tcp",
remoteIpPrefix: values.cidr, (2)
securityGroupId: etcdSecGroup.id,
});
// Create security rule and assing to a security group
const etcd2380 = new os.networking.SecGroupRule("etcd2380", {
description: "etcd",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 2380,
portRangeMin: 2380,
protocol: "tcp",
remoteIpPrefix: values.cidr,
securityGroupId: etcdSecGroup.id,
});
// Create web security group
const webSecGroup = new os.networking.SecGroup("web", {
description: "Web security group"
})
// Create security rule and assing to a security group
const web80 = new os.networking.SecGroupRule("web80", {
description: "HTTP",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 80,
portRangeMin: 80,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
// Create security rule and assing to a security group
const web443 = new os.networking.SecGroupRule("web443", {
description: "HTTPS",
direction: "ingress",
ethertype: "IPv4",
portRangeMax: 443,
portRangeMin: 443,
protocol: "tcp",
remoteIpPrefix: '0.0.0.0/0',
securityGroupId: webSecGroup.id,
});
export {webSecGroup, etcdSecGroup} (3)
1 | Importación del archivo de parámetros y configuración del prefijo values para usar los objetos que ha exportado |
2 | Uso de las constantes definidas en el archivo de parámetros |
3 | Se exportan los grupos de seguridad para poder ser reutilizados |
index.ts
con la configuración de la instancia del despliegue y la asignación de una IP flotante asignada previamente al proyectoimport * as os from "@pulumi/openstack";
import * as values from './values' (1)
import * as sg from './security-groups' (2)
const fs = require('fs')
// Create an OpenStack resource (Compute Instance)
const rancherInstance = new os.compute.Instance("rancher-sistemas-prod", {
availabilityZone: "stic-prod",
imageName: "Ubuntu 18.04 LTS",
flavorName: "large",
networks: [
{
name: "Sistemas-prod-net",
}
],
keyPair: "os-sistemas",
userData: fs.readFileSync('./rancher-setup.sh', 'utf8'),
securityGroups: [sg.etcdSecGroup.name, sg.webSecGroup.name] (3)
});
// Associate a floating IP to the instance
const fipFloatingIpAssociate = new os.compute.FloatingIpAssociate("fip", {
floatingIp: values.floatingIP, (4)
instanceId: rancherInstance.id,
});
1 | Importación del archivo de parámetros y configuración del prefijo values para usar los objetos que ha exportado |
2 | Importación del archivo de grupos de seguridad y configuración del prefijo sg (security-groups) para usar los objetos que ha exportado |
3 | Uso de los grupos de seguridad del archivo de grupos de seguridad |
4 | Uso de los parámetros del archivo de parámetros |
5. Caso de uso. Creación de clusters Kubernetes con Rancher y OpenStack como proveedor de infraestructura
Pulumi cuenta con un paquete Rancher que permite la configuración de recursos Rancher. En este caso de uso partimos de una instalación previa de Rancher (ver Caso de uso. Creación de una máquina virtual configurada con Rancher en OpenStack). El objetivo será la creación de un cluster de Kubernetes usando Pulumi obteniendo un despliegue replicable y repetible en nuestro propósito de tener la infraestructura como código. La infraestructura del cluster de Kubernetes será ofrecida por un cloud OpenStack. Por tanto, deberemos comenzar con la creación de diferentes plantillas con las configuraciones necesarias de los recursos de los nodos del cluster (usaremos plantillas diferentes para los roles de control, base de datos etcd y los nodos worker). Una vez creadas las plantillas de los nodos procederemos a crear los nodos del cluster Kubernetes personalizados a la plantilla adecuada.
5.1. Generación de las credenciales de la API de Rancher
Para usar el paquete Rancher de Pulumi es necesario realizar un proceso de configuración de claves de la API de Rancher con el objetivo de poder crear y actualizar recursos de Rancher desde Pulumi. Las credenciales se obtendrán desde Rancher siguiendo estos pasos:
-
En el menú desplegable de usuario seleccionar
API & Keys
. -
En la pantalla
API & Keys
pulsar el botónAdd Key
. -
Dejar los valores predeterminados en el cuadro de diálogo.
-
Copiar los valores generados. Estos valores será la única vez que se muestran y no se podrán volver a recuperar.
Si se pierden estas credenciales habrá que volver a generar otras nuevas ya que no se podrá recuperar la contraseña generada (secret key). |
Una vez seguidos estos pasos habremos generado las credenciales de acceso para la interacción con Rancher a través de su API. Esto es lo que necesitábamos para poder gestionar recursos Rancher desde Pulumi.
5.2. Creación y configuración inicial del proyecto
Desde dentro de un directorio vacío creado para el proyecto crearemos el proyecto Pulumi con el comando pulumi new
. Como no hay plantillas definidas para Rancher, crearemos el proyecto con
$ pulumi new typescript
A continuación:
-
Aceptaremos el nombre del proyecto, cuyo valor predeterminado es el del directorio en el que se encuentra.
-
Completaremos la descripción con
Configuración de cluster K8s
. -
Aceptaremos el nombre del stack (dev).
Una vez aceptadas las opciones de creación del proyecto se instalarán las dependencias del proyecto y unos instantes después el proyecto estará listo para ejecutarse.
Como resultado tendremos un proyecto con la estructura siguiente:
├── .gitignore
├── index.ts (1)
├── package.json (2)
├── Pulumi.yaml (3)
└── tsconfig.json
1 | Archivo donde incluiremos los recursos a desplegar. |
2 | Archivo de dependencias. |
3 | Configuración del nombre y descripción del proyecto y runtime de ejecución |
5.3. Instalación del paquete Rancher2
Instalaremos el paquete Rancher2 para Pulumi con el comando
npm install @pulumi/rancher2
Esto actualizará el archivo de dependencias package.json
del proyecto.
5.4. Configuración de credenciales de acceso a Rancher desde Pulumi
Con los valores obtenidos en Generación de las credenciales de la API de Rancher hay que pasarlos a Pulumi. Siguiendo los pasos de configuración de credenciales de Rancher en Pulumi hay dos opciones:
-
Configurar las variables de entorno
RANCHER_URL
,RANCHER_ACCESS_KEY
yRANCHER_SECRET_KEY
. En nuestro caso sería
export RANCHER_URL=https://ranchitodesa.stic.ual.es/v3
export RANCHER_ACCESS_KEY=token-tj6vf
export RANCHER_SECRET_KEY=8pq6g2dpf7njgmncglqrsfggrbwx57.........
-
Establecer la configuración en el stack del proyecto para facilitar el trabajo colaborativo.
$ pulumi config set rancher2:apiUrl https://ranchitodesa.stic.ual.es/v3 $ pulumi config set rancher2:accessKey token-tj6vf --secret $ pulumi config set rancher2:secretKey 8pq6g2dpf7njgmncglqrsfggrbwx57......... --secret
Las credenciales configuradas no son enviadas a pulumi.com.
Si seguimos esta alternativa, tras hacer esta configuración se genera un archivo
Pulumi.dev.yaml
con la configuración realizada. Por motivos de seguridad, no se deberá enviar esta información de forma indiscriminada a repositorios públicos
5.5. Creación de las plantillas de los nodos
Como el cluster de Kubernetes que vamos a crear usa OpenStack como proveedor de infraestructura y cada instalación particular tiene sus propios valores, hay que crear una plantilla en la que se indiquen todos estos parámetros particulares, desde la URL, credenciales de acceso, y demás, hasta los datos particulares del proyecto del que se van a consumir los recursos, pasando por los datos de la imagen a utilizar para los nodos que usen la plantilla.
Las plantillas se crean con el recurso NodeTemplate
del módulo rancher2
. Basta con indicar un nombre para la plantilla y un JSON para las opciones. En nuestro caso incluiremos la descripción de la plantilla.
const sistemas_ssh_key = fs.readFileSync('/Users/manolo/.ssh/os-sistemas','utf8');
// Create a new rancher2 Large Ubuntu Node Template up to Rancher 2.1.x
const ubuntuLargeTemplate = new rancher2.NodeTemplate("ubuntu-18-04-large-pulumi", {
openstackConfig: {
authUrl: "http://openstack.stic.ual.es:5000/v3", (1)
availabilityZone: "nova", (2)
domainName: "default", (3)
endpointType: "publicURL",
flavorName: "large", (4)
floatingIpPool: "ual-net", (5)
imageName: "Ubuntu 18.04 LTS", (6)
keypairName: "os-sistemas", (7)
netName: "Sistemas-prod-net", (8)
password: "xxxx", (9)
privateKeyFile: fs.readFileSync('/Users/manolo/.ssh/os-sistemas', 'utf8'), (10)
region: "RegionOne", (11)
secGroups: "default", (12)
sshPort: "22", (13)
sshUser: "ubuntu", (14)
tenantName: "Sistemas-prod", (15)
username: "sistemas", (16)
userDataFile: fs.readFileSync('./ubuntu-node-setup.sh', 'utf8') (17)
},
description: "Ubuntu 18.04 LTS large Pulumi",
});
1 | URL de autenticación de la instalación particular de OpenStack |
2 | Nombre de la zona de disponibilidad |
3 | Nombre del dominio OpenStack |
4 | Nombre del sabor para las instancias que usen esta plantilla |
5 | Nombre de la red externa que propociona las direcciones IP flotantes a las instancias que usen esta plantilla |
6 | Nombre de la imagen a usar como base para las instancias que usen esta plantilla |
7 | Nombre del archivo de clave pública del usuario OpenStack para inyectar en las instancias que usen esta plantilla |
8 | Red del proyecto OpenStack a la que se conectarán las instancias que usen esta plantilla |
9 | Contraseña del usuario OpenStack |
10 | Ubicación del archivo de clave privada para poder interactuar con la instancia. Otra opción (menos segura) es pasar el contenido del archivo de clave privada. |
11 | Región OpenStack |
12 | Grupos de seguridad OpenStack aplicables a la instancia |
13 | Puerto de acceso SSH a la instancia creada |
14 | Nombre de usuario SSH de la imagen de sistema operativo utilizada en los nodos del cluster de Kubernetes |
15 | Nombre del proyecto que propociona los recursos al cluster de Kubernetes |
16 | Nombre de usuario OpenStack propietario del proyecto que propociona los recursos al cluster de Kubernetes |
17 | Opción de pasar un script para configurar la instancia (p.e. para hacer una configuración particular de seguridad de nuestra organización) |
Esto define una plantilla asignada a una constante ubuntuLargeTemplate
. Asignar el recurso creado a una constante o una variable permite manipularlo posteriormente. En nuestro caso se usará para la crear nodos del cluster de Kubernetes.
Análogamente crearemos una plantilla similar pero con un sabor medium
para nodos que necesiten menos recursos.
// Create a new rancher2 Medium Ubuntu Node Template up to Rancher 2.1.x
const ubuntuMediumTemplate = new rancher2.NodeTemplate("ubuntu-18-04-medium-pulumi", {
openstackConfig: {
authUrl: "http://openstack.stic.ual.es:5000/v3",
availabilityZone: "nova",
domainName: "default",
endpointType: "publicURL",
flavorName: "medium", (1)
floatingIpPool: "ual-net",
imageName: "Ubuntu 18.04 LTS",
keypairName: "os-sistemas",
netName: "Sistemas-prod-net",
password: "xxxx",
privateKeyFile: fs.readFileSync('/Users/manolo/.ssh/os-sistemas', 'utf8'),
region: "RegionOne",
secGroups: "default",
sshPort: "22",
sshUser: "ubuntu",
tenantName: "Sistemas-prod",
username: "sistemas",
userDataFile: fs.readFileSync('./ubuntu-node-setup.sh', 'utf8')
},
description: "Ubuntu 18.04 LTS medium Pulumi",
});
1 | Sabor medium para nodos menos exigentes |
5.6. Creación del cluster
Los clusters se crean con el recurso Cluster
del módulo rancher2
. Basta con indicar un nombre para el cluster y un JSON para las opciones. En nuestro caso incluiremos la descripción de la plantilla, y la configuración de RKE (Rancher Kubernetes Engine), la distribución de Kubernetes certificada por CNCF y que funciona sobre Docker. RKE facilita la creación del cluster de Kubernetes. En nuestro caso configuraremos el plugin de red y usaremos OpenStack como cloud provider. En la configuración de OpenStack hay que indicar valores relacionados con nombre de usuario, contraseña, URL, proyecto, red, y demás. En la documentación de Rancher OpenStack cloud provider se puede encontrar más información. También hay información de utilidad en Uso de OpenStack como proveedor de infraestructura en Rancher
A continuación se muestra el código para crear un cluster usando OpenStack como proveedor de infraestuctura
// Create a new rancher2 RKE Cluster
const cluster = new rancher2.Cluster("cluster-pulumi", {
description: "Cluster Pulumi Desa",
rkeConfig: {
network: {
plugin: "canal",
},
cloudProvider: {
name: "openstack",
openstackCloudProvider: {
blockStorage: {
ignoreVolumeAz: true,
trustDevicePath: false
},
global: {
authUrl: "http://openstack.stic.ual.es:5000/v3", (1)
domainName: "default", (2)
tenantName: "Sistemas-prod", (3)
password: "sistemas", (4)
username: "xxxx", (5)
},
loadBalancer: {
createMonitor: false,
floatingNetworkId: "30bf68df-xxxxxx", (6)
manageSecurityGroups: false,
monitorMaxRetries: 0,
subnetId: "aabe1065-xxxxxx", (7)
useOctavia: false
},
metadata: {
requestTimeout: 0
},
route: {}
}
},
},
clusterAuthEndpoint: {
enabled: true
}
});
1 | URL de autenticación |
2 | Dominio al que pertenece el usuario |
3 | Nombre del proyecto que proporciona la infraestructura |
4 | Contraseña |
5 | Nombre de usuario |
6 | ID de la red externa, la que proporciona las IPs flotantes |
7 | ID de la subred del proyecto que proporciona la infraestructura |
Los id de redes y subredes se encuntran en el menú |
5.7. Creación de los grupos de nodos (node pools) del cluster
Los clusters están formados por grupos de nodos (node pools). Un grupo de nodos es un conjunto de nodos definidos de acuerdo con una plantilla y puede tener uno o varios de estos roles: etcd, control y worker.
En nuestro caso crearemos un grupo de nodos para cada función y tendremos las funciones separadas en grupos de nodos diferentes.
Los grupos de nodos se crean con el recurso NodePool
del módulo rancher2
. Basta con indicar un nombre para el grupo de nodos y un JSON para las opciones. En nuestro caso incluiremos el cluster al que se aplican, el prefijo que se usará para asignarle nombre a las instancias del grupo de nodos en OpenStack, la plantilla OpenStack a utilizar, la cantidad de nodos que tendrá el grupo y los roles activados (etcd, control y worker). De forma predeterminada, los roles están desactivados.
A continuación se muestra el código de los tres grupos de nodos a configurar para el cluster creado anteriormente. Cada grupo de nodos se corresponde con cada uno de los roles (etcd, control y worker). Como es sólo para un ejemplo, a cada grupo de nodos sólo se le define un nodo (quantity: 1
).
// Create a new control rancher2 Node Pool (1)
const controlNodePool = new rancher2.NodePool("control-node-pool-pulumi-desa", {
clusterId: cluster.id, (2)
hostnamePrefix: "control-pulumi-desa", (3)
nodeTemplateId: ubuntuLargeTemplate.id, (4)
quantity: 1, (5)
controlPlane: true, (6)
});
// Create a new etcd rancher2 Node Pool (7)
const etcdNodePool = new rancher2.NodePool("etcd-node-pool-pulumi-desa", {
clusterId: cluster.id,
hostnamePrefix: "etcd-pulumi-desa",
nodeTemplateId: ubuntuMediumTemplate.id, (8)
quantity: 1,
etcd: true (9)
});
// Create a new worker rancher2 Node Pool (10)
const workerNodePool = new rancher2.NodePool("worker-node-pool-pulumi-desa", {
clusterId: cluster.id,
hostnamePrefix: "worker-pulumi-desa",
nodeTemplateId: ubuntuLargeTemplate.id, (11)
quantity: 1,
worker: true (12)
});
1 | Grupo de nodos del rol control |
2 | Id del grupo al que pertence el grupo de nodos |
3 | Prefijo para las máquinas virtuales correspondientes a los nodos del grupo |
4 | Plantilla large en los nodos del grupo control |
5 | 1 nodo en el grupo |
6 | Activación del rol control en el grupo de nodos control |
7 | Grupo de nodos del rol etcd |
8 | Plantilla medium en los nodos del grupo etcd |
9 | Activación del rol etcd en el grupo de nodos etcd |
10 | Grupo de nodos del rol worker |
11 | Plantilla large en los nodos del grupo worker |
12 | Activación del rol worker en el grupo de nodos worker |
6. Eliminación de un proyecto
La eliminación de los recursos de un proyecto se realiza con el comando pulumi destroy
.
El comando |
La eliminación de los recursos de un proyecto con pulumi destroy
elimina los recursos pero se sigue conservando el historial de operaciones y la configuración del stack. Para eliminar el stack totalmente, y no sólo sus recursos, ejecutar el comando siguiente
$ pulumi stack rm <<nombre-del-stack (p.e. dev)>>
7. Solución de problemas en despliegues corruptos
Si mientras se realiza un despliegue se cancela antes de que finalice aparecerá un mensaje que indica que el despligue tiene recursos con operaciones pendientes. Como motivos de la cancelación podemos tener cancelaciones del proceso por parte del usuario, cortes en la red o un error de ejecución en el CLI de Pulumi.
Como Pulumi no tiene forma de conocer si una operación que ha iniciado se ha llevado a cabo correctamente o ha fallado, esto se traduce en que pueden haberse creado recursos y que Pulumi no tenga conocimiento. Por ello, lo siguiente que se debe hace es cancelar el despliegue con el comando siguiente
$ pulumi cancel
Tras esto, se debe exportar y volver a importar el stack
$ pulumi stack export | pulumi stack import
Para actualizar el estado el stack hay que ejecutar el comando
$ pulumi refresh
7.1. Restaurar a partir de un estado corrupto
A veces, la única forma de restaurar un despliegue corrupto es revisar el archivo de despliegue y ver las operaciones que se han quedado pendientes. Para ello, primero exportaremos a un archivo (p.e. state.json
) el estado del despliegue con el comando siguiente:
$ pulumi stack export --file state.json
Seguir estos pasos:
-
Ir a la sección
pending_operations
del archivo de despliegue (p.e.state.json
) y eliminar dichos recursos de la infraestructura en el caso de que hubiesen sido creados y que Pulumi no tuviese conocimiento. -
Editar ese archivo y eliminar el contenido del array
pending_operations
dejando el array vacío. Guardar los cambios en el archivo. -
Volver a importar en el que ya no aparecen operaciones pendientes
$ pulumi stack import --file state.json
Para más información sobre solución de errores consultar las secciones Recovering from an Interrupted Update y Manually Editing Your Deployment de la guía de solución de errores de Pulumi. |