Skip to content

Commit 2e80986

Browse files
lmicciniclaude
andcommitted
Add network partition detection to prevent unsafe fencing
Add two complementary mechanisms to prevent InstanceHA from fencing healthy hosts when the pod's worker node is network-isolated: K8s API connectivity check: - Background thread polls the K8s API every K8S_API_CHECK_INTERVAL seconds (default 15). After 3 consecutive failures, the agent marks itself not-ready and blocks all fencing until connectivity restores. - Any non-5xx response is treated as reachable (covers 401/429/etc). - Fencing gate runs before Nova API calls to avoid wasted requests on a partitioned network. - Falls back to reading the ServiceAccount namespace file when POD_NAMESPACE env var is not set. Heartbeat cliff detection: - Tracks active heartbeat host count between poll cycles. If the count drops by more than HEARTBEAT_CLIFF_THRESHOLD% (default 50%) in a single cycle (minimum 3 hosts previously active), fencing is skipped. - Baseline count is preserved during a cliff event so the detection re-triggers on subsequent cycles until heartbeats recover. - Uses a dedicated HEARTBEAT_CLIFF_THRESHOLD config key, independent of the existing THRESHOLD used for aggregate failure gating. Also includes: - Prometheus metrics: k8s_api_reachable gauge, heartbeat_cliff_total counter, heartbeat_rejected_total counter - Prometheus monitoring guide with alert rules, Grafana dashboard queries, PodMonitor/ScrapeConfig setup, and telemetry-operator integration - Documentation updates for architecture, deployment guide, and configuration reference - Tests for K8s partition detection, heartbeat cliff detection, and credential namespace fallback - Fix operator precedence in controller finalizer guard - Fix flaky concurrent cache refresh test (race in mock setup) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4bfc0b3 commit 2e80986

14 files changed

Lines changed: 691 additions & 613 deletions

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: 54 additions & 77 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)
@@ -22,10 +20,9 @@ InstanceHA is a high-availability service for OpenStack that automatically detec
2220
12. [Startup Reconciliation](#startup-reconciliation)
2321
13. [Graceful Shutdown](#graceful-shutdown)
2422
14. [Security](#security)
25-
15. [Performance](#performance)
26-
16. [Testing](#testing)
27-
17. [Deployment](#deployment)
28-
18. [Configuration Options Reference](#configuration-options-reference)
23+
15. [Testing](#testing)
24+
16. [Deployment](#deployment)
25+
17. [Configuration Options Reference](#configuration-options-reference)
2926

3027
---
3128

@@ -126,13 +123,15 @@ _config_map: Dict[str, ConfigItem] = {
126123
'KDUMP_TIMEOUT': ConfigItem('int', 30, 5, 300),
127124
'CHECK_HEARTBEAT': ConfigItem('bool', False),
128125
'HEARTBEAT_TIMEOUT': ConfigItem('int', 120, 30, 600),
126+
'HEARTBEAT_CLIFF_THRESHOLD': ConfigItem('int', 50, 10, 100),
129127
'DISABLED': ConfigItem('bool', False),
130128
'SSL_VERIFY': ConfigItem('bool', True),
131129
'FENCING_TIMEOUT': ConfigItem('int', 30, 5, 120),
132130
'HASH_INTERVAL': ConfigItem('int', 60, 30, 300),
133131
'ORCHESTRATED_RESTART': ConfigItem('bool', False),
134132
'SKIP_SERVERS_WITH_NAME': ConfigItem('list', []),
135133
'EVACUATION_RETRIES': ConfigItem('int', DEFAULT_EVACUATION_RETRIES, 1, 20), # DEFAULT_EVACUATION_RETRIES = 5
134+
'K8S_API_CHECK_INTERVAL': ConfigItem('int', 15, 5, 120),
136135
}
137136
```
138137

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

159158
# Health monitoring
160159
self.current_hash = ""
161-
self.hash_update_successful = True
160+
self.hash_update_successful = False
162161
self._last_hash_time = 0
163162
self._previous_hash = ""
164163
self.ready = False # Readiness flag (set after first poll)
164+
self.k8s_api_reachable = False # K8s API connectivity (partition detection)
165165

166166
# Caching
167167
self._host_servers_cache = {}
@@ -188,6 +188,7 @@ def __init__(self, config_manager: ConfigManager, cloud_client: Optional[OpenSta
188188
self.heartbeat_hosts_timestamp = defaultdict(float)
189189
self.heartbeat_listener_stop_event = threading.Event()
190190
self.heartbeat_listener_start_time = 0.0
191+
self.heartbeat_previous_active_count = 0 # Cliff detection baseline
191192

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

11131115
### Event Structure
@@ -1181,39 +1183,7 @@ kubectl get events -n openstack --field-selector involvedObject.kind=InstanceHa,
11811183

11821184
### Prometheus Metrics
11831185

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.
1186+
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.
12171187

12181188
---
12191189

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

18431854
---
18441855

1845-
## Performance
1846-
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
1876-
1877-
---
1878-
18791856
## Testing
18801857

18811858
### Test Categories

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

0 commit comments

Comments
 (0)