Автоматизация развертывания стенда Kubernetes

563ab08f57bf42ed2b6cb1d8844538f6.png

Меня заинтересовала тема Kubernetes, и я решил освоить его. На начальном этапе все шло хорошо, пока я изучал теорию.

Однако как только дело дошло до практики внезапно выяснилось что по каким то причинам самое быстрое и распространённое решение minicube просто отказывается разворачиваться на моей Fedora. Разворачивание просто зависало на одном из этапов. Причина подозреваю была в не отключенном по умолчанию swap разделе, но на тот момент я не додумал.

Попробовав несколько вариантов с разными виртуальными машинами, я решил что раз не работает minicube, значит надо развернуть более комплексное решение. Подумал и полез в интернет. После прочтения нескольких статей на нашем ресурсе я решил остановиться на этой:

@imbasoft «Гайд для новичков по установке Kubernetes» 

В ней было всё разложено по полочкам, все шаги выполнялись легко и непринуждённо. В конце получался замечательный стенд для тренировок. Причем стенд вариативный, с разными движками контейнеризации, и вариантами запуска как аналог миникуба, так и пяти хостовый вариант с управляющими и рабочими нодами.

Но смущал меня один момент, в статье было описано развёртывание со многими снапшотами, чтобы можно было вернуться и переиграть по‑новому.
Каждый раз откатываться по нескольким машинам мне не хотелось. А развернуть хотелось.
Решение пришло быстро. Ansible. Можно раскатываться и перераскатываться относительно быстро. В любой момент можно удалить стенд и начать заново.

До этого не работал с ansible, поэтому это так же показалось мне вполне неплохой практикой.

Возможно решение не самое правильное, надеюсь люди в комментариях меня поправят или подскажут более удачные варианты решений. Но пока представляю на суд то что получилось в итоге.

Я знаю что существуют готовые варианты типа kubespray, но тут больше хотелось поработать именно с ansible, так что я решил не упускать возможности. В любом случае не ошибается тот, кто ничего не делает, так что лучше я допущу десяток ошибок новичка, на которые мне может даже укажут, чем вообще не попробую.

Итак, с чего начать?

Во‑первых надо было выбрать виртуализацию. KVM показался мне нормальным решением, он можно сказать родной для linux, есть возможность рулить из командной строки.

Я не буду описывать как настраивать KVM на машине и устанавливать ansible, статья не об этом. Предположим что у вас уже всё установлено.

Как я уже написал, опыта с ansible у меня не много, но даже с ним я понимаю что писать одну большую простыню кода не особо удобно, а отлаживать и того хуже. Было решено разбить её на несколько простыней поменьше посредством ролей.

В целом если прочитать оригинальную статью то можно выделить 3 этапа:

  1. Подготовка виртуальных машин

  2. Установка движка контейнеризации

  3. Установка all_in_one/ha_cluster

Исходя из этого будем готовить 4 роли со своими тасками.

  • vm_provision

  • driver_provision

  • k8s_all_in_one

  • k8s_ha_cluster

Создаём каталог, у меня он называется kvmlab, и в нем файл setup_k8s.yaml

Это будет главный playbook, из него будут подтягиваться остальные по мере необходимости. Tак же нам понадобится inventory и файл с переменными которыми мы будем управлять развёртыванием. Ну и конечно же роли.

В каталоге выполним, для создания ролей.

ansible-galaxy role init vm_provision
ansible-galaxy role init driver_provision
ansible-galaxy role init k8s_ha_cluster
ansible-galaxy role init k8s_all_in_one

Файл inventory описывает наши ansible_host для подключения:

all:
  children:
    management:
      hosts:
        node1:
          ansible_host: 172.30.0.201
        node2:
          ansible_host: 172.30.0.202
        node3:
          ansible_host: 172.30.0.203
    workers:
      hosts:
        node4:
          ansible_host: 172.30.0.204
        node5:
          ansible_host: 172.30.0.205

my_vars.yml как видно из названия описывает переменные, параметры развертывания, параметры виртуальных машин, каталоги хранения iso и дисков VM:

variant: all-in-one #[all-in-one, ha-cluster]
engine: cri-o #[container-d, cri-o, docker]
libvirt_pool_dir: "/home/alex/myStorage/storage_for_VMss"
libvirt_pool_images: "/home/alex/myStorage/iso_imagess"
vm_net: k8s_net
ssh_key: "/home/alex/.ssh/id_rsa.pub"
ansible_ssh_common_args: "-o StrictHostKeyChecking=no"
version: "1.26"
os: "Debian_11"
vm_info:
  vm_names:
    - name: node1
      memory: 2048
      cpu: 2
      ipaddr: 172.30.0.201
    - name: node2
      memory: 2048
      cpu: 2
      ipaddr: 172.30.0.202
    - name: node3
      memory: 2048
      cpu: 2
      ipaddr: 172.30.0.203
    - name: node4
      memory: 3072
      cpu: 4
      ipaddr: 172.30.0.204
    - name: node5
      memory: 3072
      cpu: 4
      ipaddr: 172.30.0.205

Рассмотрим переменные чуть подробнее:

variant — это наш вариант установки будем ли мы устанавливать кластер или ограничимся одной машиной и сделаем аналог minikube.

engine — собственно движок контейнеризации

libvirt_pool_dir и libvirt_pool_images каталоги хранения дисков виртуальных машин и скачанных образов соответственно.

vm_net — имя создаваемой сети для ваших машин.

ssh_key — ваш публичный ключ, подкидывается на ВМ в процессе подготовки и дальнейшие действия выполняются вашим логином из под root.

ansible_ssh_common_args — отключение проверки хеша ключа.

Теперь вернемся к setup_k8s.yaml:

Первый play выполняется на localhost, требует повышенных прав и состоит из 6 task:

  1. Подготовка окружения — на этом этапе мы устанавливаем необходимые пакеты для управления libvirt.

  2. Настройка сети — машины будут использовать свою сеть, но её надо предварительно создать.

  3. Подготовка шаблона для ВМ — все машины будут с одинаковой OS, в моём случае с debian 11, у них будет одинаковый набор начальных пакетов. Каждый раз разворачивать с нуля долго, поэтому надо подготовить шаблон VM и переиспользовать его при необходимости.

  4. Создание ВМ нод из шаблонного образа. Создание нужного количества VM для развертывания.

  5. Перезагрузка созданных машин.

  6. Создание снапшота. Эта таска опциональна, при дальнейшем развёртывании часто случались ошибки и надо было начинать сначала, снапшот решал эту проблему. в целом сейчас он уже не нужен, но я оставил. Для подготовки будем использовать роль vm_provision о ней чуть позже, а сейчас посмотрим на то что получилось:

kvmlab/setup_k8s.yaml:

---
- name: Подготовка ВМ к развёртыванию k8s
  hosts: localhost
  gather_facts: yes
  become: yes
  tasks:
    - name: Подготовка окружения
      package:
        name:
          - libguestfs-tools
          - python3-libvirt
        state: present

    - name: Настройка сети
      include_role:
        name: vm_provision
        tasks_from: create_network.yml

    - name: Подготовка шаблона для ВМ
      include_role:
        name: vm_provision
        tasks_from: prepare_images_for_cluster.yml

    - name: Создание ВМ нод из шаблонного образа.
      include_role:
        name: vm_provision
        tasks_from: create_nodes.yml
      vars:
        vm_name: "{{ item.name }}"
        vm_vcpus: "{{ item.cpu }}"
        vm_ram_mb: "{{ item.memory }}"
        ipaddr: "{{ item.ipaddr }}"
      with_items: "{{ vm_info.vm_names }}"
      when: variant == 'ha-cluster' or (variant == 'all-in-one' and item.name == 'node1')

    - name: Ожидание загрузки всех ВМ из списка
      wait_for:
        host: "{{ hostvars[item].ansible_host }}"
        port: 22
        timeout: 300
        state: started
      when: variant == 'ha-cluster' or item == 'node1'
      with_items: "{{ groups['all'] }}"

    - name: Создаем снимок host_provision
      include_role:
        name: vm_provision
        tasks_from: create_snapshot.yml
      vars:
        vm_name: "{{ item.name }}"
        snapshot_name: "host_provision"
        snapshot_description: "Нода подготовлена к установке движка"
      when: variant == 'ha-cluster' or item.name == 'node1'
      with_items: "{{ vm_info.vm_names }}"

Визуально не много, давай разберём что скрывается под include_role.

А под ролью у нас скрывается:

Дефолтные настройки на случай если какие то переменные не заполнены, по иерархии если не ошибаюсь стоят в самом низу, т.е. если эти переменные прилетят откуда от еще, их приоритет будет выше:

kvmlab/roles/vm_provision/defaults/main.yml:

---
# defaults file for vm_provision
base_image_name: debian-11-generic-amd64-20230124-1270.qcow2
base_image_url: https://cdimage.debian.org/cdimage/cloud/bullseye/20230124-1270/{{ base_image_name }}
base_image_sha: 8db9abe8e68349081cc1942a4961e12fb7f94f460ff170c4bdd590a9203fbf83
libvirt_pool_dir: "/var/lib/libvirt/images"
libvirt_pool_images: "/var/lib/libvirt/images"
vm_vcpus: 2
vm_ram_mb: 2048
vm_net: vmnet
vm_root_pass: test123
ssh_key: /root/.ssh/id_rsa.pub

2 шаблона:

roles/vm_provision/templates/

vm-template.xml.j2 — Шаблон по которому создается виртуальная машина в xml формате. при создании параметры заполняются из заданных переменных.

vm_network.xml.j2 — Шаблон для создания сети которую будут использовать VM.

Я не буду их приводить, вы сможете забрать их в репозитории.

Ну и наконец roles/vm_provision/tasks/

create_network.yml — набор задач для создания сети

create_nodes.yml — набор задач для создания нод

create_snapshot.yml — создание снапшотов

prepare_images_for_cluster.yml — подготовка шаблона

Начнем с подготовки шаблона:
состоит из 4 задач:

  1. Создание каталога для хранения исходного образа (если конечно он не существует).

  2. Скачивание и проверка базового образа. Каждый раз качать его нет смысла, поэтому скачивается один раз, при повторном запуске, если файл уже лежит на месте эта часть скипается.

  3. Базовый образ уже можно подключить к ВМ и работать с ним, однако тогда он перестанет быть базовым, а уже будет кастомизированным. Оставим его как есть, но скопируем его как шаблон для ВМ.

  4. первичная настройка шаблона. Часть библиотек и ПО для любого варианта развертывания будет одна и та же. Поэтому проще накатить их сразу в шаблон. Так же заполним hosts,  по-хорошему его бы заполнять динамически в зависимости от количества нод, но я прописал 5 штук сразу. Сильно не мешает.

kvmlab/roles/vm_provision/tasks/prepare_images_for_cluster.yml:

---
# tasks file vm_provision, создание  шаблона ВМ
- name: Создание каталога {{ libvirt_pool_images }} если не существует.
  file:
    path: "{{ libvirt_pool_images }}"
    state: directory
    mode: 0755

- name: Скачивание базового образа если его нет в хранилище
  get_url:
    url: "{{ base_image_url }}"
    dest: "{{ libvirt_pool_images }}/{{ base_image_name }}"
    checksum: "sha256:{{ base_image_sha }}"  

- name: Создание копии базового образа, для шаблона
  copy:
    dest: "{{ libvirt_pool_images }}/template_with_common_settings.qcow2"
    src: "{{ libvirt_pool_images }}/{{ base_image_name }}"
    force: no
    remote_src: yes 
    mode: 0660
  register: copy_results

- name: Первичная настройка шаблона.
  command: |
    virt-customize -a {{ libvirt_pool_images }}/template_with_common_settings.qcow2 \
    --root-password password:{{ vm_root_pass }} \
    --ssh-inject 'root:file:{{ ssh_key }}' \
    --uninstall cloud-init \
    --run-command 'apt update && apt install -y ntpdate gnupg gnupg2 curl software-properties-common wget keepalived haproxy' \
    --append-line '/etc/hosts:172.30.0.201 node1.internal node1\n172.30.0.202 node2.internal node2\n172.30.0.203 node3.internal node3\n172.30.0.204 node4.internal node4\n172.30.0.205 node5.internal node5' \
    --run-command 'curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | gpg --dearmour -o /etc/apt/trusted.gpg.d/cgoogle.gpg' \
    --run-command 'apt-add-repository "deb http://apt.kubernetes.io/ kubernetes-xenial main"' \
    --run-command 'apt update && apt install -y kubeadm kubectl' \
    --run-command 'echo 'overlay' > /etc/modules-load.d/k8s.conf && echo 'br_netfilter' >> /etc/modules-load.d/k8s.conf' \
    --run-command 'echo -e "net.bridge.bridge-nf-call-ip6tables = 1\nnet.bridge.bridge-nf-call-iptables = 1\nnet.ipv4.ip_forward = 1" > /etc/sysctl.d/10-k8s.conf'

  when: copy_results is changed

Отлично, шаблон готов. далее на очереди создание сети, ибо сеть используется в шаблоне создания ВМ, и если её не будет, то чуда не случится.

Тут все просто, используя virsh мы проверяем создавалась ли сеть ранее. если да, то скипаем, если же нет, то используя шаблон в который будет подставлено имя сети из переменных средствами всё той же virsh будет создана, запущена и выставлена в автозапуск сеть.

kvmlab/roles/vm_provision/tasks/create_network.yml:

---
# tasks file for vm_provision, пересоздание сети

- name: Получение списка сетей KVM
  command: virsh net-list --all
  register: net_list_output

- name: Проверка наличия сети {{ vm_net }}
  shell: echo "{{ net_list_output.stdout }}" | grep -w "{{ vm_net }}"
  register: network_check
  ignore_errors: true

- name: Создание и настройка сети {{ vm_net }}
  block:
    - name: Копирование шаблона сети
      template:
        src: vm_network.xml.j2
        dest: /tmp/vm_network.xml

    - name: Создание сети {{ vm_net }}
      command: virsh net-define /tmp/vm_network.xml

    - name: Запуск сети {{ vm_net }}
      command: virsh net-start {{ vm_net }}

    - name: Автостарт сети {{ vm_net }}
      command: virsh net-autostart {{ vm_net }}
  when: network_check.rc != 0

Так. Шаблон ВМ есть, сеть есть. Ничего не мешает нам создать ноду или ноды:
Создание нод запускается циклом по переменным. (vm_info.vm_names)
ноды создаются по одной и проходят следующие этапы:

  1. Опять же создается каталог для хранения дисков виртуальных машин, если он не существует.

  2. Каждая машина перед созданием проверяется на наличие её в уже существующих, если она есть, то создание пропускается, так что если у вас осталась машина с прошлого стенда то лучше её пересоздать.

  3. Копируется шаблонный образ диска и переименовывается в соответствии с именем ВМ.

  4. Изменяется размер диска, расширяется до 10 GB, этого объема мне хватило для установки всех вариантов. Значение захардкожено, но при желании его можно так же параметризовать.

  5. Начальное конфигурирование ноды. Тут у нод появляется индивидуальность, имя, ip и свой ssh ключ.

  6. Когда все составные части готовы, создается машина из шаблона xml.

  7. Запуск ВМ.

kvmlab/roles/vm_provision/tasks/create_nodes.yml:

---
# tasks file for vm_provision, создание нод
- name: Создание каталога {{ libvirt_pool_dir }} если не существует.
  file:
    path: "{{ libvirt_pool_dir }}"
    state: directory
    mode: 0755

- name: Получаем список существующих ВМ
  community.libvirt.virt:
    command: list_vms
  register: existing_vms
  changed_when: no

- name: Создание ВМ если её имени нет в списке
  block:
  - name: Копирование шаблонного образа в хранилище
    copy:
      dest: "{{ libvirt_pool_dir }}/{{ vm_name }}.qcow2"
      src: "{{ libvirt_pool_images }}/template_with_common_settings.qcow2"
      force: no
      remote_src: yes 
      mode: 0660
    register: copy_results
  
  - name: Изменение размера виртуального диска
    shell: "qemu-img resize {{ libvirt_pool_dir }}/{{ vm_name }}.qcow2 10G"


  - name: Начальное конфигурирование hostname:{{ vm_name }}, ip:{{ ipaddr }}
    command: |
      virt-customize -a {{ libvirt_pool_dir }}/{{ vm_name }}.qcow2 \
      --hostname {{ vm_name }}.internal \
      --run-command 'echo "source /etc/network/interfaces.d/*\nauto lo\niface lo inet loopback\nauto enp1s0\niface enp1s0 inet static\naddress {{ ipaddr }}\nnetmask 255.255.255.0\ngateway 172.30.0.1\ndns-nameservers 172.30.0.1" > /etc/network/interfaces'
      --run-command 'ssh-keygen -A'

    when: copy_results is changed

  - name: Создание ВМ из шаблона
    community.libvirt.virt:
      command: define
      xml: "{{ lookup('template', 'vm-template.xml.j2') }}"
      
  when: "vm_name not in existing_vms.list_vms"

- name: Включение ВМ
  community.libvirt.virt:
    name: "{{ vm_name }}"
    state: running
  register: vm_start_results
  until: "vm_start_results is success"
  retries: 15
  delay: 2

Отлично, машины созданы. теперь перезагрузим их, дождемся пока все загрузятся и сделаем снапшоты.

kvmlab/roles/vm_provision/tasks/create_snapshot.yml:

---
# tasks file for vm_provision, создание снапшотов
- name: Создание снапшота {{ snapshot_name }}
  shell: "virsh snapshot-create-as --domain {{ vm_name }} --name {{ snapshot_name }} --description '{{ snapshot_description }}'"
  register: snapshot_create_status
  ignore_errors: true

Если ничего не забыл, то первый этап выполнен.

У вас есть одна или пять нод, все готовы к дальнейшей работе.

Причем если удалить все ВМ и запустить создание повторно, то из за наличия готового шаблона процесс пройдёт гораздо быстрее.

Отлично. переходим к установке движка:

вернемся в setup_k8s.yaml и добавим следующий play:

 - name: Установка движка контейнеризации [cri-o, container-d, docker]
  hosts: all
  gather_facts: true
  become: true
  remote_user: root
  tasks:
    - name: Синхронизация даты/времени с NTP сервером
      shell: ntpdate 0.europe.pool.ntp.org

    - name: Установка cri-o
      include_role:
        name: driver_provision
        tasks_from: install_crio.yml
      when: engine == "cri-o"

    - name: Установка container-d
      include_role:
        name: driver_provision
        tasks_from: install_container_d.yml
      when: engine == "container-d"

    - name: Установка docker cri
      include_role:
        name: driver_provision
        tasks_from: install_docker_cri.yml
      when: engine == "docker"

В целом всё просто, используем роль driver_provision, но в зависимости от установленных параметров запускаем одну из трех последовательностей.

Вся последовательность действий для каждого из движков была взята из статьи указанной вначале.

Я не буду подробно комментировать таски, в целом их имена отражают суть всех действий.
приведём все три варианта:

kvmlab/roles/driver_provision/tasks/install_container_d.yml:

---
# tasks file for driver_provision, установка container-d
- name: Скачиваем containerd
  get_url:
    url: "https://github.com/containerd/containerd/releases/download/v1.7.0/containerd-1.7.0-linux-amd64.tar.gz"
    dest: "/tmp/containerd-1.7.0-linux-amd64.tar.gz"

- name: Распаковываем архив
  unarchive:
    src: /tmp/containerd-1.7.0-linux-amd64.tar.gz
    dest: /usr/local
    copy: no

- name: Удаляем скачаный архив за ненадобностю
  file:
    path: "/tmp/containerd-1.7.0-linux-amd64.tar.gz"
    state: absent

- name: Создание директории для конфигурации containerd
  file:
    path: /etc/containerd/
    state: directory

- name: Проверяем создан ли каталог
  stat:
    path: /etc/containerd
  register: containerd_dir

- name: Создание конфиг файла containerd
  become: true
  command: "sh -c 'containerd config default > /etc/containerd/config.toml'"
  when: containerd_dir.stat.exists

- name: конфигурирование cgroup driver
  replace:
    path: "/etc/containerd/config.toml"
    regexp: "SystemdCgroup = false"
    replace: "SystemdCgroup = true"

- name: Скачиваем containerd systemd service file
  get_url:
    url: "https://raw.githubusercontent.com/containerd/containerd/main/containerd.service"
    dest: "/etc/systemd/system/containerd.service"

- name: Скачиваем и устанавливаем runc
  get_url:
    url: "https://github.com/opencontainers/runc/releases/download/v1.1.4/runc.amd64"
    dest: "/usr/local/sbin/runc"
    mode: "u+x"

- name: Скачиваем CNI plugins
  get_url:
    url: "https://github.com/containernetworking/plugins/releases/download/v1.2.0/cni-plugins-linux-amd64-v1.2.0.tgz"
    dest: "/tmp/cni-plugins-linux-amd64-v1.2.0.tgz"

- name: Распаковываем CNI plugins archive
  unarchive:
    src: "/tmp/cni-plugins-linux-amd64-v1.2.0.tgz"
    dest: "/opt/cni/bin"
    copy: no

- name: Удаляем CNI plugins archive
  file:
    path: "/tmp/cni-plugins-linux-amd64-v1.2.0.tgz"
    state: absent

- name: Перезагрузка systemd
  systemd:
    daemon_reload: yes

- name: Запуск и активация containerd service
  systemd:
    name: containerd
    state: started
    enabled: yes

kvmlab/roles/driver_provision/tasks/install_crio.yml:

---
# tasks file for driver_provision, установка cri-o
- name: Установка ключа репозитория cri-o
  apt_key:
    url: https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/{{ version }}/{{ os }}/Release.key
    state: present

- name: Установка репозитория cri-o
  apt_repository:
    repo: 'deb https://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable/{{ os }}/ /'
    filename: devel:kubic:libcontainers:stable.list

- name: Установка репозитория cri-ostable/cri-o
  apt_repository:
    repo: 'deb http://download.opensuse.org/repositories/devel:/kubic:/libcontainers:/stable:/cri-o:/{{ version }}/{{ os }}/ /'
    filename: 'devel:kubic:libcontainers:stable:cri-o:{{ version }}.list'

- name: Установка cri-o
  apt:
    name: ['cri-o', 'cri-o-runc']
    state: latest

- name: Создание каталога /var/lib/crio
  file:
    path: /var/lib/crio
    state: directory
    
- name: Перезагрузка systemd
  systemd:
    daemon_reload: yes

- name: запуск служб crio
  systemd:
    name: crio
    enabled: yes
    state: started

kvmlab/roles/driver_provision/tasks/install_docker_cri.yml:

---
# tasks file for driver_provision, установка docker + cri
- name: Create directory /etc/apt/keyrings
  file:
    path: /etc/apt/keyrings
    state: directory
    mode: '0755'

- name: Add GPG key Docker
  ansible.builtin.shell:  curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/trusted.gpg.d/docker.gpg --yes

- name: Get dpkg architecture
  shell: "dpkg --print-architecture"
  register: architecture

- name: Get lsb release
  shell: "lsb_release -cs"
  register: release_output

- name: Add Docker repository
  apt_repository:
    repo: "deb [arch={{ architecture.stdout_lines | join }} signed-by=/etc/apt/trusted.gpg.d/docker.gpg] https://download.docker.com/linux/debian {{ release_output.stdout_lines | join }} stable"
    state: present
  register: docker_repo

- name: Apt Update
  ansible.builtin.apt:
    update_cache: yes

- name: Install Docker
  apt:
    name:
      - docker-ce
      - docker-ce-cli
      - containerd.io
      - docker-compose-plugin
    state: present

- name: Download plugin cri-dockerd
  get_url:
    url: "https://github.com/Mirantis/cri-dockerd/releases/download/v0.3.1/cri-dockerd-0.3.1.amd64.tgz"
    dest: "/tmp/cri-dockerd.tgz"

- name: Unpack cri-dockerd
  unarchive:
    src: "/tmp/cri-dockerd.tgz"
    dest: "/tmp/"
    copy: no

- name: Copy unpacked bin cri-dockerd
  copy:
    dest: "/usr/local/bin/"
    src: "/tmp/cri-dockerd/cri-dockerd"
    force: no
    remote_src: yes 
    mode: 0660
  register: copy_results

- name: change alc on cri-dockerd
  file:
    path: "/usr/local/bin/cri-dockerd"
    mode: "0755"

- name: Download config file on cri-dockerd.service
  get_url:
    url: "https://raw.githubusercontent.com/Mirantis/cri-dockerd/master/packaging/systemd/cri-docker.service"
    dest: "/etc/systemd/system/cri-docker.service"

- name: Download config file on cri-dockerd.socket
  get_url:
    url: "https://raw.githubusercontent.com/Mirantis/cri-dockerd/master/packaging/systemd/cri-docker.socket"
    dest: "/etc/systemd/system/cri-docker.socket"

- name: Update cri-docker.service
  ansible.builtin.shell:  "sed -i -e 's,/usr/bin/cri-dockerd,/usr/local/bin/cri-dockerd,' /etc/systemd/system/cri-docker.service"

- name: daemon reload
  systemd:
    daemon_reload: yes

- name: enable cri-docker.service
  systemd:
    name: cri-docker.service
    enabled: yes
    state: started

- name: enable cri-dockerd.socket
  systemd:
    name: cri-docker.socket
    enabled: yes
    state: started

Так, готово. после этапа установки движка идёт еще один play для localhost для создания снапшота.

- name: Создаем снапшот driver_provision
  hosts: localhost
  become: yes
  tasks:
    - name: Создаем снимки
      include_role:
        name: vm_provision
        tasks_from: create_snapshot.yml
      vars:
        vm_name: "{{ item.name }}"
        snapshot_name: "driver_provision"
        snapshot_description: "Движок установлен, нода подготовлена к инициализации k8s"
      when: variant == 'ha-cluster' or item.name == 'node1'
      with_items: "{{ vm_info.vm_names }}"

В целом так же опциональный, можно удалить.

Bтак, осталось самое важное, ради чего всё это начиналось.

Инициализация кубера!

возвращаемся в setup_k8s.yaml и дописываем следующий play.

- name: Настройка kubernetes [all-in-one либо ha-cluster]
  hosts: all
  gather_facts: true
  become: true
  remote_user: root
  tasks:
    - name: Установка all-in-one
      include_role:
        name: k8s_all_in_one
        tasks_from: all_in_one.yml
      when: variant == "all-in-one" and inventory_hostname == 'node1'

    - name: Подготовка нод для ha-cluster
      include_role:
        name: k8s_ha_cluster
        tasks_from: ha_cluster_prepare_managers.yml
      when: variant == "ha-cluster"

    - name: Установка первой ноды
      include_role:
        name: k8s_ha_cluster
        tasks_from: ha_cluster_first_node.yml
      when: variant == "ha-cluster" and inventory_hostname == 'node1' and inventory_hostname in groups['management']
      register: first_node_result


    - name: Передача команд на остальные ноды
      set_fact:
        control_plane_join_command: "{{ hostvars['node1']['control_plane_join_command'] }}"
        worker_join_command: "{{ hostvars['node1']['worker_join_command'] }}"
      when: variant == "ha-cluster" and inventory_hostname != 'node1'

    - name: вывод команд подключения
      debug:
        msg: |
          control_plane_join_command: {{ control_plane_join_command }}
          worker_join_command: {{ worker_join_command }}
      when: variant == "ha-cluster" and inventory_hostname == 'node1'

    - name: Использование команды control_plane_join_command
      block:
        - name: Подключение управляющих нод для ['container-d', 'cri-o']
          ansible.builtin.shell:
            cmd: "{{ control_plane_join_command }}"
          until: result.rc == 0
          register: result
          retries: 5
          delay: 30
          when: engine in ['container-d', 'cri-o']

        - name: Подключение управляющих нод для docker
          ansible.builtin.shell:
            cmd: "{{ control_plane_join_command }} --cri-socket unix:///var/run/cri-dockerd.sock"
          until: result.rc == 0
          register: result
          retries: 5
          delay: 30
          when: engine == 'docker'
      when: variant == "ha-cluster" and inventory_hostname != 'node1' and inventory_hostname in groups['management']


    - name: Использование команды worker_join_command
      block:
        - name: Подключение рабочих нод для ['container-d', 'cri-o']
          ansible.builtin.shell:
            cmd: "{{ worker_join_command }}"
          until: result.rc == 0
          register: result
          retries: 5
          delay: 30
          when: engine in ['container-d', 'cri-o']

        - name: Подключение рабочих нод для docker
          ansible.builtin.shell:
            cmd: "{{ worker_join_command }} --cri-socket unix:///var/run/cri-dockerd.sock"
          until: result.rc == 0
          register: result
          retries: 5
          delay: 30
          when: engine == 'docker'
      when: variant == "ha-cluster" and inventory_hostname != 'node1' and inventory_hostname in groups['workers']

    - name: Скачивание конфига с первой ноды (подходит для обоих вариантов all-in-one и ha-cluster)
      ansible.builtin.fetch:
        src: /etc/kubernetes/admin.conf
        dest: /tmp/
        flat: yes
        force: yes
      when: inventory_hostname == 'node1'

    - name: Перезагрузка всех машин
      ansible.builtin.reboot:
        reboot_timeout: 300

Тут для установки используются две роли (можно было и одной обойтись, но так нагляднее).

Начнем пожалуй с all‑in‑one варианта установки, он самый простой:

roles/k8s_all_in_one/tasks/all_in_one.yml:

---
- name: Проверка наличия файла конфига
  stat:
    path: /etc/kubernetes/admin.conf
  register: file_info

- name: Инициализация кластера если конфиг не обнаружен.
  block:
    - name: Инициализация кластера для движков ['container-d', 'cri-o']
      shell: kubeadm init --pod-network-cidr=10.244.0.0/16
      when: engine in ['container-d', 'cri-o']
      register: kubeadm_output

    - name: Инициализация кластера для движка docker
      shell: |
        kubeadm init \
               --pod-network-cidr=10.244.0.0/16 \
               --cri-socket unix:///var/run/cri-dockerd.sock
      when: engine == 'docker'
      register: kubeadm_output

    - name: Установка KUBECONFIG в enviroment
      become: true
      lineinfile:
        dest: /etc/environment
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

    - name: Установка KUBECONFIG в bashrc
      become: true
      lineinfile:
        dest: '~/.bashrc'
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

    - name: Подождем пока всё запустится
      wait_for:
        host: localhost
        port: 6443
        timeout: 300

    - name: Установка сетевого плагина Flannel
      shell: kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml

    - name: Снятие ограничения на запуск рабочих нагрузок c {{ ansible_hostname }}
      shell: "kubectl taint nodes --all node-role.kubernetes.io/control-plane-"
      become:roles/k8s_all_in_one/tasks/all_in_one.yml: true
      register: taint_result
      failed_when:
        - "'error: taint \"node-role.kubernetes.io/control-plane\" not found' not in taint_result.stderr"
        - "'node/' + ansible_hostname + '.internal untainted' not in taint_result.stdout"
  when: not file_info.stat.exists

- name: Проверка инициализации
  shell: "export KUBECONFIG=/etc/kubernetes/admin.conf && kubectl get nodes"
  register: kubectl_output
  ignore_errors: true

- name: Инициализация завершена.
  debug:
    msg: 'Инициализация завершена! выполните комманду export KUBECONFIG=/etc/kubernetes/admin.conf, проверьте вывод команды kubectl get nodes'
  when: kubectl_output.rc == 0

Что в нем происходит.

Проверяем есть ли файл конфига. логика проста, если файл есть то с большой долей вероятности инициализация уже была и отчасти успешна. в этом случае если не работает, то лучше убить ноду и собрать заново.

Если же файла нет, то в зависимости от движка идёт команда инициализации (для докера она идёт с доп параметрами).

Устанавливается сетевой плагин, снимаются ограничения и проверяется установка.

Всё, стенд готов.

Теперь давай пробежимся по ha_cluster.

Тут всё немного сложнее.

Первое что надо сделать это подготовить ноды, а именно настроить keepalived и haproxy, для обеспечения отказоустойчивости и балансировки нагрузки.

roles/k8s_ha_cluster/tasks/ha_cluster_prepare_managers.yml

- name: Синхронизация даты/времени с NTP сервером
  shell: ntpdate 0.europe.pool.ntp.org

- name: Копируем настройку демона keepalived
  template:
    src: templates/keepalived.conf.j2
    dest: /etc/keepalived/keepalived.conf
    mode: '0644'

- name: Копируем скрипт check_apiserver.sh, предназначенный для проверки доступности серверов.
  template:
    src: templates/check_apiserver.sh.j2
    dest: /etc/keepalived/check_apiserver.sh
    mode: '0755'

- name: запуск службы keepalived
  systemd:
    name: keepalived
    enabled: yes
    state: restarted

- name: Копируем настройку демона haproxy
  template:
    src: templates/haproxy.cfg.j2
    dest: /etc/haproxy/haproxy.cfg
    mode: '0644'

- name: запуск службы haproxy
  systemd:
    name: haproxy
    enabled: yes
    state: restarted

Вторым шагом удет установка первой ноды. важный процесс, ибо после инициализации кубера он выдает команды для добавления новых хостов, которые нам надо будет передать на выполнение следующим нодам.

В целом суть та же, проверяем конфиг, если его нет, то делаем инициализацию. для docker команда чуть побольше.

После инициализации фильтруем вывод регуляркой и сохраняем для передачи остальным нодам. экспортируем конфиг, устанавливаем сетевой плагин и идём дальше.

ha_cluster_first_node.yml:
- name: Проверка наличия файла конфига
  stat:
    path: /etc/kubernetes/admin.conf
  register: file_info

- name: Инициализация кластера если конфиг не обнаружен.
  block:
    - name: Инициализация кластера для движков ['container-d', 'cri-o']
      shell: |
        kubeadm init \
                --pod-network-cidr=10.244.0.0/16 \
                --control-plane-endpoint "172.30.0.210:8888" \
                --upload-certs
      register: init_output_containerd_crio
      when: engine in ['container-d', 'cri-o']


    - name: Инициализация кластера для движка ['docker']
      shell: |
        kubeadm init \
                --cri-socket unix:///var/run/cri-dockerd.sock \
                --pod-network-cidr=10.244.0.0/16 \
                --control-plane-endpoint "172.30.0.210:8888" \
                --upload-certs
      register: init_output_docker
      when: engine == 'docker'

    - name: Сохранение значения init_output для дальнейшего использования
      set_fact:
        init_output: "{{ init_output_containerd_crio if init_output_containerd_crio is defined and init_output_containerd_crio.stdout is defined else init_output_docker }}"

    - name: Фильтрация вывода kubeadm init
      set_fact:
        filtered_output: "{{ init_output.stdout | regex_replace('(\\n|\\t|\\\\n|\\\\)', ' ') }}"

    - name: Фильтр комманд для добавления управляющих и рабочих нод
      set_fact:
        control_plane_join_command: "{{ filtered_output | regex_search('kubeadm join(.*?--discovery-token-ca-cert-hash\\s+sha256:[\\w:]+.*?--control-plane.*?--certificate-key.*?[\\w:]+)')}}"
        worker_join_command: "{{ filtered_output | regex_search('kubeadm join(.*?--discovery-token-ca-cert-hash\\s+sha256:[\\w:]+)')}}"

    - name: Установка KUBECONFIG в enviroment
      lineinfile:
        dest: /etc/environment
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

    - name: Установка KUBECONFIG в bashrc
      lineinfile:
        dest: '~/.bashrc'
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

    - name: Подождем пока всё запустится
      wait_for:
        host: localhost
        port: 6443
        timeout: 300

    - name: Установка сетевого плагина Flannel
      shell: kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/master/Documentation/kube-flannel.yml

  when: not file_info.stat.exists

После успешной инициализации кластера и получения команд для добавления нод, мы используем эти команды чтобы добавить управляющие и рабочие ноды.

И снова ветвление ибо у докера есть доп параметры при установке.

Есть нюанс, управляющие ноды иногда по неизвестной мне причине не добавлялись. при этом при повторном запуске команды всё проходило нормально. Поэтому я добавил 5 попыток подключения. обычно хватает двух.

С воркерами такого не наблюдалось, однако я всё равно добавил те же 5 попыток.

Воркер или управляющая нода определяется из группы в inventory.

Готово, перезагружаем все машины и ждем загрузки.

Последний play скопирует конфиг с первой ноды на вашу локальную машину. чтобы можно было управлять кластером непосредственно с хоста. он так же опционален, можно просто зайти на первую ноду и запускать деплои оттуда.

- name: Настройка хостовой машины, чтобы не лазить постоянно на виртуальные.
  hosts: localhost
  gather_facts: false
  tasks:
    - name: Переместить файл
      ansible.builtin.file:
        src: /tmp/admin.conf
        dest: /etc/kubernetes/admin.conf
        state: link
        force: yes
      become: true

    - name: Установка KUBECONFIG в enviroment
      lineinfile:
        dest: /etc/environment
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

    - name: Установка KUBECONFIG в bashrc
      lineinfile:
        dest: '~/.bashrc'
        line: 'export KUBECONFIG=/etc/kubernetes/admin.conf'

Бонусом идёт удаление стенда. раз он быстро создаётся то должен быстро и исчезать.

Удаляются только ВМ их диски и снапшоты, шаблоны и образы остаются в каталогах хранения.

remove_stand.yml:

---
  - name: Удаление стенда kubernetes
    hosts: localhost
    become: trueс
    vars_files:
      - my_vars.yml
    tasks:
      - name: Получаем список существующих ВМ
        community.libvirt.virt:
          command: list_vms
        register: existing_vms
        changed_when: no

      - name: Удаление машин
        block:
          - name: Полностью останавливаем ВМ
            community.libvirt.virt:
              command: destroy
              name: "{{ item.name }}"
            loop: "{{ vm_info.vm_names }}"
            when: "item.name in existing_vms.list_vms"
            ignore_errors: true

          - name: Удаляем снапшоты
            shell: |
              virsh snapshot-delete --domain {{ item.name }} --snapshotname host_provision
              virsh snapshot-delete --domain {{ item.name }} --snapshotname driver_provision
            ignore_errors: true
            loop: "{{ vm_info.vm_names }}"
            when: "item.name in existing_vms.list_vms"

          - name: Отменяем регистрацию ВМ
            community.libvirt.virt:
              command: undefine
              name: "{{ item.name }}"
            loop: "{{ vm_info.vm_names }}"
            when: "item.name in existing_vms.list_vms"

          - name: Удаление диска виртуальной машины
            ansible.builtin.file:
              path: "{{libvirt_pool_dir}}/{{ item.name }}.qcow2"
              state: absent
            loop: "{{ vm_info.vm_names }}"
            when: "item.name in existing_vms.list_vms"

В целом всё готово. можно запускать.

Установка стенда:

ansible-playbook -K ./setup_k8s.yaml -i ./inventory --extra-vars "@my_vars.yml"

Удаление стенда:

ansible-playbook -K ./remove_stand.yml

В общем и целом я просто перенёс готовый гайд на рельсы автоматизации, отсебятины я добавил по минимуму. где то иначе добавляются репозитории и ключи, где то запускаю синхронизацию времени, из за того что при восстановлении со снапшота у меня начинались проблемы с ключами из за неверной текущей даты.

Добавляю ссылку на репозиторий со всем этим добром.

В решении я попробовал много различных элементов управления ansible, создание каталогов, циклы, ветвления, установки и прочие кирпичи из которых вырастает система.

Буду рад если вы подскажете какие решения были удачными, а какие не очень. эта информация будет очень полезна для меня.

Спасибо что осилили и прочли до конца!)

Отдельное спасибо @imbasoft за отличную и понятную статью.

© Habrahabr.ru