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:
- Pre-tasks: подготовка OS (swap, модули ядра, sysctl, лимиты)
- Установка k3s
- Установка Cilium (CNI должен быть раньше, чем узел перейдёт в Ready)
- 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-opsysctl— 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.