Rancher: das “gut & günstig” Setup mit K3s und Ansible

Jannik Zinkl
Jannik Zinkl
  • 2023-02-07
  • 9 min to read
Blog Images

Rancher: das “gut & günstig” Setup mit K3s

Rancher ist ein tolles Tool, um mehrere Kubernetes Cluster schnell und einfach verwalten zu können. Bei trieb.work betreiben wir viele Services in Kubernetes Cluster, die wir mit der Rancher Oberfläche verwalten. Da Rancher selber in einem Kubernetes Cluster betrieben werden muss, hat man hier oft hohe laufende Kosten. Wir stellen hier im Kurzen unser Setup mit “K3s” statt “k8s” vor und wieso dieses Setup günstiger ist.

Unterschied zwischen K3s und k8s

Im Vergleich zu Kubernetes bietet K3s einen geringeren Funktionsumfang, ist jedoch schneller und einfacher zu installieren und zu verwalten. Es ist für Edge-Computing-Szenarien und Umgebungen mit geringen Ressourcen geeignet, während Kubernetes für größere, komplexere Deployments entwickelt wurde.

Da das Cluster nur Rancher als Applikation beheimatet, werden viele Features von Kubernetes nicht benötigt - es kann im allgemeinen einfacher aufgebaut sein und damit weniger Ressourcen verbrauchen.

K3s Aufbau in der Hetzner Cloud

Hetzner hat sich auch bei diesem Projekt als Hostinganbieter angeboten. Wir nutzen hier für einen simplen Aufbau zwei K3s VMs und eine PostgresQL VM als Datenspeicher.

Das “Bestellen” der VMs und des virtuellen Netz läuft komplett mittels Ansible. Der generelle Ablauf und die Strukturierung der Ansible Skripte sieht komplett so aus:

# SSH-Key einrichten
- hosts: localhost
  connection: local
  gather_facts: False
  user: root
  vars:
  tasks:
    - include_tasks: roles/hetzner-project/security.yml

# VMs bereitstellen (2 oder mehr Controllnodes, 
# eine Datenbanknode)
- hosts: localhost
  connection: local
  gather_facts: False
  user: root
  vars:
    datacenter_location: nbg1
  tasks:
    - include_tasks: roles/hetzner-server/hetzner-nodes-k3s.yml

# Internes netzwerk bereitstellen, alle Server hinzufügen
- hosts: localhost
  user: root
  connection: local
  gather_facts: False
  vars:
  tasks:
    - include_tasks: roles/hetzner-networking/hetzner-network.yml

# Komplettes Systemupgrade
- hosts: _managed_by_ansible_
  user: root
  tasks:
     - include_tasks: roles/system-upgrade.yml

# Standard SSH-Hardening
- hosts: _managed_by_ansible_
  user: root
  gather_facts: False
  collections:
    - devsec.hardening
  roles:
    - ssh_hardening
  vars:
    ssh_permit_root_login: "without-password"
    ssh_print_motd: true
    ssh_allow_tcp_forwarding: 'yes'
    # needed for ansible
    sftp_enabled: true 

# PostgresQL Service einrichten - traffic nur über internes
# Netz zulassen
- hosts: control-k3s-datastore
  user: root
  gather_facts: False
  tasks:
    - include_tasks: roles/postgresql/install.yml  
    - include_tasks: roles/postgresql/configure.yml     
  handlers:
  - name: restart postgres
    service: name=postgresql state=restarted 

# k3s setup auf den Worker nodes ausführen
- hosts: _workload_type_control
  user: root
  gather_facts: False
  tasks:
    - include_tasks: roles/k3s/master.yml

# kubeconfig in der Konsole ausgeben. kann kopiert
# werden um von der lokalen Maschine aus k3s mit der kubectl
# anzusprechen
- hosts: control-k3s-1
  user: root
  gather_facts: False
  name: Display content of k3s kubeconfig
  tasks:
    - name: Display k3s kubeconfig contents
      command: cat /etc/rancher/k3s/k3s.yaml
      register: command_output
    - name: Print to console
      debug:
        msg: "{{ command_output.stdout_lines }} "

Die einzelnen Tasks werden jetzt im Detail beschrieben:

Hetzner Security Einstellungen setzen

Damit direkt der Zugriff mittels SSH auf die Nodes möglich ist, benötigen wir einen SSh-Key, der in das Hetzner Portal geladen werden kann. Wir verwenden hier ein “Operations” Keypair - alternativ sollte hier mit Zertifikaten gearbeitet werden.

Mit ansible laden wir den Public Key in das Hetzner Portal, um ihn später beim Erstellen von VMs auswählen zu können.

- name: Add the trieb.work engineers ssh-key
  hetzner.hcloud.hcloud_ssh_key:
    name: "trieb.work engineers"
    public_key: "ecdsa-sha2-nistp521 AAAAE2VjZXXXXXXXXXX== info@tXXXXX"
    state: present

Hetzner VMs bereitstellen

Wir benutzen ebenfalls das hetzner hcloud Plugin um die VM Server, die benötigt werden, automatisch bereitzustellen. Dabei kann man die Location auswählen. Wir legen ebenfalls eine Placementgruppe an, damit die VMs auf unterschiedlichen, physikalischen Servern bereitgestellt werden:

# roles/hetzner-server/hetzner-nodes-k3s.yml
- name: Create a basic placement group for worker nodes
  hetzner.hcloud.hcloud_placement_group:
    name: placement-group-worker-nodes
    state: present
    type: spread

- name: "Create k3s datastore node {{ datacenter_location }}"
  hcloud_server:
    name: control-k3s-datastore
    server_type: cpx11
    image: centos-stream-8
    state: present
    location: "{{ datacenter_location }}"  
    ssh_keys:
      - "trieb.work engineers"  
    labels: {
      workload_type: database,
      managed_by_ansible: ""
    }  

- name: "Create k3s worker/control node {{ datacenter_location }}"
  hetzner.hcloud.hcloud_server:
    name: "control-k3s-{{ item }}"
    server_type: cx31
    image: centos-stream-8
    state: present
    location: "{{ datacenter_location }}"  
    ssh_keys:
      - "trieb.work engineers"  
    labels: {
      workload_type: control,
      managed_by_ansible: ""
    }  
  with_sequence: count=2 

- name: Refresh inventory
  meta: refresh_inventory

Der letzte Befehl “Refresh inventory” sorgt dafür, dass wir direkt auf die neu erstellten VMs zugreifen können und nicht Ansible erneut starten müssen.

Hetzner privates Netzwerk einrichten

Im nächsten Schritt legen wir ein privates Netzwek inklusive Subnet an, über welches die gesamte interne Kommunikation laufen soll. Dadurch müssen wir die Postgres Datenbank nicht im public Internet haben und können diesen Traffic über das virtuelle Hetzner Netz laufen lassen:

# roles/hetzner-networking/hetzner-network.yml
- name: Create the internal network
  hetzner.hcloud.hcloud_network:
    name: internal
    ip_range: 10.0.0.0/8
    state: present

- name: Create the internal subnetwork
  hetzner.hcloud.hcloud_subnetwork:
    network: internal
    ip_range: 10.0.0.0/16
    network_zone: eu-central
    type: cloud
    state: present

- name: Add all servers to network
  hetzner.hcloud.hcloud_server_network:
    server: "{{ item }}" 
    network: internal
  with_inventory_hostnames:
    - worker-*
    - control-*
    - etcd-*
  register: internal_network

CentOS Systemupgrade

Ein komplettes System Upgrade sollte regelmäßig durchgeführt werden. Kernel Upgrades, die oftmals einen Systemneustart benötigen, lassen wir bei automatischen Upgrades allerdings aus. Die restlichen Pakete sollten kontinuierlich auf den neuesten Stand gebracht werden, um auch Security Patches zu installieren. Da fast alle Anwendungen in containerD laufen, ist die Abhängigkeit an bestimmte Betriebssystempaketversionen auch nicht so hoch. Bisher gab es mit diesem Setup noch keine Probleme.

- name: upgrade all packages, excluding kernel & containerd related packages
  yum:
    name: '*'
    state: latest
    exclude: kernel*,containerd*,docker*

SSH Hardening

Ein standard SSH-Hardening um Zugriffe einzuschränken. Dieser optionale Schritt härtet die SSH Konfiguration, damit der Zugriff nur mittels SSH Key funktioniert. Dafür wird das Open Source Playbook von “Dev-Sec” verwendet. In Zukunft soll eine alternative Zugriffsmöglichkeit gefunden werden, wie Ansible sich mit den Servern verbinden kann. Dedizierte IPv4 Adressen bedeuten zusätzliche Kosten bei Hetzner und am Ende auch einen zusätzlichen offenen Port im Internet.

Postgres Installation und Konfiguration

Als Datastore für k3s kommt eine dedizierte Postgres VM zum Einsatz. Das Setup mit Ansible ist sehr simpel und günstig. In unserem Setup verwenden wir eine CPX11-Instanz mit 2vCPUs und 2GB RAM. K3s hat hier eine sehr geringe Last auf das System und würde sogar mit einer noch kleineren Node auskommen.

Postgres muss nicht im Internet verfügbar gemacht werden, sondern ausschließlich über das private, interne Hetzner Netzwerk. Bei Hetzner VMs kann das Netzwerkinterface entweder “ens10” oder “enp7s0” benannt sein, weshalb wir die IP-Adresse von beiden Namen checken und als Variable abspeichern. Der Postgres Service wird dann entsprechend eingestellt, dass er nur auf der internen IP Adresse lauscht.

Die Install Routine sieht so aus:

# roles/postgresql/install.yml
- name: "Install packages"
  dnf: "name={{ item }} state=present"
  with_items:
    - postgresql
    - postgresql-server
    - python3-psycopg2

- name: "Internal IP interfaces ens10"
  set_fact: 
    internal_ip_postgres: "{{ ansible_facts.ens10.ipv4.address }}"
  when: ansible_facts['ens10'] is defined

- name: "Internal IP interfaces enp7s0"
  set_fact: 
    internal_ip_postgres: "{{ ansible_facts.enp7s0.ipv4.address }}"
  when: ansible_facts['enp7s0'] is defined

- name: "Bootstrapping node with internal IP"
  debug:
    msg: "{{ internal_ip_postgres }}"

- name: "Make Postgres listen on internal address"
  replace: 
      path: /var/lib/pgsql/data/postgresql.conf
      regexp: '^#listen_addresses.*'
      replace: "listen_addresses = '{{ internal_ip_postgres }}'"
  notify: restart postgres

- name: "Find out if PostgreSQL is initialized"
  ansible.builtin.stat:
    path: "/var/lib/pgsql/data/pg_hba.conf"
  register: postgres_data

- name: "Initialize PostgreSQL"
  shell: "postgresql-setup initdb"
  when: not postgres_data.stat.exists

- name: "Start and enable services"
  service: "name={{ item }} state=started enabled=yes"
  with_items:
    - postgresql

Nachdem der Service an sich eingerichtet ist und läuft, können anschließend der Nutzer und die Datenbank für K3s eingerichtet werden:

# roles/postgresql/configure.yml
- name: "Create app database"
  postgresql_db:
    state: present
    name: "{{ db_name }}"
  become: yes
  become_user: postgres

- name: "Create db user"
  postgresql_user:
    state: present
    name: "{{ db_user }}"
    password: "{{ db_password }}"
  become: yes
  become_user: postgres

- name: "Grant db user access to app db"
  postgresql_privs:
    type: database
    database: "{{ db_name }}"
    roles: "{{ db_user }}"
    grant_option: no
    privs: all
  become: yes
  become_user: postgres

- name: "Allow md5 connection for the db user"
  postgresql_pg_hba:
    dest: "~/data/pg_hba.conf"
    contype: host
    databases: all
    method: md5
    users: "{{ db_user }}"
    create: true
    address: "10.0.0.0/16"
  become: yes
  become_user: postgres
  notify: restart postgres 

K3s Installieren

Nachdem die Datenbank fertig eingerichtet worden ist, werden jetzt die K3s Nodes installiert und eingerichtet. Dabei wird prinzipiell nur das K3s Script auf der Node ausgeführt. Das Skript ist idempotent, wenn entsprechend die Version gepinned ist. Diese setzen wir mit einer Ansible Variable.

#roles/k3s/master.yml
- name: Start the k3s script
  shell: "curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION={{ k3s_version }} K3S_DATASTORE_ENDPOINT='postgres://{{ db_user }}:{{ db_password }}@{{ hostvars['control-k3s-datastore']['internal_ip_postgres'] }}:5432/k3s?sslmode=disable' K3S_TOKEN={{ k3s_token }} sh -"
  • Start des k3s setups auf den Worker-Nodes. Hierbei richten wir auch die beiden IP Adressen in Cloudflare im DNS ein. Die Controlnodes sind jetzt mit dem entsprechenden DNS Namen im Internet verfügbar. Wenn kein Cloudflare, oder ein anderer flexibler DNS Service besteht, kann dieser Schritt auch einfach manuell durchgeführt werden. Da diese Nodes stateless sind, können sie einfach ausgetauscht werden, womit sich im Normalfall auch die IP Adresse ändert.
  • Nach dem Setup wird der Inhalt der kubeconfig auf der Console angezeigt. Dieses sollte kopiert werden und in die lokale Konfig der kubectl eingefügt werden
Jannik Zinkl
Jannik Zinkl

Entrepreneur & Cloud Architect with a passion for Climate Tech