647 lines
17 KiB
Markdown
647 lines
17 KiB
Markdown
# Proxmox Cluster Formation Workflow
|
|
|
|
Complete guide to forming a Proxmox VE cluster using Ansible automation with idempotent patterns.
|
|
|
|
## Overview
|
|
|
|
This workflow automates the creation of a Proxmox VE cluster with:
|
|
|
|
- Hostname resolution configuration
|
|
- SSH key distribution for cluster operations
|
|
- Idempotent cluster initialization
|
|
- Corosync network configuration
|
|
- Quorum and health verification
|
|
|
|
## Prerequisites
|
|
|
|
Before forming a cluster:
|
|
|
|
1. **All nodes must have:**
|
|
- Proxmox VE 9.x installed
|
|
- Network connectivity on management network
|
|
- Dedicated corosync network configured (VLAN 9 for Matrix)
|
|
- Unique hostnames
|
|
- Synchronized time (NTP configured)
|
|
|
|
2. **Minimum requirements:**
|
|
- At least 3 nodes for quorum (production)
|
|
- 1 node for development/testing (non-recommended)
|
|
|
|
3. **Network requirements:**
|
|
- All nodes must be able to resolve each other's hostnames
|
|
- Corosync network must be isolated (no VM traffic)
|
|
- Low latency between nodes (<2ms recommended)
|
|
- MTU 1500 on management network
|
|
|
|
## Phase 1: Prepare Cluster Nodes
|
|
|
|
### Step 1: Verify Prerequisites
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/prerequisites.yml
|
|
---
|
|
- name: Check Proxmox VE is installed
|
|
ansible.builtin.stat:
|
|
path: /usr/bin/pvecm
|
|
register: pvecm_binary
|
|
failed_when: not pvecm_binary.stat.exists
|
|
|
|
- name: Get Proxmox VE version
|
|
ansible.builtin.command:
|
|
cmd: pveversion
|
|
register: pve_version
|
|
changed_when: false
|
|
|
|
- name: Verify minimum Proxmox VE version
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'pve-manager/9' in pve_version.stdout or 'pve-manager/8' in pve_version.stdout"
|
|
fail_msg: "Proxmox VE 8.x or 9.x required"
|
|
|
|
- name: Verify minimum node count for production
|
|
ansible.builtin.assert:
|
|
that:
|
|
- groups[cluster_group] | length >= 3
|
|
fail_msg: "Production cluster requires at least 3 nodes for quorum"
|
|
when: cluster_environment == 'production'
|
|
|
|
- name: Check no existing cluster membership
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: existing_cluster
|
|
failed_when: false
|
|
changed_when: false
|
|
|
|
- name: Display cluster warning if already member
|
|
ansible.builtin.debug:
|
|
msg: |
|
|
WARNING: Node {{ inventory_hostname }} is already a cluster member.
|
|
Current cluster: {{ existing_cluster.stdout }}
|
|
This playbook will attempt to join the target cluster.
|
|
when:
|
|
- existing_cluster.rc == 0
|
|
- cluster_name not in existing_cluster.stdout
|
|
```
|
|
|
|
### Step 2: Configure Hostname Resolution
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/hosts_config.yml
|
|
---
|
|
- name: Ensure cluster nodes in /etc/hosts (management IP)
|
|
ansible.builtin.lineinfile:
|
|
path: /etc/hosts
|
|
regexp: "^{{ item.management_ip }}\\s+"
|
|
line: "{{ item.management_ip }} {{ item.fqdn }} {{ item.short_name }}"
|
|
state: present
|
|
loop: "{{ cluster_nodes }}"
|
|
loop_control:
|
|
label: "{{ item.short_name }}"
|
|
|
|
- name: Ensure corosync IPs in /etc/hosts
|
|
ansible.builtin.lineinfile:
|
|
path: /etc/hosts
|
|
regexp: "^{{ item.corosync_ip }}\\s+"
|
|
line: "{{ item.corosync_ip }} {{ item.short_name }}-corosync"
|
|
state: present
|
|
loop: "{{ cluster_nodes }}"
|
|
loop_control:
|
|
label: "{{ item.short_name }}"
|
|
|
|
- name: Verify hostname resolution (forward)
|
|
ansible.builtin.command:
|
|
cmd: "getent hosts {{ item.fqdn }}"
|
|
register: host_lookup
|
|
failed_when: host_lookup.rc != 0
|
|
changed_when: false
|
|
loop: "{{ cluster_nodes }}"
|
|
loop_control:
|
|
label: "{{ item.fqdn }}"
|
|
|
|
- name: Verify hostname resolution (reverse)
|
|
ansible.builtin.command:
|
|
cmd: "getent hosts {{ item.management_ip }}"
|
|
register: reverse_lookup
|
|
failed_when:
|
|
- reverse_lookup.rc != 0
|
|
changed_when: false
|
|
loop: "{{ cluster_nodes }}"
|
|
loop_control:
|
|
label: "{{ item.management_ip }}"
|
|
|
|
- name: Test corosync network connectivity
|
|
ansible.builtin.command:
|
|
cmd: "ping -c 3 -W 2 {{ item.corosync_ip }}"
|
|
register: corosync_ping
|
|
changed_when: false
|
|
when: item.short_name != inventory_hostname_short
|
|
loop: "{{ cluster_nodes }}"
|
|
loop_control:
|
|
label: "{{ item.short_name }}"
|
|
```
|
|
|
|
### Step 3: Distribute SSH Keys
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/ssh_keys.yml
|
|
---
|
|
- name: Generate SSH key for root (if not exists)
|
|
ansible.builtin.user:
|
|
name: root
|
|
generate_ssh_key: true
|
|
ssh_key_type: ed25519
|
|
ssh_key_comment: "root@{{ inventory_hostname }}"
|
|
register: root_ssh_key
|
|
|
|
- name: Fetch public keys from all nodes
|
|
ansible.builtin.slurp:
|
|
src: /root/.ssh/id_ed25519.pub
|
|
register: node_public_keys
|
|
|
|
- name: Distribute SSH keys to all nodes
|
|
ansible.posix.authorized_key:
|
|
user: root
|
|
state: present
|
|
key: "{{ hostvars[item].node_public_keys.content | b64decode }}"
|
|
comment: "cluster-{{ item }}"
|
|
loop: "{{ groups[cluster_group] }}"
|
|
when: item != inventory_hostname
|
|
|
|
- name: Populate known_hosts with node SSH keys
|
|
ansible.builtin.shell:
|
|
cmd: "ssh-keyscan -H {{ item }} >> /root/.ssh/known_hosts"
|
|
when: item != inventory_hostname
|
|
loop: "{{ groups[cluster_group] }}"
|
|
loop_control:
|
|
label: "{{ item }}"
|
|
changed_when: true
|
|
|
|
- name: Test SSH connectivity to all nodes
|
|
ansible.builtin.command:
|
|
cmd: "ssh -o ConnectTimeout=5 {{ item }} hostname"
|
|
register: ssh_test
|
|
changed_when: false
|
|
when: item != inventory_hostname
|
|
loop: "{{ groups[cluster_group] }}"
|
|
loop_control:
|
|
label: "{{ item }}"
|
|
```
|
|
|
|
## Phase 2: Initialize Cluster
|
|
|
|
### Step 4: Create Cluster (First Node Only)
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/cluster_init.yml
|
|
---
|
|
- name: Check existing cluster status
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: cluster_status
|
|
failed_when: false
|
|
changed_when: false
|
|
|
|
- name: Get cluster nodes list
|
|
ansible.builtin.command:
|
|
cmd: pvecm nodes
|
|
register: cluster_nodes_check
|
|
failed_when: false
|
|
changed_when: false
|
|
|
|
- name: Set cluster facts
|
|
ansible.builtin.set_fact:
|
|
in_target_cluster: "{{ cluster_status.rc == 0 and cluster_name in cluster_status.stdout }}"
|
|
|
|
- name: Create new cluster on first node
|
|
ansible.builtin.command:
|
|
cmd: "pvecm create {{ cluster_name }} --link0 {{ corosync_link0_address }}"
|
|
when: not in_target_cluster
|
|
register: cluster_create
|
|
changed_when: cluster_create.rc == 0
|
|
|
|
- name: Wait for cluster to initialize
|
|
ansible.builtin.pause:
|
|
seconds: 10
|
|
when: cluster_create.changed
|
|
|
|
- name: Verify cluster creation
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: cluster_verify
|
|
changed_when: false
|
|
failed_when: cluster_name not in cluster_verify.stdout
|
|
|
|
- name: Display cluster status
|
|
ansible.builtin.debug:
|
|
var: cluster_verify.stdout_lines
|
|
when: cluster_create.changed or ansible_verbosity > 0
|
|
```
|
|
|
|
### Step 5: Join Nodes to Cluster
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/cluster_join.yml
|
|
---
|
|
- name: Check if already in cluster
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: cluster_status
|
|
failed_when: false
|
|
changed_when: false
|
|
|
|
- name: Set membership facts
|
|
ansible.builtin.set_fact:
|
|
is_cluster_member: "{{ cluster_status.rc == 0 }}"
|
|
in_target_cluster: "{{ cluster_status.rc == 0 and cluster_name in cluster_status.stdout }}"
|
|
|
|
- name: Get first node hostname
|
|
ansible.builtin.set_fact:
|
|
first_node_hostname: "{{ hostvars[groups[cluster_group][0]].inventory_hostname }}"
|
|
|
|
- name: Join cluster
|
|
ansible.builtin.command:
|
|
cmd: >
|
|
pvecm add {{ first_node_hostname }}
|
|
--link0 {{ corosync_link0_address }}
|
|
when:
|
|
- not is_cluster_member or not in_target_cluster
|
|
register: cluster_join
|
|
changed_when: cluster_join.rc == 0
|
|
failed_when:
|
|
- cluster_join.rc != 0
|
|
- "'already in a cluster' not in cluster_join.stderr"
|
|
|
|
- name: Wait for node to join cluster
|
|
ansible.builtin.pause:
|
|
seconds: 10
|
|
when: cluster_join.changed
|
|
|
|
- name: Verify cluster membership
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: join_verify
|
|
changed_when: false
|
|
failed_when:
|
|
- "'Quorate: Yes' not in join_verify.stdout"
|
|
```
|
|
|
|
## Phase 3: Configure Corosync
|
|
|
|
### Step 6: Corosync Network Configuration
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/corosync.yml
|
|
---
|
|
- name: Get current corosync configuration
|
|
ansible.builtin.slurp:
|
|
src: /etc/pve/corosync.conf
|
|
register: corosync_conf_current
|
|
|
|
- name: Parse current corosync config
|
|
ansible.builtin.set_fact:
|
|
current_corosync: "{{ corosync_conf_current.content | b64decode }}"
|
|
|
|
- name: Check if corosync config needs update
|
|
ansible.builtin.set_fact:
|
|
corosync_needs_update: "{{ corosync_network not in current_corosync }}"
|
|
|
|
- name: Backup corosync.conf
|
|
ansible.builtin.copy:
|
|
src: /etc/pve/corosync.conf
|
|
dest: "/etc/pve/corosync.conf.{{ ansible_date_time.epoch }}.bak"
|
|
remote_src: true
|
|
mode: '0640'
|
|
when: corosync_needs_update
|
|
delegate_to: "{{ groups[cluster_group][0] }}"
|
|
run_once: true
|
|
|
|
- name: Update corosync configuration
|
|
ansible.builtin.template:
|
|
src: corosync.conf.j2
|
|
dest: /etc/pve/corosync.conf.new
|
|
validate: corosync-cfgtool -c %s
|
|
mode: '0640'
|
|
when: corosync_needs_update
|
|
delegate_to: "{{ groups[cluster_group][0] }}"
|
|
run_once: true
|
|
|
|
- name: Apply new corosync configuration
|
|
ansible.builtin.copy:
|
|
src: /etc/pve/corosync.conf.new
|
|
dest: /etc/pve/corosync.conf
|
|
remote_src: true
|
|
mode: '0640'
|
|
when: corosync_needs_update
|
|
notify:
|
|
- reload corosync
|
|
delegate_to: "{{ groups[cluster_group][0] }}"
|
|
run_once: true
|
|
```
|
|
|
|
**Corosync Template Example:**
|
|
|
|
```jinja2
|
|
# templates/corosync.conf.j2
|
|
totem {
|
|
version: 2
|
|
cluster_name: {{ cluster_name }}
|
|
transport: knet
|
|
crypto_cipher: aes256
|
|
crypto_hash: sha256
|
|
|
|
interface {
|
|
linknumber: 0
|
|
knet_link_priority: 255
|
|
}
|
|
}
|
|
|
|
nodelist {
|
|
{% for node in cluster_nodes %}
|
|
node {
|
|
name: {{ node.short_name }}
|
|
nodeid: {{ node.node_id }}
|
|
quorum_votes: 1
|
|
ring0_addr: {{ node.corosync_ip }}
|
|
}
|
|
{% endfor %}
|
|
}
|
|
|
|
quorum {
|
|
provider: corosync_votequorum
|
|
{% if cluster_nodes | length == 2 %}
|
|
two_node: 1
|
|
{% endif %}
|
|
}
|
|
|
|
logging {
|
|
to_logfile: yes
|
|
logfile: /var/log/corosync/corosync.log
|
|
to_syslog: yes
|
|
timestamp: on
|
|
}
|
|
```
|
|
|
|
## Phase 4: Verify Cluster Health
|
|
|
|
### Step 7: Health Checks
|
|
|
|
```yaml
|
|
# roles/proxmox_cluster/tasks/verify.yml
|
|
---
|
|
- name: Wait for cluster to stabilize
|
|
ansible.builtin.pause:
|
|
seconds: 15
|
|
|
|
- name: Check cluster quorum
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: cluster_health
|
|
changed_when: false
|
|
failed_when: "'Quorate: Yes' not in cluster_health.stdout"
|
|
|
|
- name: Get cluster node count
|
|
ansible.builtin.command:
|
|
cmd: pvecm nodes
|
|
register: cluster_nodes_final
|
|
changed_when: false
|
|
|
|
- name: Verify expected node count
|
|
ansible.builtin.assert:
|
|
that:
|
|
- cluster_nodes_final.stdout_lines | length >= groups[cluster_group] | length
|
|
fail_msg: "Expected {{ groups[cluster_group] | length }} nodes but found {{ cluster_nodes_final.stdout_lines | length }}"
|
|
|
|
- name: Check corosync ring status
|
|
ansible.builtin.command:
|
|
cmd: corosync-cfgtool -s
|
|
register: corosync_status
|
|
changed_when: false
|
|
|
|
- name: Verify all nodes in corosync
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'online' in corosync_status.stdout"
|
|
fail_msg: "Corosync ring issues detected"
|
|
|
|
- name: Get cluster configuration version
|
|
ansible.builtin.command:
|
|
cmd: corosync-cmapctl -b totem.config_version
|
|
register: config_version
|
|
changed_when: false
|
|
|
|
- name: Display cluster health summary
|
|
ansible.builtin.debug:
|
|
msg: |
|
|
Cluster: {{ cluster_name }}
|
|
Quorum: {{ 'Yes' if 'Quorate: Yes' in cluster_health.stdout else 'No' }}
|
|
Nodes: {{ cluster_nodes_final.stdout_lines | length }}
|
|
Config Version: {{ config_version.stdout }}
|
|
```
|
|
|
|
## Matrix Cluster Example Configuration
|
|
|
|
```yaml
|
|
# group_vars/matrix_cluster.yml
|
|
---
|
|
cluster_name: "Matrix"
|
|
cluster_group: "matrix_cluster"
|
|
cluster_environment: "production"
|
|
|
|
# Corosync configuration
|
|
corosync_network: "192.168.8.0/24" # VLAN 9
|
|
|
|
# Node configuration
|
|
cluster_nodes:
|
|
- short_name: foxtrot
|
|
fqdn: foxtrot.matrix.spaceships.work
|
|
management_ip: 192.168.3.5
|
|
corosync_ip: 192.168.8.5
|
|
node_id: 1
|
|
|
|
- short_name: golf
|
|
fqdn: golf.matrix.spaceships.work
|
|
management_ip: 192.168.3.6
|
|
corosync_ip: 192.168.8.6
|
|
node_id: 2
|
|
|
|
- short_name: hotel
|
|
fqdn: hotel.matrix.spaceships.work
|
|
management_ip: 192.168.3.7
|
|
corosync_ip: 192.168.8.7
|
|
node_id: 3
|
|
|
|
# Set per-node corosync address
|
|
corosync_link0_address: "{{ cluster_nodes | selectattr('short_name', 'equalto', inventory_hostname_short) | map(attribute='corosync_ip') | first }}"
|
|
```
|
|
|
|
## Complete Playbook Example
|
|
|
|
```yaml
|
|
# playbooks/cluster-init.yml
|
|
---
|
|
- name: Initialize Proxmox Cluster
|
|
hosts: "{{ cluster_group | default('matrix_cluster') }}"
|
|
become: true
|
|
serial: 1 # One node at a time for safety
|
|
|
|
pre_tasks:
|
|
- name: Validate cluster group is defined
|
|
ansible.builtin.assert:
|
|
that:
|
|
- cluster_group is defined
|
|
- cluster_name is defined
|
|
- cluster_nodes is defined
|
|
fail_msg: "Required variables not defined in group_vars"
|
|
|
|
- name: Display cluster configuration
|
|
ansible.builtin.debug:
|
|
msg: |
|
|
Forming cluster: {{ cluster_name }}
|
|
Nodes: {{ cluster_nodes | map(attribute='short_name') | join(', ') }}
|
|
Corosync network: {{ corosync_network }}
|
|
run_once: true
|
|
|
|
tasks:
|
|
- name: Verify prerequisites
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/prerequisites.yml"
|
|
|
|
- name: Configure /etc/hosts
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/hosts_config.yml"
|
|
|
|
- name: Distribute SSH keys
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/ssh_keys.yml"
|
|
|
|
# First node creates cluster
|
|
- name: Initialize cluster on first node
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/cluster_init.yml"
|
|
when: inventory_hostname == groups[cluster_group][0]
|
|
|
|
# Wait for first node
|
|
- name: Wait for first node to complete
|
|
ansible.builtin.pause:
|
|
seconds: 20
|
|
when: inventory_hostname != groups[cluster_group][0]
|
|
|
|
# Other nodes join
|
|
- name: Join cluster on other nodes
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/cluster_join.yml"
|
|
when: inventory_hostname != groups[cluster_group][0]
|
|
|
|
- name: Configure corosync
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/corosync.yml"
|
|
|
|
- name: Verify cluster health
|
|
ansible.builtin.include_tasks: "{{ role_path }}/tasks/verify.yml"
|
|
|
|
post_tasks:
|
|
- name: Display final cluster status
|
|
ansible.builtin.command:
|
|
cmd: pvecm status
|
|
register: final_status
|
|
changed_when: false
|
|
delegate_to: "{{ groups[cluster_group][0] }}"
|
|
run_once: true
|
|
|
|
- name: Show cluster status
|
|
ansible.builtin.debug:
|
|
var: final_status.stdout_lines
|
|
run_once: true
|
|
|
|
handlers:
|
|
- name: reload corosync
|
|
ansible.builtin.systemd:
|
|
name: corosync
|
|
state: reloaded
|
|
throttle: 1
|
|
```
|
|
|
|
## Usage
|
|
|
|
### Initialize Matrix Cluster
|
|
|
|
```bash
|
|
# Check syntax
|
|
ansible-playbook playbooks/cluster-init.yml --syntax-check
|
|
|
|
# Dry run (limited functionality)
|
|
ansible-playbook playbooks/cluster-init.yml --check --diff
|
|
|
|
# Initialize cluster
|
|
ansible-playbook playbooks/cluster-init.yml --limit matrix_cluster
|
|
|
|
# Verify cluster status
|
|
ansible -i inventory/proxmox.yml foxtrot -m shell -a "pvecm status"
|
|
ansible -i inventory/proxmox.yml foxtrot -m shell -a "pvecm nodes"
|
|
```
|
|
|
|
### Add mise Task
|
|
|
|
```toml
|
|
# .mise.toml
|
|
[tasks."cluster:init"]
|
|
description = "Initialize Proxmox cluster"
|
|
run = """
|
|
cd ansible
|
|
uv run ansible-playbook playbooks/cluster-init.yml
|
|
"""
|
|
|
|
[tasks."cluster:status"]
|
|
description = "Show cluster status"
|
|
run = """
|
|
ansible -i ansible/inventory/proxmox.yml foxtrot -m shell -a "pvecm status"
|
|
"""
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Node Won't Join Cluster
|
|
|
|
**Symptoms:**
|
|
|
|
- `pvecm add` fails with timeout or connection error
|
|
|
|
**Solutions:**
|
|
|
|
1. Verify SSH connectivity: `ssh root@first-node hostname`
|
|
2. Check /etc/hosts: `getent hosts first-node`
|
|
3. Verify corosync network: `ping -c 3 192.168.8.5`
|
|
4. Check firewall: `iptables -L | grep 5404`
|
|
|
|
### Cluster Shows No Quorum
|
|
|
|
**Symptoms:**
|
|
|
|
- `pvecm status` shows `Quorate: No`
|
|
|
|
**Solutions:**
|
|
|
|
1. Check node count: Must have majority (2 of 3, 3 of 5, etc.)
|
|
2. Verify corosync: `systemctl status corosync`
|
|
3. Check corosync ring: `corosync-cfgtool -s`
|
|
4. Review logs: `journalctl -u corosync -n 50`
|
|
|
|
### Configuration Sync Issues
|
|
|
|
**Symptoms:**
|
|
|
|
- Changes on one node don't appear on others
|
|
|
|
**Solutions:**
|
|
|
|
1. Verify pmxcfs: `systemctl status pve-cluster`
|
|
2. Check filesystem: `pvecm status | grep -i cluster`
|
|
3. Restart cluster filesystem: `systemctl restart pve-cluster`
|
|
|
|
## Related Workflows
|
|
|
|
- [CEPH Deployment](ceph-deployment.md) - Deploy CEPH after cluster formation
|
|
- [Network Configuration](../reference/networking.md) - Configure cluster networking
|
|
- [Cluster Maintenance](cluster-maintenance.md) - Add/remove nodes, upgrades
|
|
|
|
## References
|
|
|
|
- ProxSpray analysis: `docs/proxspray-analysis.md` (lines 1318-1428)
|
|
- Proxmox VE Cluster Manager documentation
|
|
- Corosync configuration guide
|
|
- [Ansible cluster automation pattern](../../ansible-best-practices/patterns/cluster-automation.md)
|