--- # ───────────────────────────────────────────────────────────────────────────── # Безопасно удалить ноду из внешнего etcd кластера # # Использование: # make remove-etcd-node NODE=etcd04 — удалить ноду etcd04 # make remove-etcd-node NODE=master04 — удалить мастер-ноду из etcd участников # # ВНИМАНИЕ: # Нельзя удалить ноду если в кластере меньше 3 участников без потери кворума. # После удаления обнови inventory/hosts.ini — убери ноду из [etcd_nodes]. # # Порядок: # 1. Валидация (кворум, не последняя нода) # 2. etcdctl member remove # 3. Остановить etcd на ноде # 4. Очистить данные и сертификаты # 5. Верификация оставшегося кластера # ───────────────────────────────────────────────────────────────────────────── # ── Валидация ───────────────────────────────────────────────────────────────── - name: Validate and check quorum hosts: "{{ (groups['etcd_nodes'] | reject('equalto', node_to_remove | default('')) | list)[0] | default('localhost') }}" gather_facts: false become: true tags: [always] tasks: - name: Check node_to_remove is specified ansible.builtin.assert: that: node_to_remove is defined and node_to_remove | length > 0 fail_msg: "Укажи ноду: make remove-etcd-node NODE=" delegate_to: localhost become: false - name: Check node is in etcd_nodes group ansible.builtin.assert: that: node_to_remove in groups['etcd_nodes'] fail_msg: > Нода '{{ node_to_remove }}' не найдена в группе [etcd_nodes]. Убедись что имя совпадает с inventory. delegate_to: localhost become: false - name: Check minimum cluster size ansible.builtin.assert: that: groups['etcd_nodes'] | length > 1 fail_msg: > В кластере только одна нода etcd — удаление невозможно. Нельзя оставить кластер без узлов. delegate_to: localhost become: false - name: Warn about quorum loss risk ansible.builtin.debug: msg: > ВНИМАНИЕ: после удаления в кластере останется {{ groups['etcd_nodes'] | length - 1 }} нод(ы). Кворум (большинство): требуется минимум {{ ((groups['etcd_nodes'] | length - 1) // 2 + 1) }} нод. {% if (groups['etcd_nodes'] | length - 1) < 2 %} ПРЕДУПРЕЖДЕНИЕ: менее 2 нод — кластер потеряет HA! {% endif %} - name: Get member ID of node to remove 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" \ member list -w json \ | python3 -c " import json, sys data = json.load(sys.stdin) for m in data['members']: if m.get('name') == '{{ node_to_remove }}': print(hex(m['ID'])) sys.exit(0) sys.exit(1) " register: _member_id changed_when: false failed_when: _member_id.rc != 0 - name: Show member ID ansible.builtin.debug: msg: "Member ID для {{ node_to_remove }}: {{ _member_id.stdout }}" - name: Remove member from etcd cluster 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" \ member remove {{ _member_id.stdout }} changed_when: true # ── Останавливаем etcd на удаляемой ноде ───────────────────────────────────── - name: Stop etcd on removed node hosts: "{{ node_to_remove }}" gather_facts: false become: true tags: [stop] tasks: - name: Stop and disable etcd service ansible.builtin.systemd: name: etcd state: stopped enabled: false failed_when: false - name: Remove etcd systemd service file ansible.builtin.file: path: /etc/systemd/system/etcd.service state: absent notify: Reload systemd - name: Remove etcd data directory ansible.builtin.file: path: "{{ etcd_data_dir }}" state: absent - name: Remove etcd config and PKI ansible.builtin.file: path: "{{ etcd_config_dir }}" state: absent - name: Remove etcd binaries ansible.builtin.file: path: "{{ etcd_install_dir }}/{{ item }}" state: absent loop: - etcd - etcdctl failed_when: false handlers: - name: Reload systemd ansible.builtin.systemd: daemon_reload: true # ── Удаляем локальные сертификаты удалённой ноды ───────────────────────────── - name: Clean up local PKI artifacts hosts: localhost gather_facts: false become: false tags: [pki] tasks: - name: Remove node certs from local PKI dir ansible.builtin.file: path: "{{ etcd_pki_local_dir }}/{{ item }}" state: absent loop: - "server-{{ node_to_remove }}.crt" - "server-{{ node_to_remove }}.key" - "server-{{ node_to_remove }}.csr" - "peer-{{ node_to_remove }}.crt" - "peer-{{ node_to_remove }}.key" - "peer-{{ node_to_remove }}.csr" failed_when: false # ── Верификация ─────────────────────────────────────────────────────────────── - name: Verify remaining cluster hosts: "{{ (groups['etcd_nodes'] | reject('equalto', node_to_remove) | list)[0] }}" gather_facts: false become: true tags: [verify] tasks: - name: Check cluster health ansible.builtin.shell: | ETCDCTL_API=3 etcdctl \ --endpoints="{{ remaining_endpoints }}" \ --cacert="{{ etcd_pki_dir }}/ca.crt" \ --cert="{{ etcd_pki_dir }}/server.crt" \ --key="{{ etcd_pki_dir }}/server.key" \ endpoint health vars: remaining_endpoints: >- {%- set eps = [] -%} {%- for h in groups['etcd_nodes'] | reject('equalto', node_to_remove) | list -%} {%- set _ = eps.append('https://' ~ hostvars[h]['ansible_host'] ~ ':' ~ etcd_client_port) -%} {%- endfor -%} {{ eps | join(',') }} register: _health changed_when: false - name: Show remaining members 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" \ member list -w table register: _members changed_when: false - name: Display remaining members ansible.builtin.debug: msg: "{{ _members.stdout_lines }}" - name: Summary ansible.builtin.debug: msg: > Нода {{ node_to_remove }} успешно удалена из etcd кластера. Осталось нод: {{ groups['etcd_nodes'] | length - 1 }}. Удали ноду из [etcd_nodes] в inventory/hosts.ini.