feat: аддоны через addons.yml, внешний etcd, управление etcd нодами

## Аддоны (group_vars/all/addons.yml)

- Создан group_vars/all/addons.yml — единое место для включения/отключения
  аддонов (addon_ingress_nginx: true/false и т.д.) и их основных настроек
- Из group_vars/all/main.yml убраны все секции аддонов (NFS, CSI, ingress,
  cert-manager, etcd backup, Istio, Prometheus) — остался только core кластер
- Создан playbooks/addons.yml — комбинированный плейбук с 10 плеями,
  каждый с `when: addon_X | default(false) | bool`; запускает только включённые
- make install-full: core (site.yml) + аддоны по addons.yml
- make install-addons: только аддоны без переустановки core
- Убраны все *_enabled флаги из аддонов (cert_manager_enabled, istio_enabled,
  prometheus_stack_enabled и др.) — аддон ставится явным вызовом
- kube-vip: убран skip guard и kube_vip_enabled флаг (core, всегда ставится)
- TLS defaults в argocd/longhorn/kubernetes-dashboard: убрана зависимость
  от cert_manager_enabled, теперь просто false (задаётся явно)
- Kiali: убрана зависимость от prometheus_stack_enabled, добавлены переменные
  kiali_prometheus_enabled/url и kiali_grafana_enabled/url

## Внешний etcd кластер

- Новая переменная k3s_etcd_type: embedded|external в main.yml
- inventory/hosts.ini: добавлена группа [etcd_nodes] — любые серверы,
  не обязательно мастера
- roles/etcd/: полная роль для установки внешнего etcd кластера:
  - install.yml — скачивает бинарник, создаёт пользователя и директории
  - pki.yml — генерирует CA + server/peer/client сертификаты через openssl
    на Ansible-контроллере; раскладывает на etcd ноды и k3s мастера
  - service.yml — разворачивает etcd.env и systemd сервис, проверяет здоровье
  - etcd.env.j2 и etcd.service.j2 — шаблоны конфигурации
  - etcd_pki_local_dir: persistent путь (<project>/etcd-pki/) вместо /tmp,
    etcd-pki/ добавлен в .gitignore
- roles/k3s/templates/k3s-server-config.yaml.j2: при external режиме
  подставляет datastore-endpoint со всеми etcd нодами + пути к клиентским
  сертификатам; при embedded — прежняя логика cluster-init
- playbooks/site.yml: условный плей для etcd перед k3s (тег etcd)
- make install-etcd: отдельная команда для развёртывания etcd кластера

## Управление etcd нодами

- playbooks/add-etcd-node.yml: добавить ноду в работающий etcd кластер
  (PKI генерация → install → etcdctl member add → start с state=existing → verify)
- playbooks/remove-etcd-node.yml: безопасно удалить ноду из etcd кластера
  (проверка кворума → member remove → stop → clean up PKI)
- playbooks/add-node.yml: при k3s_etcd_type=external и наличии ноды в
  [etcd_nodes] автоматически добавляет её в etcd кластер после k3s
- playbooks/remove-node.yml: при k3s_etcd_type=external сначала удаляет
  ноду из etcd (member remove + stop), затем из k3s
- make add-etcd-node NODE=etcd04 / make remove-etcd-node NODE=etcd04
- Команды add-etcd-node / remove-etcd-node в docker/entrypoint.sh
This commit is contained in:
Sergey Antropoff
2026-04-25 06:34:48 +03:00
parent 8aa55a694c
commit a94039e0f1
30 changed files with 1301 additions and 169 deletions

View File

@@ -1,13 +1,32 @@
---
# Директория для снимков etcd на сервере
# ── Версия и бинарник ──────────────────────────────────────────────────────────
etcd_version: "v3.5.13"
etcd_arch: "{{ 'arm64' if ansible_architecture == 'aarch64' else 'amd64' }}"
etcd_download_url: "https://github.com/etcd-io/etcd/releases/download/{{ etcd_version }}/etcd-{{ etcd_version }}-linux-{{ etcd_arch }}.tar.gz"
# ── Директории ─────────────────────────────────────────────────────────────────
etcd_install_dir: "/usr/local/bin"
etcd_data_dir: "/var/lib/etcd"
etcd_config_dir: "/etc/etcd"
etcd_pki_dir: "/etc/etcd/pki"
# ── Директория для PKI на Ansible-хосте ───────────────────────────────────────
# Хранится рядом с inventory — нужна при добавлении новых etcd нод (CA переиспользуется).
# Добавь etcd-pki/ в .gitignore (CA ключ — секрет!).
etcd_pki_local_dir: "{{ inventory_dir | dirname }}/etcd-pki"
# ── Сеть ───────────────────────────────────────────────────────────────────────
etcd_peer_port: 2380
etcd_client_port: 2379
# ── Кластер ────────────────────────────────────────────────────────────────────
etcd_initial_cluster_token: "k3s-etcd-cluster"
etcd_initial_cluster_state: "new" # new | existing
# ── Backup (embedded etcd) ─────────────────────────────────────────────────────
# Эти значения переопределяются в group_vars/all/addons.yml
etcd_backup_dir: "{{ k3s_data_dir | default('/var/lib/kubernetes/k3s') }}/server/db/snapshots"
# Количество снимков для хранения (удаляет старые при превышении)
etcd_backup_retention: 5
# Скопировать снимок на локальную машину (откуда запускается Ansible)
etcd_backup_copy_to_local: false
etcd_backup_local_dir: "./etcd-backups"
# Имя снимка (пусто = автогенерация по дате)
etcd_backup_name: ""

View File

@@ -0,0 +1,9 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
- name: Restart etcd
ansible.builtin.systemd:
name: etcd
state: restarted

View File

@@ -0,0 +1,50 @@
---
- name: Check if etcd is already installed
ansible.builtin.stat:
path: "{{ etcd_install_dir }}/etcd"
register: etcd_binary
- name: Download etcd archive
ansible.builtin.get_url:
url: "{{ etcd_download_url }}"
dest: "/tmp/etcd-{{ etcd_version }}-linux-{{ etcd_arch }}.tar.gz"
mode: '0644'
when: not etcd_binary.stat.exists
- name: Extract etcd binaries
ansible.builtin.unarchive:
src: "/tmp/etcd-{{ etcd_version }}-linux-{{ etcd_arch }}.tar.gz"
dest: /tmp
remote_src: true
creates: "/tmp/etcd-{{ etcd_version }}-linux-{{ etcd_arch }}/etcd"
when: not etcd_binary.stat.exists
- name: Install etcd and etcdctl
ansible.builtin.copy:
src: "/tmp/etcd-{{ etcd_version }}-linux-{{ etcd_arch }}/{{ item }}"
dest: "{{ etcd_install_dir }}/{{ item }}"
mode: '0755'
remote_src: true
loop:
- etcd
- etcdctl
when: not etcd_binary.stat.exists
- name: Create etcd system user
ansible.builtin.user:
name: etcd
system: true
shell: /sbin/nologin
create_home: false
- name: Create etcd directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: etcd
group: etcd
mode: '0700'
loop:
- "{{ etcd_data_dir }}"
- "{{ etcd_config_dir }}"
- "{{ etcd_pki_dir }}"

View File

@@ -0,0 +1,9 @@
---
- name: Install etcd binary
ansible.builtin.include_tasks: install.yml
- name: Generate PKI certificates
ansible.builtin.include_tasks: pki.yml
- name: Configure and start etcd service
ansible.builtin.include_tasks: service.yml

148
roles/etcd/tasks/pki.yml Normal file
View File

@@ -0,0 +1,148 @@
---
# Генерирует PKI на Ansible-контроллере, затем раскладывает по нодам.
# Использует openssl — не требует дополнительных Python-пакетов.
- name: Create local PKI directory
ansible.builtin.file:
path: "{{ etcd_pki_local_dir }}"
state: directory
mode: '0700'
delegate_to: localhost
run_once: true
become: false
# ── CA ─────────────────────────────────────────────────────────────────────────
- name: Generate CA private key
ansible.builtin.shell: |
openssl genrsa -out "{{ etcd_pki_local_dir }}/ca.key" 4096
args:
creates: "{{ etcd_pki_local_dir }}/ca.key"
executable: /bin/bash
delegate_to: localhost
run_once: true
become: false
- name: Generate CA certificate
ansible.builtin.shell: |
openssl req -new -x509 -days 3650 \
-key "{{ etcd_pki_local_dir }}/ca.key" \
-out "{{ etcd_pki_local_dir }}/ca.crt" \
-subj "/CN=etcd-ca/O=etcd"
args:
creates: "{{ etcd_pki_local_dir }}/ca.crt"
executable: /bin/bash
delegate_to: localhost
run_once: true
become: false
# ── Server + peer certs для каждой etcd ноды ──────────────────────────────────
- name: Generate server/peer certs for each etcd node
ansible.builtin.shell: |
set -e
NODE="{{ item }}"
NODE_IP="{{ hostvars[item]['ansible_host'] }}"
PKI="{{ etcd_pki_local_dir }}"
SAN="subjectAltName=IP:${NODE_IP},IP:127.0.0.1,DNS:${NODE},DNS:localhost"
# server cert
openssl genrsa -out "${PKI}/server-${NODE}.key" 2048
openssl req -new -key "${PKI}/server-${NODE}.key" -out "${PKI}/server-${NODE}.csr" \
-subj "/CN=${NODE}/O=etcd"
openssl x509 -req -days 3650 \
-in "${PKI}/server-${NODE}.csr" -CA "${PKI}/ca.crt" -CAkey "${PKI}/ca.key" \
-CAcreateserial -out "${PKI}/server-${NODE}.crt" \
-extfile <(printf "${SAN}")
# peer cert
openssl genrsa -out "${PKI}/peer-${NODE}.key" 2048
openssl req -new -key "${PKI}/peer-${NODE}.key" -out "${PKI}/peer-${NODE}.csr" \
-subj "/CN=${NODE}/O=etcd-peer"
openssl x509 -req -days 3650 \
-in "${PKI}/peer-${NODE}.csr" -CA "${PKI}/ca.crt" -CAkey "${PKI}/ca.key" \
-CAcreateserial -out "${PKI}/peer-${NODE}.crt" \
-extfile <(printf "${SAN}")
args:
creates: "{{ etcd_pki_local_dir }}/server-{{ item }}.crt"
executable: /bin/bash
loop: "{{ groups['etcd_nodes'] }}"
delegate_to: localhost
run_once: true
become: false
# ── Client cert для k3s ────────────────────────────────────────────────────────
- name: Generate k3s client certificate
ansible.builtin.shell: |
set -e
PKI="{{ etcd_pki_local_dir }}"
openssl genrsa -out "${PKI}/client.key" 2048
openssl req -new -key "${PKI}/client.key" -out "${PKI}/client.csr" \
-subj "/CN=k3s-etcd-client/O=k3s"
openssl x509 -req -days 3650 \
-in "${PKI}/client.csr" -CA "${PKI}/ca.crt" -CAkey "${PKI}/ca.key" \
-CAcreateserial -out "${PKI}/client.crt"
args:
creates: "{{ etcd_pki_local_dir }}/client.crt"
executable: /bin/bash
delegate_to: localhost
run_once: true
become: false
# ── Раскладываем серверные сертификаты на etcd ноды ───────────────────────────
- name: Copy CA cert to etcd nodes
ansible.builtin.copy:
src: "{{ etcd_pki_local_dir }}/ca.crt"
dest: "{{ etcd_pki_dir }}/ca.crt"
owner: etcd
group: etcd
mode: '0644'
- name: Copy server/peer certificates to etcd nodes
ansible.builtin.copy:
src: "{{ etcd_pki_local_dir }}/{{ item.src }}"
dest: "{{ etcd_pki_dir }}/{{ item.dest }}"
owner: etcd
group: etcd
mode: "{{ item.mode }}"
loop:
- { src: "server-{{ inventory_hostname }}.crt", dest: "server.crt", mode: "0644" }
- { src: "server-{{ inventory_hostname }}.key", dest: "server.key", mode: "0600" }
- { src: "peer-{{ inventory_hostname }}.crt", dest: "peer.crt", mode: "0644" }
- { src: "peer-{{ inventory_hostname }}.key", dest: "peer.key", mode: "0600" }
notify: Restart etcd
# ── Раскладываем клиентские сертификаты на k3s мастера ────────────────────────
- name: Create etcd PKI directory on k3s masters
ansible.builtin.file:
path: "{{ k3s_config_dir }}/etcd"
state: directory
owner: root
group: root
mode: '0700'
delegate_to: "{{ master_host }}"
loop: "{{ groups['k3s_master'] }}"
loop_control:
loop_var: master_host
run_once: true
- name: Copy CA + client certs to k3s masters
ansible.builtin.copy:
src: "{{ etcd_pki_local_dir }}/{{ item.1.src }}"
dest: "{{ k3s_config_dir }}/etcd/{{ item.1.dest }}"
owner: root
group: root
mode: "{{ item.1.mode }}"
loop: "{{ groups['k3s_master'] | product(etcd_client_certs) | list }}"
loop_control:
label: "{{ item.0 }}: {{ item.1.dest }}"
vars:
etcd_client_certs:
- { src: "ca.crt", dest: "ca.crt", mode: "0644" }
- { src: "client.crt", dest: "client.crt", mode: "0644" }
- { src: "client.key", dest: "client.key", mode: "0600" }
delegate_to: "{{ item.0 }}"
run_once: true

View File

@@ -0,0 +1,70 @@
---
- name: Build initial cluster members string
ansible.builtin.set_fact:
etcd_initial_cluster_members: >-
{%- set members = [] -%}
{%- for h in groups['etcd_nodes'] -%}
{%- set _ = members.append(h ~ '=https://' ~ hostvars[h]['ansible_host'] ~ ':' ~ etcd_peer_port) -%}
{%- endfor -%}
{{ members | join(',') }}
- name: Deploy etcd environment config
ansible.builtin.template:
src: etcd.env.j2
dest: "{{ etcd_config_dir }}/etcd.env"
owner: etcd
group: etcd
mode: '0640'
notify: Restart etcd
- name: Deploy etcd systemd service
ansible.builtin.template:
src: etcd.service.j2
dest: /etc/systemd/system/etcd.service
mode: '0644'
notify:
- Reload systemd
- Restart etcd
- name: Enable and start etcd
ansible.builtin.systemd:
name: etcd
enabled: true
state: started
daemon_reload: true
- name: Wait for etcd to be healthy
ansible.builtin.shell: |
ETCDCTL_API=3 etcdctl \
--endpoints="https://{{ ansible_host }}:{{ etcd_client_port }}" \
--cacert="{{ etcd_pki_dir }}/ca.crt" \
--cert="{{ etcd_pki_dir }}/server.crt" \
--key="{{ etcd_pki_dir }}/server.key" \
endpoint health
register: etcd_health
until: etcd_health.rc == 0
retries: 20
delay: 5
changed_when: false
- name: Show etcd cluster members
ansible.builtin.shell: |
ETCDCTL_API=3 etcdctl \
--endpoints="{{ etcd_client_endpoints }}" \
--cacert="{{ etcd_pki_dir }}/ca.crt" \
--cert="{{ etcd_pki_dir }}/server.crt" \
--key="{{ etcd_pki_dir }}/server.key" \
member list -w table
register: etcd_members
run_once: true
changed_when: false
vars:
etcd_client_endpoints: >-
{{ groups['etcd_nodes'] | map('extract', hostvars, 'ansible_host')
| map('regex_replace', '^(.*)$', 'https://\1:' ~ etcd_client_port)
| join(',') }}
- name: Display etcd members
ansible.builtin.debug:
msg: "{{ etcd_members.stdout_lines }}"
run_once: true

View File

@@ -0,0 +1,29 @@
# etcd environment — generated by Ansible
ETCD_NAME="{{ inventory_hostname }}"
ETCD_DATA_DIR="{{ etcd_data_dir }}"
# Peer (inter-cluster) communication
ETCD_INITIAL_ADVERTISE_PEER_URLS="https://{{ ansible_host }}:{{ etcd_peer_port }}"
ETCD_LISTEN_PEER_URLS="https://0.0.0.0:{{ etcd_peer_port }}"
# Client API
ETCD_ADVERTISE_CLIENT_URLS="https://{{ ansible_host }}:{{ etcd_client_port }}"
ETCD_LISTEN_CLIENT_URLS="https://0.0.0.0:{{ etcd_client_port }},https://127.0.0.1:{{ etcd_client_port }}"
# Cluster bootstrap
ETCD_INITIAL_CLUSTER="{{ etcd_initial_cluster_members }}"
ETCD_INITIAL_CLUSTER_TOKEN="{{ etcd_initial_cluster_token }}"
ETCD_INITIAL_CLUSTER_STATE="{{ etcd_initial_cluster_state }}"
# TLS — server (для клиентских подключений)
ETCD_CERT_FILE="{{ etcd_pki_dir }}/server.crt"
ETCD_KEY_FILE="{{ etcd_pki_dir }}/server.key"
ETCD_CLIENT_CERT_AUTH="true"
ETCD_TRUSTED_CA_FILE="{{ etcd_pki_dir }}/ca.crt"
# TLS — peer (между etcd нодами)
ETCD_PEER_CERT_FILE="{{ etcd_pki_dir }}/peer.crt"
ETCD_PEER_KEY_FILE="{{ etcd_pki_dir }}/peer.key"
ETCD_PEER_CLIENT_CERT_AUTH="true"
ETCD_PEER_TRUSTED_CA_FILE="{{ etcd_pki_dir }}/ca.crt"

View File

@@ -0,0 +1,17 @@
[Unit]
Description=etcd key-value store
Documentation=https://etcd.io/docs
After=network.target
[Service]
Type=notify
EnvironmentFile={{ etcd_config_dir }}/etcd.env
ExecStart={{ etcd_install_dir }}/etcd
Restart=on-failure
RestartSec=5s
LimitNOFILE=65536
User=etcd
Group=etcd
[Install]
WantedBy=multi-user.target

View File

@@ -16,12 +16,23 @@ write-kubeconfig: "{{ k3s_kubeconfig_path }}"
write-kubeconfig-mode: "0644"
data-dir: "{{ k3s_data_dir }}"
{% if k3s_etcd_type | default('embedded') == 'external' %}
# Внешний etcd кластер
datastore-endpoint: "{% for h in groups['etcd_nodes'] %}https://{{ hostvars[h]['ansible_host'] }}:2379{% if not loop.last %},{% endif %}{% endfor %}"
datastore-cafile: "{{ k3s_config_dir }}/etcd/ca.crt"
datastore-certfile: "{{ k3s_config_dir }}/etcd/client.crt"
datastore-keyfile: "{{ k3s_config_dir }}/etcd/client.key"
{% if inventory_hostname != groups['k3s_master'][0] or k3s_force_join | default(false) %}
server: "https://{{ k3s_join_address | default(hostvars[groups['k3s_master'][0]]['ansible_host']) }}:6443"
{% endif %}
{% else %}
# HA embedded etcd: первый сервер инициализирует кластер, остальные присоединяются
{% if inventory_hostname == groups['k3s_master'][0] and not k3s_force_join | default(false) %}
cluster-init: true
{% else %}
server: "https://{{ k3s_join_address | default(hostvars[groups['k3s_master'][0]]['ansible_host']) }}:6443"
{% endif %}
{% endif %}
{% if k3s_disable_traefik or k3s_disable_servicelb or k3s_disable_local_storage %}
disable:

View File

@@ -1,6 +1,5 @@
---
# Включить установку kube-vip (VIP + LoadBalancer для Services)
kube_vip_enabled: true
# Версия kube-vip
kube_vip_version: "v0.8.3"

View File

@@ -1,8 +1,4 @@
---
- name: Skip kube-vip if not enabled
ansible.builtin.meta: end_play
when: not kube_vip_enabled | default(true) | bool
- name: Resolve kube-vip network interface
ansible.builtin.set_fact:
_kube_vip_iface: "{{ kube_vip_interface if kube_vip_interface | length > 0 else ansible_default_ipv4.interface | default('eth0') }}"