Skip to content

Commit 797736a

Browse files
Merge pull request #594 from lmiccini/split-brain-def
Add network partition detection to prevent unsafe fencing
2 parents 7cb8569 + 29d811b commit 797736a

25 files changed

Lines changed: 1539 additions & 1195 deletions

apis/instanceha/v1beta1/instanceha_webhook.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ func (r *InstanceHa) ValidateUpdate(ctx context.Context, c client.Client, old ru
121121
var allWarn []string
122122
basePath := field.NewPath("spec")
123123

124+
oldInstance, ok := old.(*InstanceHa)
125+
if !ok {
126+
return nil, apierrors.NewInternalError(fmt.Errorf("unable to convert existing object"))
127+
}
128+
129+
if r.Spec.OpenStackCloud != oldInstance.Spec.OpenStackCloud {
130+
allErrs = append(allErrs, field.Forbidden(
131+
basePath.Child("openStackCloud"),
132+
"openStackCloud cannot be changed after creation",
133+
))
134+
}
135+
124136
allErrs = append(allErrs, r.Spec.ValidateTopology(basePath, r.Namespace)...)
125137
allErrs = append(allErrs, r.validateUniqueOpenStackCloud(ctx, c, basePath)...)
126138
allErrs = append(allErrs, r.validateCipherSuites(basePath)...)

docs/instanceha_application_credentials.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@ access that can be rotated independently of the service user password.
99

1010
- A Keystone user with sufficient privileges to query and evacuate VMs
1111
(typically the `admin` role on the relevant project).
12-
- The `keystone-operator` deployed with Application Credential support
13-
([keystone-operator PR #567](https://github.com/openstack-k8s-operators/keystone-operator/pull/567)).
12+
- The `keystone-operator` deployed with `KeystoneApplicationCredential` CR support.
1413
- The `clouds.yaml` ConfigMap must still be present — InstanceHA reads
1514
`auth_url` and `region_name` from it even when using Application Credentials.
1615

docs/instanceha_architecture.md

Lines changed: 52 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
InstanceHA is a high-availability service for OpenStack that automatically detects and evacuates instances from failed compute nodes.
66

7-
**Version**: 2.5
8-
97
## Table of Contents
108

119
1. [Core Components](#core-components)
@@ -126,13 +124,15 @@ _config_map: Dict[str, ConfigItem] = {
126124
'KDUMP_TIMEOUT': ConfigItem('int', 30, 5, 300),
127125
'CHECK_HEARTBEAT': ConfigItem('bool', False),
128126
'HEARTBEAT_TIMEOUT': ConfigItem('int', 120, 30, 600),
127+
'HEARTBEAT_CLIFF_THRESHOLD': ConfigItem('int', 50, 10, 100),
129128
'DISABLED': ConfigItem('bool', False),
130129
'SSL_VERIFY': ConfigItem('bool', True),
131130
'FENCING_TIMEOUT': ConfigItem('int', 30, 5, 120),
132131
'HASH_INTERVAL': ConfigItem('int', 60, 30, 300),
133132
'ORCHESTRATED_RESTART': ConfigItem('bool', False),
134133
'SKIP_SERVERS_WITH_NAME': ConfigItem('list', []),
135134
'EVACUATION_RETRIES': ConfigItem('int', DEFAULT_EVACUATION_RETRIES, 1, 20), # DEFAULT_EVACUATION_RETRIES = 5
135+
'K8S_API_CHECK_INTERVAL': ConfigItem('int', 15, 5, 120),
136136
}
137137
```
138138

@@ -158,10 +158,11 @@ def __init__(self, config_manager: ConfigManager, cloud_client: Optional[OpenSta
158158

159159
# Health monitoring
160160
self.current_hash = ""
161-
self.hash_update_successful = True
161+
self.hash_update_successful = False
162162
self._last_hash_time = 0
163163
self._previous_hash = ""
164164
self.ready = False # Readiness flag (set after first poll)
165+
self.k8s_api_reachable = False # K8s API connectivity (partition detection)
165166

166167
# Caching
167168
self._host_servers_cache = {}
@@ -188,6 +189,7 @@ def __init__(self, config_manager: ConfigManager, cloud_client: Optional[OpenSta
188189
self.heartbeat_hosts_timestamp = defaultdict(float)
189190
self.heartbeat_listener_stop_event = threading.Event()
190191
self.heartbeat_listener_start_time = 0.0
192+
self.heartbeat_previous_active_count = 0 # Cliff detection baseline
191193

192194
# Host processing tracking
193195
self.hosts_processing = defaultdict(float)
@@ -1108,6 +1110,7 @@ In addition, each event emission site also increments a corresponding Prometheus
11081110
| `AggregateThresholdExceeded` | Warning | aggregate name | Failed hosts in aggregate exceed `instanceha:max_failures` metadata limit |
11091111
| `HostReachable` | Warning | compute host | Host reported down by Nova but still reachable via heartbeat (CHECK_HEARTBEAT) |
11101112
| `HostReenabled` | Normal | compute host | Host re-enabled after successful evacuation |
1113+
| `HeartbeatCliff` | Warning | `cluster` | Sudden heartbeat loss detected — possible network partition, fencing skipped for this cycle |
11111114
| `OrphanedHostRecovered` | Warning | compute host | Startup reconciliation recovered a fenced host left without evacuation marker |
11121115

11131116
### Event Structure
@@ -1181,39 +1184,7 @@ kubectl get events -n openstack --field-selector involvedObject.kind=InstanceHa,
11811184

11821185
### Prometheus Metrics
11831186

1184-
The agent exposes Prometheus-format metrics at `:8080/metrics` on the same HTTP server used for health checks. Metrics are served using the `prometheus_client` Python library.
1185-
1186-
**Counters** (monotonically increasing, reset on pod restart):
1187-
1188-
| Metric | Labels | Description |
1189-
|--------|--------|-------------|
1190-
| `instanceha_fencing_total` | `host`, `result` | Fencing operations (`started`/`succeeded`/`failed`) |
1191-
| `instanceha_evacuation_total` | `host`, `result` | Host-level evacuations |
1192-
| `instanceha_instance_evacuation_total` | `host`, `result` | Per-instance evacuations (smart/orchestrated) |
1193-
| `instanceha_host_down_total` | `host` | Host-down detections |
1194-
| `instanceha_host_reachable_total` | `host` | Hosts still reachable via heartbeat despite Nova reporting down |
1195-
| `instanceha_host_reenabled_total` | `host` | Hosts re-enabled after evacuation |
1196-
| `instanceha_threshold_exceeded_total` | | Evacuations blocked by global threshold |
1197-
| `instanceha_aggregate_threshold_exceeded_total` | `aggregate` | Evacuations blocked by per-aggregate threshold |
1198-
| `instanceha_recovery_completed_total` | `host` | Full recovery workflows completed |
1199-
| `instanceha_processing_failed_total` | `host` | Service processing failures |
1200-
| `instanceha_orphaned_host_recovered_total` | | Orphaned hosts recovered at startup |
1201-
| `instanceha_poll_cycles_total` | `result` | Poll cycles (`success`/`error`) |
1202-
1203-
**Gauges** (current value):
1204-
1205-
| Metric | Description |
1206-
|--------|-------------|
1207-
| `instanceha_poll_consecutive_failures` | Current consecutive Nova API failures |
1208-
| `instanceha_hosts_processing` | Hosts currently being processed |
1209-
1210-
**Histograms**:
1211-
1212-
| Metric | Labels | Buckets (s) | Description |
1213-
|--------|--------|-------------|-------------|
1214-
| `instanceha_instance_evacuation_duration_seconds` | `host` | 10, 30, 60, 120, 180, 300, 600 | Per-instance evacuation duration |
1215-
1216-
Each metric increment is co-located with the corresponding `_emit_k8s_event()` call, so the event catalog and metric catalog map 1:1.
1187+
The agent exposes Prometheus-format metrics at `:8080/metrics` on the same HTTP server used for health checks. Each metric increment is co-located with the corresponding `_emit_k8s_event()` call. See [instanceha_prometheus.md](instanceha_prometheus.md) for the full metric catalog, alert rules, and Grafana dashboard queries.
12171188

12181189
---
12191190

@@ -1303,7 +1274,48 @@ Minimum packet size: 45 bytes (4 magic + 8 timestamp + 1 hostname + 32 HMAC).
13031274
13041275
---
13051276
1306-
### 3. Reserved Hosts
1277+
### 3. K8s API Connectivity Check
1278+
1279+
**Purpose**: Detect when the InstanceHA pod's worker node is network-isolated from the Kubernetes API, preventing it from fencing healthy hosts it can no longer observe.
1280+
1281+
**Architecture**:
1282+
- Background thread started by `start_k8s_health_check()`
1283+
- Polls `GET /api/v1/namespaces/{namespace}` using the in-cluster service account token
1284+
- Interval controlled by `K8S_API_CHECK_INTERVAL` (default 15s, range 5–120)
1285+
1286+
**Configuration**: `K8S_API_CHECK_INTERVAL: 15`
1287+
1288+
**Behavior**:
1289+
1. The health check thread pings the K8s API every `K8S_API_CHECK_INTERVAL` seconds
1290+
2. Any non-5xx response (status code < 500) is treated as reachable — the API is responding, even if RBAC denies the specific request
1291+
3. After 3 consecutive failures, `k8s_api_reachable` is set to `False` and `ready` is set to `False`
1292+
4. The main poll loop checks `k8s_api_reachable` before fencing — if `False`, the entire fencing cycle is skipped
1293+
5. When connectivity restores, `k8s_api_reachable` is set back to `True`; readiness is restored after the next successful Nova poll
1294+
1295+
**Prometheus**: `instanceha_k8s_api_reachable` gauge (1=ok, 0=isolated)
1296+
1297+
---
1298+
1299+
### 4. Heartbeat Cliff Detection
1300+
1301+
**Purpose**: Detect sudden mass heartbeat loss that is more likely caused by a listener-side network partition than simultaneous host failures.
1302+
1303+
**Architecture**:
1304+
- Integrated into `_filter_reachable_hosts()`, runs during each poll cycle when `CHECK_HEARTBEAT` is enabled
1305+
- Compares current active heartbeat count against the previous cycle's count stored in `heartbeat_previous_active_count`
1306+
1307+
**Configuration**: `HEARTBEAT_CLIFF_THRESHOLD: 50` (default 50%, range 10–100)
1308+
1309+
**Behavior**:
1310+
1. At each poll cycle, count hosts with a fresh heartbeat (within `HEARTBEAT_TIMEOUT`)
1311+
2. If the previous count was ≥ 3 and the drop percentage exceeds `HEARTBEAT_CLIFF_THRESHOLD`, skip fencing for this cycle
1312+
3. Emit a `HeartbeatCliff` K8s event (Warning) and increment `instanceha_heartbeat_cliff_total`
1313+
4. The baseline count is preserved during a cliff event, so the cliff re-triggers on subsequent cycles until heartbeats recover
1314+
5. When no cliff is detected, update `heartbeat_previous_active_count` for the next cycle
1315+
1316+
---
1317+
1318+
### 5. Reserved Hosts
13071319
13081320
**Purpose**: Maintain spare capacity by auto-enabling reserved hosts and optionally forcing evacuation to them.
13091321
@@ -1349,7 +1361,7 @@ Reserved Hosts: reserved-01 (aggregate-A), reserved-02 (aggregate-B)
13491361
13501362
---
13511363
1352-
### 4. Caching System
1364+
### 6. Caching System
13531365
13541366
**Purpose**: Cache evacuable resources to reduce Nova API calls.
13551367
@@ -1367,7 +1379,7 @@ Reserved Hosts: reserved-01 (aggregate-A), reserved-02 (aggregate-B)
13671379
13681380
---
13691381
1370-
### 5. Threshold Protection
1382+
### 7. Threshold Protection
13711383
13721384
**Purpose**: Prevent mass evacuations during datacenter-level failures.
13731385
@@ -1844,35 +1856,7 @@ These values are passed as `METRICS_TLS_MIN_VERSION` and `METRICS_TLS_CIPHERS` e
18441856

18451857
## Performance
18461858

1847-
### 1. Caching Performance
1848-
1849-
**Benchmark** (100 flavors, 100 images):
1850-
- First call: ~50ms (with API call)
1851-
- Cached call: ~0.1ms (without API call)
1852-
1853-
---
1854-
1855-
### 2. Concurrent Processing
1856-
1857-
**ThreadPoolExecutor** for smart evacuation:
1858-
```python
1859-
with concurrent.futures.ThreadPoolExecutor(max_workers=WORKERS) as executor:
1860-
future_to_server = {
1861-
executor.submit(_server_evacuate_future, conn, srv): srv
1862-
for srv in evacuables
1863-
}
1864-
```
1865-
1866-
**Concurrency**: WORKERS controls the number of hosts processed concurrently and, for smart/orchestrated mode, the per-host server concurrency (capped at `MAX_TOTAL_EVACUATION_THREADS=32`).
1867-
1868-
---
1869-
1870-
### 3. Memory Management
1871-
1872-
**Cleanup Strategies**:
1873-
- Kdump timestamp cleanup (>2000 entries)
1874-
- Host processing expiration
1875-
- Generic cleanup helper
1859+
For heartbeat filter latency, UDP listener throughput, lock contention, memory overhead, and scalability projections at 1000-node scale, see [instanceha_heartbeat_performance.md](instanceha_heartbeat_performance.md).
18761860

18771861
---
18781862

docs/instanceha_guide.md

Lines changed: 32 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ InstanceHA runs as a Kubernetes-managed workload alongside the OpenStack control
5151

5252
### How It Works
5353

54-
1. **Detection**: The agent polls the Nova API every `POLL` seconds (default: 45). It queries all `nova-compute` services and checks each one's `updated_at` timestamp and `state` field. A service is considered failed if it is reported as `down` or if its heartbeat is staler than `DELTA` seconds (default: 30). When heartbeat verification is enabled (`CHECK_HEARTBEAT`), both the Nova service-list poll and the UDP heartbeat channel must agree that a host is unreachable before it is marked as failed.
54+
1. **Detection**: The agent polls the Nova API every `POLL` seconds (default: 45). It queries all `nova-compute` services and checks each one's `updated_at` timestamp. A service is considered failed if it is reported as `down` or if its `updated_at` timestamp is older than `DELTA` seconds (default: 30). When heartbeat verification is enabled (`CHECK_HEARTBEAT`), both the Nova service-list poll and the UDP heartbeat channel must agree that a host is unreachable before it is marked as failed.
5555

5656
2. **Safety checks**: Before acting, the agent verifies that the percentage of failed hosts does not exceed the `THRESHOLD` (default: 50%) — calculated against only active services, excluding already-disabled and already-forced-down hosts — and that at least one `nova-scheduler` is running. These checks prevent cascading evacuations during infrastructure-wide outages. A `ThresholdExceeded` K8s event is emitted when the limit is hit.
5757

@@ -121,6 +121,7 @@ InstanceHA emits Kubernetes events on the InstanceHa CR to provide observability
121121
| `ThresholdExceeded` | Warning | Too many hosts down simultaneously |
122122
| `AggregateThresholdExceeded` | Warning | Per-aggregate failure threshold exceeded |
123123
| `OrphanedHostRecovered` | Warning | Recovered fenced host from prior crash |
124+
| `HeartbeatCliff` | Warning | Sudden heartbeat loss detected — possible network partition, fencing skipped |
124125

125126
Events can be viewed with `oc describe instanceha <name>` or `oc get events --field-selector involvedObject.name=<name>`.
126127

@@ -144,50 +145,24 @@ In addition to Kubernetes Events, the agent exposes Prometheus metrics at `http:
144145
| `instanceha_processing_failed_total` | `host` | Service processing failures |
145146
| `instanceha_orphaned_host_recovered_total` | | Orphaned hosts recovered at startup |
146147
| `instanceha_poll_cycles_total` | `result` | Poll cycles executed (result: `success`, `error`) |
148+
| `instanceha_heartbeat_rejected_total` | `reason` | Heartbeat packets rejected (`hmac_failed`, `timestamp_invalid`) |
149+
| `instanceha_heartbeat_cliff_total` | | Fencing skipped due to sudden heartbeat loss (possible network partition) |
147150

148151
#### Gauges
149152

150153
| Metric | Description |
151154
|--------|-------------|
152155
| `instanceha_poll_consecutive_failures` | Current consecutive Nova API poll failures (resets to 0 on success) |
153156
| `instanceha_hosts_processing` | Number of hosts currently being processed |
157+
| `instanceha_k8s_api_reachable` | Whether the Kubernetes API is reachable (1=yes, 0=no) |
154158

155159
#### Histograms
156160

157161
| Metric | Labels | Buckets (seconds) | Description |
158162
|--------|--------|-------------------|-------------|
159163
| `instanceha_instance_evacuation_duration_seconds` | `host` | 10, 30, 60, 120, 180, 300, 600 | Duration of individual instance evacuations |
160164

161-
#### Example Alert Rules
162-
163-
```yaml
164-
groups:
165-
- name: instanceha
166-
rules:
167-
- alert: InstanceHAFencingFailure
168-
expr: increase(instanceha_fencing_total{result="failed"}[5m]) > 0
169-
for: 0m
170-
labels:
171-
severity: critical
172-
annotations:
173-
summary: "Fencing failed for host {{ $labels.host }}"
174-
175-
- alert: InstanceHANovaAPIDown
176-
expr: instanceha_poll_consecutive_failures > 3
177-
for: 2m
178-
labels:
179-
severity: warning
180-
annotations:
181-
summary: "InstanceHA cannot reach Nova API"
182-
183-
- alert: InstanceHAEvacuationSlow
184-
expr: histogram_quantile(0.95, rate(instanceha_instance_evacuation_duration_seconds_bucket[15m])) > 300
185-
for: 5m
186-
labels:
187-
severity: warning
188-
annotations:
189-
summary: "Instance evacuations taking longer than 5 minutes (p95)"
190-
```
165+
See [instanceha_prometheus.md](instanceha_prometheus.md) for alert rules, Grafana dashboard queries, and PromQL examples.
191166

192167
#### Scraping Configuration
193168

@@ -215,6 +190,27 @@ spec:
215190
216191
When the pod receives SIGTERM (during scaling, rolling updates, or node drain), InstanceHA finishes any in-flight fencing or evacuation work before exiting. The Deployment is configured with a 30-second termination grace period. The agent's poll loop and evacuation workers check a shutdown flag between cycles, allowing timely response to shutdown signals.
217192
193+
### Network Partition Detection
194+
195+
Two complementary mechanisms prevent the agent from fencing healthy hosts when the pod's worker node is network-isolated:
196+
197+
**K8s API connectivity check:**
198+
- A background thread pings the K8s API every `K8S_API_CHECK_INTERVAL` seconds (default: 15) using the in-cluster service account.
199+
- After 3 consecutive failures, the agent marks itself not-ready and blocks all fencing until connectivity restores and a Nova poll succeeds.
200+
- The `instanceha_k8s_api_reachable` Prometheus gauge tracks the current state (1=ok, 0=isolated).
201+
202+
**Heartbeat cliff detection:**
203+
- The agent tracks the count of active heartbeat hosts between poll cycles.
204+
- If the count drops by more than `HEARTBEAT_CLIFF_THRESHOLD`% (default: 50%) in a single cycle (minimum 3 hosts previously active), fencing is skipped for that cycle. A sudden mass-silence is far more likely to indicate a listener-side network issue than simultaneous host failures.
205+
- A `HeartbeatCliff` K8s event (Warning) is emitted and the `instanceha_heartbeat_cliff_total` counter is incremented.
206+
- The detection re-evaluates on the next cycle — the baseline is preserved during a cliff, so fencing stays blocked until heartbeats recover.
207+
208+
```yaml
209+
config:
210+
K8S_API_CHECK_INTERVAL: "15"
211+
HEARTBEAT_CLIFF_THRESHOLD: "50"
212+
```
213+
218214
---
219215

220216
## Deployment
@@ -334,6 +330,7 @@ metadata:
334330
metallb.universe.tf/loadBalancerIPs: 172.17.0.80
335331
spec:
336332
type: LoadBalancer
333+
externalTrafficPolicy: Local
337334
selector:
338335
service: instanceha # Replace with your CR name if different
339336
ports:
@@ -347,6 +344,8 @@ spec:
347344
protocol: UDP
348345
```
349346

347+
> **Important:** `externalTrafficPolicy: Local` is required when kdump detection is enabled (`CHECK_KDUMP: "true"`). The kdump listener identifies the crashing host via reverse DNS lookup on the packet's source IP. Without `Local` policy, kube-proxy masquerades (SNATs) the source IP when forwarding traffic to the pod, replacing the compute node's real IP with an internal node address — causing the reverse DNS lookup to fail. `Local` policy preserves the original source IP by delivering traffic directly to the pod on the receiving node without cross-node forwarding or SNAT. (Heartbeat packets are unaffected — the sender's hostname is embedded in the HMAC-authenticated packet payload, so source IP rewriting does not break heartbeat identification.)
348+
350349
Replace `172.17.0.80` with an IP from your `internalapi` MetalLB address pool. The `allow-shared-ip` annotation lets this Service share the same IP with other OpenStack services (e.g., keystone, nova, cinder) that already use the `internalapi` pool — since the InstanceHA ports (7410/UDP, 7411/UDP) don't conflict with any existing service ports, sharing is safe and avoids consuming an additional IP. If you prefer a dedicated IP, pick an unused address and remove the `allow-shared-ip` annotation.
351350

352351
> **Note:** The `address-pool` value must match the actual MetalLB IPAddressPool name. In a standard openstack-k8s-operators deployment this is `internalapi`. You can verify the pool name with `oc get ipaddresspools -n metallb-system`.
@@ -880,6 +879,8 @@ All values are strings in the ConfigMap. The agent converts and validates them a
880879
|-----------|------|---------|-------------|
881880
| `CHECK_KDUMP` | bool | false | Enable kdump detection via UDP listener |
882881
| `CHECK_HEARTBEAT` | bool | false | Enable heartbeat verification via UDP listener |
882+
| `HEARTBEAT_CLIFF_THRESHOLD` | int | 50 (range: 10–100) | Percentage drop in active heartbeat hosts that triggers cliff detection (skips fencing for that cycle) |
883+
| `K8S_API_CHECK_INTERVAL` | int | 15 (range: 5–120) | Seconds between Kubernetes API reachability checks |
883884

884885
#### Security
885886

docs/instanceha_heartbeat_performance.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ nova-compute crashed (heartbeat still arriving). It acquires the
120120
against the timestamp map. Filter performance is unaffected by the
121121
protocol version since it operates on the in-memory timestamp dictionary.
122122

123+
#### Cliff Detection Overhead
124+
125+
Before filtering individual hosts, the function performs cliff detection:
126+
it counts the number of hosts with a fresh heartbeat (within
127+
`HEARTBEAT_TIMEOUT`) by scanning all entries in the timestamp snapshot,
128+
and compares this count against the previous cycle's value. If the drop
129+
exceeds `HEARTBEAT_CLIFF_THRESHOLD` (default 50%) and the previous count
130+
was ≥ 3, fencing is skipped entirely for that cycle.
131+
132+
This adds an O(n) pass over all timestamps (where n = total nodes, not
133+
just down hosts). At 1000 nodes this is a single dict iteration with one
134+
comparison per entry — well under 100 us and negligible compared to the
135+
Nova API call latency. The per-host filter logic that follows is
136+
unchanged.
137+
123138
### Latency by Down-Host Count
124139

125140
The THRESHOLD determines the maximum number of simultaneously down hosts

0 commit comments

Comments
 (0)