Prasanth Janardhanan

Creating an isolated cluster - provisioning a cluster and a Bastion host using Ansible

This is the second part of the series on setting up a cluster using Terraform and Ansible. In the first part, we had set up the bare cluster Virtual Machines. The set up included a virtual private network, a bastion server, and a separately configurable cluster of nodes.

The nodes are ready but not software or configuration is done so far. In the next step, we will provision all the nodes in our cloud

Provisioning Bastion Server using Ansible

Checkout the Ansible playbook in the folder 2-provision/bastion folder The first step we do is to configure the system:

---
- name: Set timezone to Etc/UTC
  timezone:
    name: Etc/UTC

# Install Packages
- name: Update apt
  apt: update_cache=yes

- name: Install required system packages
  apt: name={{ item }} state=latest update_cache=yes force_apt_get=yes
  loop: [ 'curl', 'vim', 'ufw']

- name: Should be able to add local hosts in /etc/hosts
  lineinfile:
    path: /etc/cloud/cloud.cfg
    regexp: '^manage_etc_hosts:'
    line: manage_etc_hosts:false
  
- name: Update system
  package:
    name: "*"
    update_cache: yes
    state: latest
  register: system_updated

First, we set up the timezone to be UTC. Then we install or upgrade some system packages. We update the file /etc/cloud/cloud.cfg to add a configuration to be able to edit the etc/hosts file. We will be updating the hosts file for accessing the nodes in the cluster easily using names like node1, node2 etc

User setup

The next step is to configure user login and ssh. See the role users in the folder 2-provision/roles/users. We create a ‘wheel’ group with sudo permissions. Then we disable root login. Note that this is just one way of configuring user access and ssh configuration. Depending on your requirements and configuration you can customize the user login setup by adding stricter and tighter SSH login restrictions.

# Sudo Group Setup
- name: Make sure we have a 'wheel' group
  group:
    name: wheel
    state: present

- name: Allow 'wheel' group to have passwordless sudo
  lineinfile:
    path: /etc/sudoers
    state: present
    regexp: '^%wheel'
    line: '%wheel ALL=(ALL) NOPASSWD: ALL'
    validate: '/usr/sbin/visudo -cf %s'
    
- name: Create a new user
  user:
    name: "{{ deploy_user_name }}"
    state: present
    groups: wheel
    append: true
    create_home: true
    shell: /bin/bash
        
- name: Set authorized key for remote user
  authorized_key:
    user: "{{ deploy_user_name }}"
    state: present
    key: "{{ lookup('file', deploy_user_key_path ) }}"
  
- name: Disable password login
  lineinfile: 
    path: /etc/ssh/sshd_config 
    regexp: '^(#\s*)?PasswordAuthentication '
    line: 'PasswordAuthentication no'
  notify: Restart sshd
  
- name: Disable root login
  lineinfile:
    path: /etc/ssh/sshd_config
    state: present
    regexp: '^#?PermitRootLogin'
    line: 'PermitRootLogin no'
  notify: Restart sshd

Configuring node communications

The next few steps are to configure the SSH communication with the nodes in the cluster a bit easier. We will assign names to the nodes in the cluster and use the private IP address to communicate between the nodes.

    - name: Add IP address of all hosts to hosts file
      lineinfile:
        dest: /etc/hosts
        regexp: '.*{{ item.name }}$'
        line: "{{ item.ip }}  {{item.name}}"
        state: present
      with_items:
        - name: node1
          ip: "10.0.0.5"
        - name: node2
          ip: "10.0.0.6"
        - name: node3
          ip: "10.0.0.7"   
        - name: node4
          ip: "10.0.0.15"   
        - name: node5
          ip: "10.0.0.16"
        - name: node6
          ip: "10.0.0.17"
                    
    - name: Copy keys used to connect to nodes
      copy:
        src: "keys/{{item}}"
        dest: "/home/{{ deploy_user_name }}/.ssh/{{item}}"
        mode: "600"
        owner: "{{ deploy_user_name }}"
        group: "{{ deploy_user_name }}"
      with_items:
        - nodes
        - nodes.pub
        
    - name: Make sure ssh/config file exists
      file:
         path: "/home/{{ deploy_user_name }}/.ssh/config"
         state: touch        
         
    - name: Setup SSH config file for nodes
      blockinfile: 
         path: "/home/{{ deploy_user_name }}/.ssh/config"
         marker: "# {mark} Added through ansible scripts {{item}}"
         block:  |
            Host {{item}}
                IdentityFile ~/.ssh/nodes
                User {{ deploy_user_name }}            
      loop:
         - node1
         - node2
         - node3
         - node4
         - node5
         - node6
         

Installing tools

Next, we install tools required for running the cluster. For example, it will be handy to have kubectl and helm on the bastion host. So we install these tools.

Next, we install and configure an NFS share on the bastion host. This is optional. However, this will be handy to have a network share accessible from all nodes of the cluster.

Firewall setup

We will use ufw for firewall. First, install ufw then allow only SSH incoming. We also open the NFS port only to the private network.

    - name: Install ufw
      apt: 
        name: ufw
        state: latest
        
    - name: ufw allow ssh
      ufw:
        rule: allow
        name: OpenSSH
        
    - name: ufw allow NFS
      ufw:
        rule: allow
        port: "2049"
        src: "10.0.0.0/16"
        
    - name: Enable UFW
      ufw:
          state: enabled

Provisioning the Bastion server

Now that we have the scripts ready, let us run Ansible to configure the servers.

First, you need to generate SSH keys for setting up a login on the bastion host Let’s name it mycluster-bastion. Then create SSH keys for the nodes. Keep the node keys(private and public) in the folder 2-provision/bastion/keys

Get the IP address of the bastion host to node.txt Then run the Ansible playbook

ansible-playbook bastion.yaml -i node.txt --user='root' --key-file="~/.ssh/tcloud" --ssh-extra-args='-p 22 -o ConnectTimeout=10 -o ConnectionAttempts=10 -o StrictHostKeyChecking=no' --extra-vars="deploy_user_name=nodeuser deploy_user_key_path=~/.ssh/mycluster-bastion.pub"

All these steps can be automated in the run.sh bash script like this:

function ProvisionBase(){
   cd ./1-infra/mycluster/base
   IP=$(terraform show | egrep bastion_ip | cut -d'"' -f 2 )
   echo "IP is $IP"
   cd ../../../2-provision/bastion 
   printf "$IP\n" > node.txt
   
   ANSIBLE_FORCE_COLOR=true 
   ansible-playbook bastion.yaml -i node.txt --user='root' --key-file="~/.ssh/tcloud" --ssh-extra-args='-p 22 -o ConnectTimeout=10 -o ConnectionAttempts=10 -o StrictHostKeyChecking=no' --extra-vars="deploy_user_name=nodeuser deploy_user_key_path=~/.ssh/mycluster-bastion.pub"
   
}

The script can be run like this:

./run.sh provision base

Provisioning the cluster nodes

We run the basic system setup on the cluster nodes as well using the same roles used for provisioning the bastion server. Then we setup SSH and user rights using the users role.

Installing essential software on the nodes

- name: post build setup - nodes
  hosts: all
  become: true
  tasks:
    - name: Initial System Setup
      include_role:
         name: ../roles/system
    - name: Users setup
      include_role:
         name: ../roles/users  
         
    - name: Install docker
      include_role:
         name: geerlingguy.docker 
      vars: 
         docker_users: 
            - "{{ deploy_user_name }}"      
                   
    - name: Set authorized key taken from file
      ansible.posix.authorized_key:
        user: myadmin
        state: present
        key: "{{ lookup('file', '../bastion/keys/nodes.pub') }}"
    - name: Enable UFW firewall
      include_role:
        name: ../roles/ufw    

The nodes require only a few software installed. We intend to build a Kubernetes cluster. We intend to use Docker for the virtualization layer. So we install docker. Then we copy the SSH keys. Finally, we install and configure UFW firewall.

The firewall is configured to disallow communications from the public network. All communications to the cluster nodes are through the private network. See the UFW rules configured using Ansible:

- name: make sure ufw is installed
  apt: 
    name: ufw
    state: latest
    
- name: disable all incoming on eth0
  ufw:
     rule: reject
     direction: in
     interface: eth0

- name: allow all from internal network
  ufw:
     rule: allow
     from_ip: "10.0.0.0/16"
     to_ip: any
     
- name: Enable UFW
  ufw:
    state: enabled

Now that the script is ready, we can run Ansible playbook.

First, create SSH key for the node user mycluster-nodes. First, get the public IP addresses of the nodes to a cluster.txt inventory file. Then run the command

cd ./2-provision/cluster

ansible-playbook nodes.yaml -i cluster.txt --user='root' --key-file="~/.ssh/tcloud" --ssh-extra-args='-p 22 -o ConnectTimeout=10 -o ConnectionAttempts=10 -o StrictHostKeyChecking=no' --extra-vars="deploy_user_name=nodeuser deploy_user_key_path=~/.ssh/mycluster-nodes.pub"

Here is the bash script function that does the same:

function provisionCluster(){
   NAME=$1
   cd "./1-infra/mycluster/$NAME"
   NODES=$(terraform show | egrep ipv4_address | cut -d'"' -f 2 | sort -u)
   cd ../../../2-provision/cluster
   printf "$NODES\n" > cluster.txt
   ANSIBLE_FORCE_COLOR=true 
   
   ansible-playbook nodes.yaml -i cluster.txt --user='root' --key-file="~/.ssh/tcloud" --ssh-extra-args='-p 22 -o ConnectTimeout=10 -o ConnectionAttempts=10 -o StrictHostKeyChecking=no' --extra-vars="deploy_user_name=nodeuser deploy_user_key_path=~/.ssh/mycluster-nodes.pub"
   
}

Once the command finishes the cluster nodes are provisioned.

In order to test whether the setup is working, first SSH to the Bastion host Then from the bastion host, ssh to the nodes in the cluster. Add the IP address of the bastion host to your /etc/hosts file

ssh -i ~/.ssh/mycluster-bastion nodeuser@bastion

ssh nodeuser@node1

If you could get to node1 through SSH, the setup is working as expected. We can proceed to install Kubernetes on this cluster.

Similarly, you can try logging into node2 and node3.

SSH to the nodes through the bastion host

You can configure SSH on your local laptop to connect to the node through the bastion host. Here is a sample ssh config file (place the file in ~/.ssh/config )

Host bastion
  User nodeuser
  IdentityFile ~/.ssh/mycluster-bastion
  
Host node1
  IdentityFile ~/.ssh/mycluster-nodes
  ProxyCommand ssh nodeuser@bastion -W %h:%p
  

Now you can access node1 directly from your local laptop:

ssh nodeuser@node1