Ansible playbook для k3s: sysctl, inotify, swap и модули ядра

Published: 2026-02-03

Установить k3s на чистую виртуалку — это пять команд. Сделать это идемпотентно, воспроизводимо и безопасно для 200+ подов с .NET-приложениями — это уже Ansible playbook. Этот пост — полный разбор чеклиста предварительных задач, аргументов установки k3s и последовательности bootstrap Cilium, с обоснованием каждого решения.


Зачем вообще playbook

Когда у вас шесть кластеров, повторять одни и те же ручные шаги на каждом узле — это риск. Пропущенный sysctl — это кластер, который выглядит здоровым, но теряет трафик под нагрузкой. Строчка swap в /etc/fstab — узел, который ломается после первой перезагрузки VM. Playbook превращает негласные знания команды в код: он выполняется одинаково каждый раз и оставляет diff в git при любом изменении.

Структура playbook:

  1. Pre-tasks: подготовка OS (swap, модули ядра, sysctl, лимиты)
  2. Установка k3s
  3. Установка Cilium (CNI должен быть раньше, чем узел перейдёт в Ready)
  4. Post-tasks: ожидание Ready, копирование kubeconfig на control host

Предварительные задачи перед установкой k3s

Отключение swap

Kubernetes отказывается запускаться на узле с включённым swap. Kubelet проверяет swap при старте и завершается, если находит его, — если явно не передать --fail-swap-on=false, чего мы не делаем. Отключить swap на работающей системе недостаточно — нужно ещё убрать его из /etc/fstab, иначе после перезагрузки он вернётся:

yaml- name: Disable swap
  ansible.builtin.shell: swapoff -a
  changed_when: false

- name: Remove swap from /etc/fstab
  ansible.builtin.replace:
    path: /etc/fstab
    regexp: '^([^#].*\s+swap\s+.*)$'
    replace: '# \1'

Regexp комментирует любую незакомментированную строку, содержащую swap как тип файловой системы. Он обрабатывает как swap-файл, так и swap-раздел. Строка комментируется, а не удаляется, чтобы причина отключения была видна в истории файла при диагностике.

Если VM созданы из облачного образа, где swap вынесен на отдельное устройство (характерно для некоторых образов Yandex Cloud), после выполнения задачи также проверьте, что swapon --show ничего не возвращает.

Модули ядра

Cilium и сетевой стек k3s требуют br_netfilter (чтобы bridge-трафик проходил через iptables) и overlay (слои файловой системы контейнеров):

yaml- name: Load kernel modules
  community.general.modprobe:
    name: "{{ item }}"
    state: present
  loop:
    - br_netfilter
    - overlay

- name: Persist kernel modules
  ansible.builtin.copy:
    dest: /etc/modules-load.d/k3s.conf
    content: |
      br_netfilter
      overlay
    mode: "0644"

Задача modprobe загружает модули для текущей сессии. Задача copy сохраняет их через modules-load.d, чтобы они выживали после перезагрузки. Обе задачи необходимы — modprobe без modules-load.d не переживёт перезагрузку, а modules-load.d без modprobe не загрузит модули немедленно.

На некоторых минималистичных образах (особенно stripped-down облачных VM) overlay может быть встроен в ядро, а не скомпилирован как модуль. В этом случае modprobe вернёт ошибку. Можно защититься:

yaml- name: Load overlay module (may be built-in)
  community.general.modprobe:
    name: overlay
    state: present
  ignore_errors: true

Проверьте результат: lsmod | grep overlay. Если модуль встроен, lsmod не покажет его, но он там есть.

sysctl для сети и Cilium

yaml- name: Set sysctl params
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_set: true
    reload: true
  loop:
    - { key: net.bridge.bridge-nf-call-iptables,  value: "1" }
    - { key: net.bridge.bridge-nf-call-ip6tables, value: "1" }
    - { key: net.ipv4.ip_forward,                 value: "1" }
    # Cilium: без этого rp_filter отбрасывает легитимный трафик подов
    - { key: net.ipv4.conf.all.rp_filter,         value: "0" }
    - { key: net.ipv4.conf.default.rp_filter,     value: "0" }

Что делает каждый параметр:

net.bridge.bridge-nf-call-iptables: 1 — когда пакет проходит через bridge, он также должен пройти через правила iptables. Без этого сетевые политики через iptables обходятся для bridge-трафика. Требуется даже когда Cilium использует eBPF, потому что k3s создаёт bridge-интерфейсы при старте.

net.ipv4.ip_forward: 1 — включает форвардинг пакетов между интерфейсами. Именно это позволяет подам на узле A достигать подов на узле B: узел выступает как маршрутизатор. Без этого межузловой трафик подов молча отбрасывается на уровне ядра.

net.ipv4.conf.all.rp_filter: 0 и net.ipv4.conf.default.rp_filter: 0 — самый частый источник проблем. Строгая фильтрация обратного пути отбрасывает пакеты, у которых source IP не совпадает с таблицей маршрутизации, — а при eBPF-маршрутизации Cilium это происходит постоянно.

В режиме kubeProxyReplacement=true таблица маршрутизации ядра не знает о виртуальных IP подов — ими управляет Cilium в eBPF. Если rp_filter строгий (1 или 2), ядро проверяет, есть ли маршрут обратно через тот же интерфейс для source IP входящего пакета, и отбрасывает пакет, если маршрута нет. Это приводит к нестабильным потерям пакетов, которые крайне сложно диагностировать с tcpdump, потому что дроп происходит до того, как пакет доходит до userspace.

Значение 0 отключает проверку. Некоторые руководства по безопасности помечают это как риск; в Kubernetes-окружении с Cilium это правильная настройка.

Лимиты inotify и файловых дескрипторов

.NET-приложения активно используют inotify для отслеживания конфигов (горячая перезагрузка конфигурации ASP.NET, IFileProvider) и управления сокетами. Стандартные лимиты ядра приводят к ошибкам EMFILE (слишком много открытых файлов) или ENOSPC (превышен лимит inotify watches) под нагрузкой:

yaml- name: Raise inotify and fd limits
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_set: true
    reload: true
  loop:
    - { key: fs.inotify.max_user_instances, value: "8192"    }
    - { key: fs.inotify.max_user_watches,   value: "1048576" }
    - { key: fs.inotify.max_queued_events,  value: "32768"   }
    - { key: fs.file-max,                   value: "1048576" }

fs.inotify.max_user_instances — максимальное число экземпляров inotify на пользователя. По умолчанию 128. При 200+ подах на узле, каждый из которых потенциально создаёт несколько экземпляров inotify, этот лимит достигается быстро. 8192 даёт запас.

fs.inotify.max_user_watches — максимальное число файлов, за которыми наблюдает один пользователь. По умолчанию 65536. ASP.NET-приложения, следящие за wwwroot и директориями конфигов, могут потреблять тысячи watches каждое. 1048576 (1M) — щедро, но не чрезмерно.

fs.inotify.max_queued_events — если ядро генерирует события быстрее, чем приложение их обрабатывает, события ставятся в очередь до этого лимита. Когда очередь заполняется, генерируется событие IN_Q_OVERFLOW, и последующие события теряются. 32768 предотвращает это при нормальной нагрузке.

fs.file-max — общесистемный лимит файловых дескрипторов. Это отдельно от лимита per-process (ulimit -n). При множестве контейнеров, каждый из которых держит открытыми сокеты и файлы, системный лимит может быть исчерпан. 1048576 — это 1M дескрипторов на всю систему.

Эти параметры живут в group_vars/all.yaml, чтобы быть одинаковыми на всех кластерах.


Аргументы установки k3s

k3s устанавливается с отключёнными Flannel и kube-proxy — их заменяет Cilium:

yaml- name: Install k3s
  ansible.builtin.shell: |
    curl -sfL {{ k3s_install_url }} | \
      INSTALL_K3S_VERSION={{ k3s_version }} \
      K3S_TOKEN={{ k3s_token }} \
      sh -s - \
        --flannel-backend=none \
        --disable-kube-proxy \
        --disable traefik \
        --disable servicelb \
        --kubelet-arg=max-pods={{ k3s_max_pods }}
  args:
    creates: /usr/local/bin/k3s

creates: делает задачу идемпотентной — если бинарник уже есть, задача пропускается.

Что делает каждый флаг:

--flannel-backend=none — отключает встроенный CNI k3s (Flannel). Без этого k3s устанавливает Flannel и создаёт собственную конфигурацию CNI. Cilium нужна чистая среда: никакой существующей конфигурации в /etc/cni/net.d/.

--disable-kube-proxy — отключает kube-proxy. Cilium в режиме kubeProxyReplacement=true реализует маршрутизацию Service через eBPF, что эффективнее и даёт лучшую наблюдаемость. Запуск обоих вызвал бы конфликты.

--disable traefik — убирает стандартный ingress-контроллер. Мы используем APISIX или nginx-ingress через Helm — это даёт больше контроля над конфигурацией и сроками обновления.

--disable servicelb — убирает Klipper ServiceLB, встроенную реализацию LoadBalancer в k3s. Вместо неё используются L2-анонсы Cilium, интегрированные с нашей сетевой конфигурацией.

--kubelet-arg=max-pods={{ k3s_max_pods }} — по умолчанию 110 подов на узел. С k3s_max_pods = 250 в group_vars/ узлы без проблем обрабатывают 200+ подов. Увеличивайте это значение, только если у узла хватает RAM — у каждого пода есть базовые накладные расходы (~10–20 МБ в зависимости от sidecar).

Переменные

yaml# group_vars/k3s_nodes.yaml
k3s_version: v1.29.4+k3s1
k3s_install_url: https://get.k3s.io
k3s_token: "{{ vault_k3s_token }}"  # из Ansible Vault
k3s_max_pods: 250
cilium_version: 1.15.4
node_ip: "{{ ansible_default_ipv4.address }}"
k3s_node_name: "{{ inventory_hostname }}"

k3s_token берётся из Ansible Vault. Никогда не храните токены в открытом виде в group_vars — даже во внутренних репозиториях.


Установка Cilium до ожидания Ready на узле

Узел не перейдёт в состояние Ready, пока не появится CNI. Поэтому Cilium необходимо установить сразу после k3s, до начала bootstrap FluxCD:

yaml- name: Install Cilium via Helm
  ansible.builtin.shell: |
    helm upgrade --install cilium cilium/cilium \
      --version {{ cilium_version }} \
      --namespace kube-system \
      --set ipam.mode=kubernetes \
      --set kubeProxyReplacement=true \
      --set k8sServiceHost={{ node_ip }} \
      --set k8sServicePort=6443 \
      --set operator.replicas=1 \
      --set l2announcements.enabled=true \
      --set hubble.enabled=true \
      --set hubble.relay.enabled=true \
      --set hubble.ui.enabled=true
  environment:
    KUBECONFIG: /etc/rancher/k3s/k3s.yaml

k8sServiceHost и k8sServicePort указывают Cilium, где находится Kubernetes API — это необходимо при отключённом kube-proxy, потому что Cilium не может полагаться на доступность ClusterIP сервиса kubernetes до настройки eBPF.

operator.replicas=1 подходит для single-node кластеров. Для multi-node — ставьте 2 для HA.

Затем ждём DaemonSet и узел:

yaml- name: Wait for Cilium DaemonSet to be ready
  ansible.builtin.shell: |
    kubectl -n kube-system rollout status daemonset/cilium --timeout=120s
  environment:
    KUBECONFIG: /etc/rancher/k3s/k3s.yaml
  retries: 5
  delay: 15
  register: cilium_ready
  until: cilium_ready.rc == 0
  changed_when: false

- name: Wait for node Ready
  ansible.builtin.shell: |
    k3s kubectl get node {{ k3s_node_name }} \
      -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}'
  register: node_ready
  retries: 30
  delay: 10
  until: node_ready.stdout == "True"
  changed_when: false

Сначала ждём rollout DaemonSet, затем состояние узла. Если пропустить ожидание DaemonSet и сразу перейти к node Ready, можно получить ложноположительный результат: узел способен показать Ready в короткий промежуток до того, как Cilium применит сетевые политики.


Post-install: получение kubeconfig

После готовности узла копируем kubeconfig на Ansible control host для немедленного использования:

yaml- name: Fetch kubeconfig
  ansible.builtin.fetch:
    src: /etc/rancher/k3s/k3s.yaml
    dest: "{{ playbook_dir }}/kubeconfigs/{{ inventory_hostname }}.yaml"
    flat: true

- name: Update server address in kubeconfig
  ansible.builtin.replace:
    path: "{{ playbook_dir }}/kubeconfigs/{{ inventory_hostname }}.yaml"
    regexp: 'https://127\.0\.0\.1:6443'
    replace: "https://{{ node_ip }}:6443"
  delegate_to: localhost

k3s записывает 127.0.0.1 в качестве адреса сервера. Заменяем на реальный IP узла, чтобы kubeconfig работал снаружи узла.


Идемпотентность

Каждая задача идемпотентна:

  • swapoff -a с changed_when: false — всегда завершается с кодом 0, изменения не сообщаются ложно
  • modprobe — если модуль уже загружен, no-op
  • sysctl — Ansible проверяет текущее значение, пишет только если оно отличается
  • Установка k3s защищена через creates: — пропускается, если бинарник существует
  • Helm upgrade --install — создаёт или обновляет, никогда не падает на существующем релизе

Запустите playbook дважды на одном узле: второй прогон ничего не изменит и займёт ~30 секунд.

Полный скелет playbook

yaml---
- name: Prepare node and install k3s with Cilium
  hosts: k3s_nodes
  become: true
  vars_files:
    - vault.yaml

  pre_tasks:
    - name: Disable swap
      # ...
    - name: Remove swap from /etc/fstab
      # ...
    - name: Load kernel modules
      # ...
    - name: Persist kernel modules
      # ...
    - name: Set sysctl params
      # ...
    - name: Raise inotify and fd limits
      # ...

  tasks:
    - name: Add Helm repo for Cilium
      ansible.builtin.shell: helm repo add cilium https://helm.cilium.io && helm repo update
      changed_when: false

    - name: Install k3s
      # ...

    - name: Install Cilium via Helm
      # ...

    - name: Wait for Cilium DaemonSet to be ready
      # ...

    - name: Wait for node Ready
      # ...

  post_tasks:
    - name: Fetch kubeconfig
      # ...
    - name: Update server address in kubeconfig
      # ...

Секция tasks намеренно короткая. Pre-tasks обрабатывают подготовку OS; bootstrap CNI — в tasks; post-tasks занимаются выходными артефактами. Эта структура позволяет при необходимости запускать только часть playbook с --tags.