In this guide, I will show you how to automate the deployment of k8s on a XCP-NG server. We will use Terraform and Ansible to deploy and configure the hosts.
This guide still largely applies to other distributions of Linux, the main section you would have to change is the adding the repos and installing the packages. You will need to find the repos and the package names might be slightly different. If you search for Ansible repo/package management for your distribution, you should find it with little issue.
Terraform
Terraform has a built-in module for XCP-NG. A prerequisite to this is you need to install Rocky-Linux to a VM manually and then convert it to a template. When creating the template you will need to install the guest-tools as that will be required to get information from the host later.
You will also need a working Xen Orchestra instance for this to work.
- First, we need to set up the provider. Create
provider.tf
terraform {
required_providers {
xenorchestra = {
source = "terra-farm/xenorchestra"
}
}
}
It’s now time to download the module. Run: terraform init
. The output should look a little like this:
- We now need to setup the data vars in
vm.tf
data "xenorchestra_pool" "pool" {
name_label = "HOSTNAME"
}
data "xenorchestra_template" "rocky_template" {
name_label = "TEMPLATE NAME"
}
data "xenorchestra_sr" "sr" {
name_label = "STORAGE NAME"
pool_id = data.xenorchestra_pool.pool.id
}
data "xenorchestra_network" "network" {
name_label = "Pool-wide network associated with XXX"
pool_id = data.xenorchestra_pool.pool.id
}
Replace the temp values with your values. The name_label
can be a value with spaces in. I got confused when it came to storage, as “Local Storage” didn’t seem like it would be a valid input and expected XCP-NG to have some other value. Nevertheless, it was correct.
We now want to test this, but we need to provide credentials. I use a .env
file in my Terraform directory. The contents of it is:
export XOA_URL=ws://XEN_ORCHESTRA_IP
export XOA_USER=USERNAME
export XOA_PASSWORD=PASSWORD
To add these values to your shell, just run eval $(cat .env)
. There is probably a better way to do this, but for now this will do.
You can now run terraform plan
and you should see something like this:
- If all is working, we can now get to the juicy part… creating the VMs. We want to start simple and add complexity as we go. Let’s start with:
resource "xenorchestra_vm" "k8s_master" {
memory_max = 8589869056
cpus = 2
name_label = "K8S Master Node"
template = data.xenorchestra_template.rocky_template.id
network {
network_id = data.xenorchestra_network.network.id
}
disk {
sr_id = data.xenorchestra_sr.sr.id
name_label = "k8s master node disk"
size = 50212254720
}
}
This is a supper simple VM setup.
resource
is how you define a VM you want to create.memory_max
is how you define the max amount of ram you want the host to use.cpus
is how many cores you want.name_label
is just a label for the VM.template
is the template you want the VM to be created from.network
will create the interface withnetwork_id
being the nic we want to use, which is what we defined earlier in the data section.
disk
is the last part we define.sr_id
is the storage pool we defined in the data section.name_label
is the name of the disk.
size
is how large we want the disk.
You can use what ever values you want here, but it is strongly recommended that you have at least 2 CPU cores and 8GB of ram.
We can now run terraform plan
to see what it will create. If all went according to plan you can now run terraform apply
and it should go ahead and create the VMs for you.
- It’s now time to create the worker nodes. We will want to copy the master node twice and change some values. This is for worker node one.
resource "xenorchestra_vm" "k8s_worker_node_1" {
memory_max = 8589869056
cpus = 2
name_label = "K8S Worker Node 1"
template = data.xenorchestra_template.rocky_template.id
network {
network_id = data.xenorchestra_network.network.id
}
disk {
sr_id = data.xenorchestra_sr.sr.id
name_label = "k8s worker node 1 disk"
size = 50212254720
}
}
Now you have two worker nodes, you can run terraform plan
again. It is a good idea to always run plan before so you know what is going to happen, though apply dose give you a heads-up. If all is good, and it says it’s going to create two more VMs, you can run terraform apply
. If it is getting confused you can always run terraform destroy
to get back to the beginning, this isn’t an issue as we have nothing running yet.
If you are creating multiple of the same VMs you can use count
but we will not go into that today.
- Awesome! Three VMs! All done with terraform, right? No, one thing that will make figuring out which host is which is setting the hostname. Terraform has something for that, it’s call
provisioner
usingremote-exec
. You will want to add something like this to each resource:
connection {
type = "ssh"
user = "USER"
password = "PASS"
host = self.ipv4_addresses[0]
}
provisioner "remote-exec" {
inline = ["sudo hostnamectl set-hostname HOSTNAME.local"]
}
You will also want to put wait_for_ip = true
somewhere near the top of the resource block, otherwise terraform will try and fail before the VM has an IP.
To be clear, putting credentials into your terraform file isn’t the best idea. I want to limit the scope of this, so I have decided not to go through best practices.
Now we really are all good to go. If you have already run terraform apply
would run terraform destroy
and then terraform apply
, just so we can ensure that it works from start to end.
After I have run it you should see it saying it has created 3 resources.
When I go to my dashboard now, I see the new VMs we created:
Ansible
Now we move over to setting up the host with Ansible. There is lots to define, so let’s get cracking.
All Hosts
- First, let’s create a hosts file. If you go into your XCP-NG, pull out the IPs and place them into here:
[k8s_all]
k8smaster.local ansible_ssh_host=X.X.X.X
k8sworker1.local ansible_ssh_host=X.X.X.X
k8sworker2.local ansible_ssh_host=X.X.X.X
[k8s_master]
k8smaster.local ansible_ssh_host=X.X.X.X
[k8s_worker]
k8sworker1.local ansible_ssh_host=X.X.X.X
k8sworker2.local ansible_ssh_host=X.X.X.X
This isn’t the cleanest file, and you can probably put groups under k8s_all, but I couldn’t figure out how. Nevertheless, this should be good.
- OK, unlike terraform, we are going to follow best practices with Ansible passwords (I promise this isn’t just because I don’t know best practices for terraform.) We want to create a vault.
Start by creating a dir called vars
then run ansible-vault create vars/default.yml
It will prompt you twice for a password. Now we can create our first playbook. To edit the file, you run ansible-vault edit vars/default.yml
. You will want to add default_user_password: PASSWORD
Create a file called initial-setup.yml
. We will want to start by defining the top of the file:
---
- name: Basic k8s node configuration
hosts: k8s_all
user: USER
vars_files:
- vars/default.yml
vars:
ansible_ssh_pass: "{{ default_user_password }}"
become: yes
become_user: root
become_method: sudo
tasks:
name
is a title for the job.hosts
allows you to limit what hosts this section will be run on. Here we are running it on all, but later we will use it to configure master nodes and worker nodes separately.user
is what user you want to connect as.vars_files
is specifying a variables file. In this case, it is our encrypted file.vars
is specifying what variables to use. In this case, the password used for ssh.become
is specifying if you want to become another user.become_user
is what user you want to become.become_method
is how you want to become that user. In our case, we are using sudo.tasks
is where you put what you want to run.
Remember to change the user to the user you setup
- Ok, now we can move onto what we want to run. To start we want to add our public key, this isn’t 100% essential but is a nice to have as we don’t want to have to enter our password each time. Add this:
- name: Set authorized key taken from file
ansible.posix.authorized_key:
user: harley
state: present
key: "{{ lookup('file', '/home/USER/.ssh/id_rsa.pub') }}"
- Now we want to disable swap:
- name: Turn off swap
command: sed -i '/ swap / s/^\(.*\)$/#\1/g' /etc/fstab
- Set SELinux to permissive.
- name: Set SELinux to permissive
ansible.posix.selinux:
policy: targeted
state: permissive
- We now want to create a dir called
src/container.io
. In that folder, we want to createcontainerd.conf
with this as the content:
overlay
br_netfilter
We now want to add a section to transfer that to the hosts.
- name: Copy container configuration
ansible.builtin.copy:
src: ./src/container.io/containerd.conf
dest: /etc/modules-load.d/containerd.conf
owner: root
group: root
mode: '0644'
We also want to ensure the modules are present. To do that add this:
- name: Ensure moduales are present
community.general.modprobe:
name: "{{ item }}"
state: present
loop:
- overlay
- br_netfilter
- Now we want to configure some network options required for k8s. Add this to your Ansible file:
- name: Enable network settings for k8s
ansible.posix.sysctl:
name: "{{ item }}"
value: '1'
state: present
loop:
- net.bridge.bridge-nf-call-iptables
- net.ipv4.ip_forward
- net.bridge.bridge-nf-call-ip6tables
- We now want to add Docker and K8S yum repos. Ansible has a built-in module
ansible.builtin.yum_repository
. Add this:
- name: Add docker repo
ansible.builtin.yum_repository:
name: docker-ce-stable
baseurl: https://download.docker.com/linux/centos/$releasever/$basearch/stable
description: Docker Repo
enabled: yes
gpgcheck: yes
gpgkey: https://download.docker.com/linux/centos/gpg
- name: Add k8s repo
ansible.builtin.yum_repository:
name: kubernetes
baseurl: https://pkgs.k8s.io/core:/stable:/v1.31/rpm/
description: k8s package
enabled: yes
gpgcheck: yes
gpgkey: https://pkgs.k8s.io/core:/stable:/v1.31/rpm/repodata/repomd.xml.key
- Next thing is to install the packages required.
- name: Installing Packages
yum:
name:
- containerd.io
- vim
- tree
- git
- kubelet
- kubeadm
- kubectl
state: latest
- We now want to add the hosts to
/etc/hosts
. This is a requirement for k8s. Well, it is a requirment if you don’t have DNS resolution via DNS.
- name: Get all hosts and their IP addresses
set_fact:
host_entries: |
{% for host in groups['all'] %}
{{ hostvars[host]['ansible_ssh_host'] }} {{ host }} {{ host.split('.')[0] }}
{% endfor %}
- name: Append entries to /etc/hosts
blockinfile:
path: /etc/hosts
block: |
{{ host_entries }}
marker: "# {mark} ANSIBLE MANAGED BLOCK"
- We now want to configure containerd to use CGroups. Then we want to reboot the hosts to make sure all is applied.
- name: Configure containerd
shell: |
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null 2>&1
sudo sed -i 's/SystemdCgroup \= false/SystemdCgroup \= true/g' /etc/containerd/config.toml
- name: Reboot machines
reboot:
Master Node
Now we have the basic configuration, we can move onto the master node. Here we will configure the firewall, enable k8s services and initialise kubelet.
- First we want to define the name of the section, become and other boilerplate stuff. I named this file
k8s-configuration.yml
---
- name: Configure k8s Master node
hosts: k8s_master
user: USER
become: yes
become_user: root
become_method: sudo
tasks:
Here we use the hosts
section to limit it to just the master nodes (what is specified in hosts file).
- We want to configure the firewall to open k8s ports along with some other useful ones.
- name: Open ports in firewall
ansible.posix.firewalld:
port: "{{ item }}"
permanent: yes
state: enabled
loop:
- 6443/tcp
- 2379/tcp
- 2380/tcp
- 10250/tcp
- 10251/tcp
- 10252/tcp
- 10257/tcp
- 10259/tcp
- 179/tcp
- 22/tcp
- 80/tcp
- 443/tcp
- 4789/udp
The firewalld module doesn’t have a built-in way to specify multiple ports, so we use a loop
. Where we specify port
we add "{{ item }}"
which refers to the loop, and it will substitute the value with the current element in the loop. This can be really useful for keeping your playbooks neat.
We also want to reload the firewall. We can use the systemd module.
- name: reload service firewalld
systemd:
name: firewalld
state: reloaded
- Now we have the ports, we can finally get to setting up k8s. You will first want to start containerd which is once again a built-in Ansible module.
- name: Enable containerd
systemd_service:
name: containerd.service
state: started
enabled: true
We can now run kubeadm init
and start the kubelet service.
- name: k8s init
shell: sudo kubeadm init --control-plane-endpoint="{{ inventory_hostname }}" --ignore-preflight-errors=all --pod-network-cidr=192.168.50.0/16
You will want to ensure that the --pod-network-cidr
doesn’t conflict with your local network, but that is unlikely with that CIDR.
We can now enable the kubelet service, once again with systemd module.
- name: Enable Kubelet
ansible.builtin.systemd_service:
name: kubelet.service
state: started
enabled: true
This should be it! The master node should be up and running. I will show you later how to check on it.
Worker nodes
We will take a different approach on this host, calling it from the master node. This is so we can pass the join command over.
- You currently still want to be in the master node section. We want to run a command to print the join command. We can then pass that over to the worker section we will create after.
- name: get join command
shell: kubeadm token create --print-join-command
register: join_command_raw
Here we run a command and use a register
to store the value. We can then add a loop to call the worker node section (that we haven’t created yet).
- name: Configure worker nodes
add_host:
name: "{{ item }}"
k8s_join_command: "{{ join_command_raw.stdout }}"
loop: "{{ groups['k8s_worker'] }}"
- We now want to create the code for the worker section. You will want to add the typical boilerplate stuff.
- name: Configure k8s Worker nodes
hosts: k8s_worker
user: USER
become: yes
become_user: root
become_method: sudo
tasks:
The main thing to note is the hosts
section is set to k8s_worker
.
- We want to configure the firewall for the worker node. The worker node has fewer ports, but the Ansible code is the same (just with fewer ports specified).
- name: Open ports in firewall
ansible.posix.firewalld:
port: "{{ item }}"
permanent: yes
state: enabled
loop:
- 30000-32767/tcp
- 10250/tcp
- 179/tcp
- 22/tcp
- 80/tcp
- 443/tcp
- 4789/udp
We also want to reload the firewall here as well.
- name: reload service firewalld
systemd:
name: firewalld
state: reloaded
- We now want to enable containerd service and kubelet.
- name: Enable containerd
ansible.builtin.systemd_service:
name: containerd.service
state: started
enabled: true
- name: Enable Kubelet
ansible.builtin.systemd_service:
name: kubelet.service
state: started
enabled: true
- The last thing we need to do is run the join command.
- name: Add worker node
shell: "{{ k8s_join_command }} --ignore-preflight-errors=all"
We refer to the variable and add --ignore-preflight-errors=all
because… well, sometimes it complains. This probably isn’t the best idea, but we can always kill the node if it isn’t behaving properly.
You can now run the playbooks:
\> ansible-playbook --inventory-file hosts initial-setup.yml --ask-vault-password
\> ansible-playbook --inventory-file hosts k8s-configuration.yml --ask-vault-password
CONGRATULATIONS! You now have a working k8s stack that can be deployed automatically! This can be hugely useful for deploying a dev stack. With a bit (lot) of work, this code could also be used to deploy a production stack.
Configure kubectl
You will need to copy the default template:
\> cp /etc/kubernetes/admin.conf ~/.kube/config
\> sudo chown $USER:$USER ~/.kube/config
. You can now run kubectl get nodes
and you should see:
If you have kubectl locally, you can copy the config we made to your local and manage the host from your local shell. You might need to add the hosts to /etc/hosts
if you don’t have DNS name resolution for hosts on your network.
If you want the complete project, you can find it here: gitlab.com/godfrey.online
Sources:
- linuxtechi.com deploy kubernetes cluster on rhel
- Referance for what needed to be configured.
- tecadmin.net setup kubernetes cluster using ansible
- Used for insperation on how to copy the output of the command to the worker nodes.