Terraform und Ansible - ein starkes Gespann


Einführung

Terraform (IaaS - Infrastructure as a Service) und Ansible (CaaS - Configuration as a Service) bilden zusammen ein starkes Gespann, um schnell konfigurierte Ressourcen auf der Microsoft Azure Cloud zu provisionieren. In meinen bisherigen Blogartikeln habe ich bisher Terraform vorgestellt




und zwar wie mit Terraform effizient Ressourcen aus Azure DevOps (https://dev.azure.com) provisioniert werden können. Während sich Terraform vor allem im Bereich "Ressourcen-Deployment" etabliert hat, scheint sich Ansible im Bereich "Configuration as a Service" zu etablieren.

In diesem Artikel präsentiere ich ein Beispiel, wie mit Terraform Ansible Code - nach der Provisionierung des Servers - auf dem Server über einen SSH Tunnel ausgeführt werden kann. Nach Beendigung des Skripts, kann mittels eines VNC-Viewers auf den provisionierten Server in der Azure Cloud zugegriffen werden. 

Obschon das Beispiel trivial ist, zeigt es doch wie Terraform und Ansible zusammen ein starkes Gespann bilden können, um beispielsweise innert Minuten eine vollständigen Webserver in der Azure Cloud zu deployen. Natürlich ist hier der Einwand berechtigt, dass Azure inzwischen andere, bessere Möglichkeiten vorsieht dies zu tun. Im Sinne einer transparenten Kostenbetrachtung ist es aber dennoch manchmal von Nutzen, eine Lösung wie sie im folgenden vorgestellt wird zu wählen.

Das Beispiel

Im Beispiel erstellen wir auf der Azure Cloud Plattform einen Linux Server (Ubuntu) mittels Terraform (hier noch Terraform 11). Nach der Erstellung der virtuellen Maschine - des Linux Servers - schreiben wir in eine Datei die öffentliche IP Adresse der VM und zusätzliche Konfigurationsparameter für Ansible. Wir führen dann mittels einer "Null-Ressource" Ansible als lokale Anwendung innerhalb Terraform aus. Dabei müssen wir stark darauf achten, dass die Ressourcen auch tatsächlich bei Ausführung von Ansible schon real vorhanden sind. Ansonsten läuft das Skript nicht durch.

Ressourcen mit Terraform generieren

Alle im nachfolgenden vorgestellten Codebeispiele befinden sich in einer Datei main.tf.


provider "azurerm" {
   subscription_id = "your_subscription_id"
   client_id       = "your_client_id"
   client_secret   = "your_client_secret"
   tenant_id       = "your_tenant_id"
}


# Create a resource group if it doesn’t exist
resource "azurerm_resource_group" "myterraformgroup" {
    name     = "myResourceGroup"
    location = "eastus"
}

# Create virtual network
resource "azurerm_virtual_network" "myterraformnetwork" {
    name                = "myVnet"
    address_space       = ["10.0.0.0/16"]
    location            = "eastus"
    resource_group_name = "${azurerm_resource_group.myterraformgroup.name}"
}

# Create subnet
resource "azurerm_subnet" "myterraformsubnet" {
    name                 = "mySubnet"
    resource_group_name  = "${azurerm_resource_group.myterraformgroup.name}"
    virtual_network_name = "${azurerm_virtual_network.myterraformnetwork.name}"
    address_prefix       = "10.0.1.0/24"
    depends_on = ["azurerm_virtual_network.myterraformnetwork"]
}

# Create public IPs
resource "azurerm_public_ip" "myterraformpublicip" {
    name                         = "myPublicIP"
    location                     = "eastus"
    resource_group_name          = "${azurerm_resource_group.myterraformgroup.name}"
    allocation_method   = "Dynamic"
}

# Create Network Security Group and rule
resource "azurerm_network_security_group" "myterraformnsg" {
    name                = "myNetworkSecurityGroup"
    location            = "eastus"
    resource_group_name = "${azurerm_resource_group.myterraformgroup.name}"

    security_rule {
        name                       = "SSH"
        priority                   = 1001
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_range     = "22"
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }

     security_rule {
        name                       = "VNC"
        priority                   = 1500
        direction                  = "Inbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_ranges    = ["5900","5901","5902","5903"]
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }

    security_rule {
        name                       = "VNC_out"
        priority                   = 1500
        direction                  = "Outbound"
        access                     = "Allow"
        protocol                   = "Tcp"
        source_port_range          = "*"
        destination_port_range     = "5900"
        source_address_prefix      = "*"
        destination_address_prefix = "*"
    }
}

# Create network interface
resource "azurerm_network_interface" "myterraformnic" {
    name                      = "myNIC"
    location                  = "eastus"
    resource_group_name       = "${azurerm_resource_group.myterraformgroup.name}"
    network_security_group_id = "${azurerm_network_security_group.myterraformnsg.id}"

    ip_configuration {
        name                          = "myNicConfiguration"
        subnet_id                     = "${azurerm_subnet.myterraformsubnet.id}"
        private_ip_address_allocation = "dynamic"
        public_ip_address_id          = "${azurerm_public_ip.myterraformpublicip.id}"
    }
    depends_on = ["azurerm_network_security_group.myterraformnsg", "azurerm_public_ip.myterraformpublicip"]
}

Ganz oben im Code wird der Provider definiert, wo die Ressourcen deployt werden. Der Code ist so konzipiert, dass er aus der Azure Cloud Shell heraus läuft. Ich werde später noch darauf eingehen, welche Dateien in die Shell hochgeladen werden müssen, damit das Beispiel läuft. Anschliessend definieren wir eine Ressourcengruppe, die als Container für alle anderen Terraform Elemente dient. In die Ressourcengruppe legen wir folgende Elemente: Ein virtuelles Netzwerk, ein Subnet, eine zu generierende öffentliche IP und eine NSG. In der NSG definieren wir die Firewall-Rules, also den möglichen Zugriff auf die später zu erzeugende virtuelle Maschine. Wir öffnen den Port 22 (SSH) und einige VNC Ports für den VNC Zugriff auf den Rechner. In einem nächsten Schritt (immer alles noch in der Datei main.tf) definieren wir einen Zufallsnamen für den Storage Account (dieser muss weltweit eindeutig sein):

# Generate random text for a unique storage account name
resource "random_id" "randomId" {
    keepers = {
        # Generate a new ID only when a new resource group is defined
        resource_group = "${azurerm_resource_group.myterraformgroup.name}"
    }

    byte_length = 8
}

# Create storage account for boot diagnostics
resource "azurerm_storage_account" "mystorageaccount" {
    name                        = "${random_id.randomId.hex}"
    resource_group_name         = "${azurerm_resource_group.myterraformgroup.name}"
    location                    = "eastus"
    account_tier                = "Standard"
    account_replication_type    = "LRS"
    depends_on = ["azurerm_network_interface.myterraformnic"]
}

Danach können wir die virtuelle Maschine definieren:



# Create virtual machine
resource "azurerm_virtual_machine" "myterraformvm" {
    name                  = "myVM"
    location              = "eastus"
    resource_group_name   = "${azurerm_resource_group.myterraformgroup.name}"
    network_interface_ids = ["${azurerm_network_interface.myterraformnic.id}"]
    vm_size               = "Standard_DS1_v2"

    storage_os_disk {
        name              = "myOsDisk"
        caching           = "ReadWrite"
        create_option     = "FromImage"
        managed_disk_type = "Premium_LRS"
    }

    storage_image_reference {
        publisher = "Canonical"
        offer     = "UbuntuServer"
        sku       = "16.04-LTS"
        version   = "latest"
    }

    os_profile {
        computer_name  = "myvm"
        admin_username = "azureuser"
        admin_password = "Password1234!"
    }

    os_profile_linux_config {
        disable_password_authentication = false
    }

    boot_diagnostics {
        enabled = "true"
        storage_uri = "${azurerm_storage_account.mystorageaccount.primary_blob_endpoint}"
    }
    depends_on = ["azurerm_storage_account.mystorageaccount", "azurerm_network_interface.myterraformnic"]
}

In einem nächsten Schritt fragen wir die öffentliche IP ab und erzeugen das Repository für Ansible mit Terraform:


data "azurerm_public_ip" "test" {
  name                = "myPublicIP"
  resource_group_name = "${azurerm_resource_group.myterraformgroup.name}"
 
  depends_on = ["azurerm_resource_group.myterraformgroup", "azurerm_public_ip.myterraformpublicip","azurerm_virtual_machine.myterraformvm"]
}


data "template_file" "ansible-setup" {  
   
  template  = "[test]\n$${ip}\n[test:vars]\n$${connection}\n$${user}\n$${ssh}\n$${args}\n"
  
  vars = {
    ip = "${data.azurerm_public_ip.test.ip_address}"
    connection = "ansible_connection=ssh"
    user= "ansible_user=azureuser"
    ssh = "ansible_ssh_pass=Password1234!"
    #interpreter = "ansible_python_interpreter=/usr/bin/python3"
    args ="ansible_ssh_common_args='-o StrictHostKeyChecking=no'"
  }
  depends_on=["azurerm_virtual_machine.myterraformvm","data.azurerm_public_ip.test"]
}

Das Template müssen wir noch als Datei rendern:


resource "local_file" "foo" {

    content     = "${data.template_file.ansible-setup.rendered}"
    filename =  "inventory.txt"
    depends_on = ["data.template_file.ansible-setup"]
}

Abhängigkeiten 

Folgendes muss noch eingefügt werden, wenn das Beispiel mit einer Pipeline über Azure DevOps ausgeführt wird:


resource "null_resource" "install-deps" {
  provisioner "local-exec" {
    working_dir = "."
    command = "sudo apt-get install sshpass"
  }
  depends_on= ["local_file.foo"]
}

Wird das Beispiel aus der Cloud-Shell deployt ist obiger Schritt nicht notwendig. Dafür muss SSHPass auf der Cloud Shell installiert werden. Das ist nur mit einigem Zusatzaufwand möglich:

1) Verbinden auf die Cloud-Shell:

2) Erstellen eines Verzeichnisses sshpass (mkdir sshpass)

3) Installieren von sshpass mittels wget:

wget https://downloads.sourceforge.net/project/sshpass/sshpass/1.06/sshpass-1.06.tar.gz

4)  Un-taren:

tar zxvf sshpass-1.06.tar.g

5) Ins Verzeichnis wechseln, wo sshpass un-tared wurde

6) ./configure ausführen

7) make

8) ./sshpass kann nun ausgeführt werden, jedoch wollen wir es in der Pfad Variable:

9) Modifikation PATH Env. Variable: PATH=$HOME/pfad zu sshpass:$PATH

sshpass kann so installiert werden. Die Vorgehensweise ist aber sicherlich nicht ganz sauber!

Ansible ausführen


resource "null_resource" "run_ansible" {
  provisioner "local-exec" {
    working_dir = "."
    command = "ansible-playbook -i inventory.txt test_playbook.yml"
  }
  depends_on= ["null_resource.install-deps"]
}

Ansible konfigurieren

Bevor wir das Beispiel mit Terraform ausführen können, müssen wir noch das Ansible Playbook verfassen. Dies tun wir im folgenden. 

Hinweis: Da ich kein Ansible Profi bin, gibt es sicherlich bessere Varianten, dass Playbook zu schreiben:

Der folgende Inhalt kommt in eine Datei test_playbook.yml

- hosts: all
  tasks:

- name: apt install
      become: yes
      apt:
        name: "{{ item.name }}"
        state: "{{ item.state }}"
      with_items:
        - { state: "latest", name: "xfce4" }


    - name: Install the GUI and VNC Packages
      become: yes
      apt:
         name: "{{ item.name }}"
         state: "{{ item.state }}"
      with_items:
         - { state: "latest", name: "vnc4server"}

    - name: Install Dos 2 Unix converter
      become: yes
      apt:
         name: "{{ item.name }}"
         state: "{{ item.state }}"
      with_items:
         - { state: "latest", name: "dos2unix"}

    - name: Set the vnc-password
      shell: 'rm mypasswd.txt | vncpasswd < mypasswd.txt | echo "mypass" >> mypasswd.txt | echo "mypass" >> mypasswd.txt'
    
    - name: Copy the vncservers.conf file in /etc/ directory
      become: yes
      template:
        src: vncserver.conf
        dest: /etc/vncservers.conf
        owner: root
        group: root

    - name: Copy the modified "xstartup" file
      template:
        src: xstartup.txt
        dest: .vnc/xstartup
        mode: 0755

    - name: Move vncserver script to init.d
      become: yes
      template:
        src: vncserver
        dest: /etc/init.d/vncserver
        owner: root
        group: root
        mode: 0755
      register: vnc_service
   
    - name: Convert vncserver script to unix format
      become: yes
      shell: 'dos2unix /etc/init.d/vncserver'

    - name: Convert vncserver xstartup to unix format
      become: yes
      shell: 'dos2unix .vnc/xstartup'

    - name: Add vncserver service to default runlevels
      become: yes
      command: "update-rc.d vncserver defaults"
      when: vnc_service.changed

    - name: Restart VNC Service
      become: yes
      service:
        name: vncserver
        pattern: /etc/init.d/vncserver
        state: restarted

Beschreibung der einzelnen Schritte

GDM installieren

In einem ersten Schritt installieren wir XFCE als leichtgewichtigen Window Manager unter Linux. In diesem Schritt können auch andere Windows Manager installiert werden. Allerdings muss darauf geachtet werden, dass der Window Manager "headless", das heisst ohne Monitor betrieben werden kann:


    - name: apt install
      become: yes
      apt:
        name: "{{ item.name }}"
        state: "{{ item.state }}"
      with_items:
        - { state: "latest", name: "xfce4" }

VNC Server installieren

    - name: Install the GUI and VNC Packages
      become: yes
      apt:
         name: "{{ item.name }}"
         state: "{{ item.state }}"
      with_items:
         - { state: "latest", name: "vnc4server"}

Optional Dos2Unix Konverter

Manchmal gibt es Probleme bei Dateien, die unter Windows erfasst werden unter Linux. Dem kann Abhilfe geschaffen werden mit dos2unix.


    - name: Install Dos 2 Unix converter
      become: yes
      apt:
         name: "{{ item.name }}"
         state: "{{ item.state }}"
      with_items:
         - { state: "latest", name: "dos2unix"}

VNC Passwort setzen

!! Dies sollte definitiv in einer produktiven Umgebung nicht so gemacht werden!!

- name: Set the vnc-password
      shell: 'rm mypasswd.txt | vncpasswd < mypasswd.txt | echo "mypass" >> mypasswd.txt | echo "mypass" >> mypasswd.txt'

Kopieren von Konfigurationsdateien

Damit das ganze artig läuft, müssen wir einige Konfigurationsdateien für den VNC Server rüber auf die frisch deployte VM kopieren:

    - name: Copy the vncservers.conf file in /etc/ directory
      become: yes
      template:
        src: vncserver.conf
        dest: /etc/vncservers.conf
        owner: root
        group: root

    - name: Copy the modified "xstartup" file
      template:
        src: xstartup.txt
        dest: .vnc/xstartup
        mode: 0755

Der Inhalt der Dateien ist unten angegeben:

vncservers.conf:

<<to be continued>>

Init.d initialisieren

Hier muss init.d so initialisiert werden, so dass bei Neustart der VM, der VNC Server automatisch wieder gestartet wird:

- name: Move vncserver script to init.d
      become: yes
      template:
        src: vncserver
        dest: /etc/init.d/vncserver
        owner: root
        group: root
        mode: 0755
      register: vnc_service
   - name: Convert vncserver script to unix format
      become: yes
      shell: 'dos2unix /etc/init.d/vncserver'

    - name: Convert vncserver xstartup to unix format
      become: yes
      shell: 'dos2unix .vnc/xstartup'

    - name: Add vncserver service to default runlevels
      become: yes
      command: "update-rc.d vncserver defaults"
      when: vnc_service.changed
Und am Schluss den VNC Server neu starten:


    - name: Restart VNC Service
      become: yes
      service:
        name: vncserver
        pattern: /etc/init.d/vncserver
        state: restarted

Wenn das ganze durchläuft, so kann man sich dann bequem via zum Beispiel Ultra-VNC Viewer auf einen Desktop in Azure verbinden.











Keine Kommentare:

Kommentar veröffentlichen