Skip to content

Commit d6e7a22

Browse files
Merge pull request #600 from lmiccini/instanceha-interruptible-sleep-and-docs
Make fencing sleeps interruptible, add dual-key HMAC tests, document small clusters
2 parents d6550a9 + 02d5a1b commit d6e7a22

8 files changed

Lines changed: 189 additions & 51 deletions

File tree

docs/instanceha_architecture.md

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2122,7 +2122,7 @@ spec:
21222122
serviceAccountName: instanceha-instanceha
21232123
securityContext:
21242124
fsGroup: 42401
2125-
terminationGracePeriodSeconds: 30
2125+
terminationGracePeriodSeconds: 45
21262126
containers:
21272127
- name: instanceha
21282128
image: <resolved from infra-instanceha-config ConfigMap or RELATED_IMAGE env var>
@@ -2218,6 +2218,52 @@ spec:
22182218

22192219
---
22202220

2221+
## Small Cluster Tuning
2222+
2223+
InstanceHA works on clusters of any size, but the default safety thresholds are tuned for medium-to-large deployments. On clusters with 3–5 compute nodes, the percentage-based safety gates can block evacuation during legitimate multi-node failures because a small absolute number of failures translates to a large percentage.
2224+
2225+
### Affected Safety Gates
2226+
2227+
| Gate | Default | 3-node impact | 4-node impact |
2228+
|------|---------|---------------|---------------|
2229+
| **THRESHOLD** (global, `>`) | 50% | 2 failures = 66.7% → **blocked** | 2 failures = 50% → allowed (check is `>`, not `>=`) |
2230+
| **HEARTBEAT_CLIFF_THRESHOLD** (`>=`) | 50% | 2 heartbeat losses = 66.7% → **cliff triggered** | 2 losses = 50% → **cliff triggered** (check is `>=`) |
2231+
| **Per-aggregate threshold** | N/A (absolute count) | Not affected (uses absolute `instanceha:max_failures`) | Not affected |
2232+
2233+
Single-node failures are handled correctly at any cluster size.
2234+
2235+
### Recommended Configuration
2236+
2237+
For clusters with 3–5 compute nodes where simultaneous multi-node failures are a realistic scenario (not just network partitions):
2238+
2239+
```yaml
2240+
config:
2241+
THRESHOLD: 100 # Disable global threshold — trust fencing to proceed
2242+
CHECK_HEARTBEAT: true # Enable heartbeat detection to distinguish host vs nova-compute failures
2243+
HEARTBEAT_CLIFF_THRESHOLD: 100 # Disable cliff detection — trust individual heartbeat status
2244+
```
2245+
2246+
Setting `THRESHOLD: 100` disables the global percentage check entirely. This is safe because:
2247+
1. Heartbeat detection (`CHECK_HEARTBEAT`) provides the primary protection against fencing during network partitions
2248+
2. The K8s API reachability gate (gate 1) suppresses fencing if the pod itself loses network connectivity
2249+
3. Per-aggregate thresholds (if configured) still apply as an absolute count, which is better suited to small clusters
2250+
2251+
If heartbeats are not deployed, a more conservative approach:
2252+
2253+
```yaml
2254+
config:
2255+
THRESHOLD: 67 # Allow up to 2/3 failure ratio
2256+
```
2257+
2258+
### Why the Defaults Are Conservative
2259+
2260+
The default `THRESHOLD: 50` and `HEARTBEAT_CLIFF_THRESHOLD: 50` are deliberately conservative because:
2261+
- False positives (unnecessary fencing) are more dangerous than false negatives (delayed evacuation)
2262+
- On larger clusters, losing >50% of nodes simultaneously almost always indicates a network partition, not real failures
2263+
- Small clusters are the exception, not the rule, in production OpenStack deployments
2264+
2265+
---
2266+
22212267
## Configuration Options Reference
22222268

22232269
This section describes each configuration option in the `_config_map`, including its purpose, validation rules, usage in the codebase, and testing coverage.
@@ -2345,6 +2391,7 @@ config:
23452391
- Value of `0` blocks all evacuations (any failure percentage exceeds 0%)
23462392
- Value of `100` disables threshold checking (nothing can exceed 100%)
23472393
- Aggregate-aware calculation uses only evacuable hosts as denominator
2394+
- **Small clusters**: On a 3-node cluster, 2 simultaneous failures = 66.7%, which exceeds the default 50%. On a 4-node cluster, 2 failures = 50% (not blocked, since the check is `>`). Operators of small clusters who expect multi-node failures should raise this value. See [Small Cluster Tuning](#small-cluster-tuning).
23482395

23492396
---
23502397

@@ -3008,6 +3055,34 @@ config:
30083055
30093056
---
30103057
3058+
#### HEARTBEAT_CLIFF_THRESHOLD
3059+
**Type**: Integer
3060+
**Default**: `50`
3061+
**Range**: `10-100` (percentage)
3062+
3063+
**Description**: Maximum percentage of heartbeat-active hosts that can disappear in a single poll cycle before cliff detection triggers. When the drop exceeds this threshold, InstanceHA assumes a listener-side network partition rather than simultaneous host failures and skips all fencing for that cycle.
3064+
3065+
**Usage**:
3066+
- Evaluated in `_filter_reachable_hosts()` each poll cycle when `CHECK_HEARTBEAT` is enabled
3067+
- Compares `heartbeat_previous_active_count` (previous cycle) against current active count
3068+
- Only evaluated when the previous active count was >= 3 (avoids false triggers during startup ramp-up)
3069+
3070+
**Behavior**:
3071+
- If drop percentage >= `HEARTBEAT_CLIFF_THRESHOLD`: skip all fencing this cycle, emit `HeartbeatCliff` K8s event (Warning)
3072+
- Baseline count is preserved during a cliff event, so the cliff re-triggers on subsequent cycles until heartbeats recover
3073+
- When no cliff is detected, update `heartbeat_previous_active_count` for the next cycle
3074+
3075+
**Small Cluster Considerations**: On a 3-node cluster with all nodes sending heartbeats, losing 2 nodes simultaneously causes a 66.7% drop — exceeding the default 50% threshold. This blocks fencing for that cycle. If simultaneous multi-node failures are expected (not partitions), raise this value (e.g., `100` to disable cliff detection entirely). See [Small Cluster Tuning](#small-cluster-tuning).
3076+
3077+
**Example**:
3078+
```yaml
3079+
config:
3080+
CHECK_HEARTBEAT: true
3081+
HEARTBEAT_CLIFF_THRESHOLD: 100 # Disable cliff detection (trust individual heartbeat status)
3082+
```
3083+
3084+
---
3085+
30113086
### Service Control Options
30123087

30133088
#### DISABLED

internal/instanceha/funcs.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ func Deployment(
177177
FSGroup: ptr.To(instanceHaUID),
178178
},
179179
Volumes: volumes,
180-
TerminationGracePeriodSeconds: ptr.To[int64](30),
180+
TerminationGracePeriodSeconds: ptr.To[int64](45),
181181
Containers: []corev1.Container{{
182182
Name: "instanceha",
183183
Image: containerImage,

templates/instanceha/bin/instanceha.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -405,11 +405,18 @@ def _make_ssl_request(method: str, url: str, auth: tuple, timeout: int,
405405

406406

407407
def _wait_for_power_off(check_func: Callable[[], Optional[str]], timeout: int,
408-
host_identifier: str, expected_state: str, agent_type: str) -> bool:
408+
host_identifier: str, expected_state: str, agent_type: str,
409+
shutdown_event=None) -> bool:
409410
"""Wait for host to power off by polling power state."""
410411
deadline = time.monotonic() + timeout
411412
while time.monotonic() < deadline:
412-
time.sleep(1)
413+
if shutdown_event and shutdown_event.is_set():
414+
logging.warning("Shutdown requested, aborting power-off wait for %s", host_identifier)
415+
return False
416+
if shutdown_event:
417+
shutdown_event.wait(1)
418+
else:
419+
time.sleep(1)
413420
power_state = check_func()
414421
if power_state == expected_state:
415422
logging.info("%s power off successful for %s", agent_type, host_identifier)
@@ -1720,7 +1727,7 @@ def _host_evacuate(connection, failed_service, service, target_host=None) -> boo
17201727
logging.info("Nothing to evacuate")
17211728
return True
17221729

1723-
time.sleep(service.config.get_config_value('DELAY'))
1730+
_interruptible_sleep(service.shutdown_event, service.config.get_config_value('DELAY'))
17241731

17251732
if service.config.get_config_value('ORCHESTRATED_RESTART') is True or service.config.get_config_value('SMART_EVACUATION'):
17261733
return _concurrent_evacuate(connection, evacuables, service, host, failed_service.id, target_host=target_host)
@@ -1929,7 +1936,7 @@ def _server_evacuate_future(connection, server, target_host=None,
19291936
return False
19301937

19311938
logging.debug("Starting evacuation of %s", response.uuid)
1932-
time.sleep(INITIAL_EVACUATION_WAIT_SECONDS)
1939+
_interruptible_sleep(shutdown_event, INITIAL_EVACUATION_WAIT_SECONDS)
19331940

19341941
result = _monitor_evacuation(connection, server.id, response.uuid, start_time,
19351942
max_retries=max_retries,
@@ -2194,7 +2201,7 @@ def _get_power_state(agent_type: str, session: requests.Session = None, **kwargs
21942201

21952202
return None
21962203

2197-
def _redfish_reset(url, user, passwd, timeout, action, config_mgr):
2204+
def _redfish_reset(url, user, passwd, timeout, action, config_mgr, shutdown_event=None):
21982205
"""Perform a Redfish reset operation on a computer system."""
21992206
if not all([url, user, passwd, action]):
22002207
logging.error("Redfish reset failed: missing required parameters")
@@ -2225,7 +2232,8 @@ def _redfish_reset(url, user, passwd, timeout, action, config_mgr):
22252232
try:
22262233
return _wait_for_power_off(
22272234
lambda: _get_power_state('redfish', session=poll_session, url=url, user=user, passwd=passwd, timeout=timeout, config_mgr=config_mgr),
2228-
timeout, safe_url, 'OFF', 'Redfish')
2235+
timeout, safe_url, 'OFF', 'Redfish',
2236+
shutdown_event=shutdown_event)
22292237
finally:
22302238
poll_session.close()
22312239
else:
@@ -2345,7 +2353,13 @@ def _bmh_wait_for_power_off(get_url, headers, cacert, host, timeout, poll_interv
23452353

23462354
with requests.Session() as session:
23472355
while time.monotonic() < timeout_end:
2348-
time.sleep(poll_interval)
2356+
if service.shutdown_event and service.shutdown_event.is_set():
2357+
logging.warning("Shutdown requested, aborting power-off wait for %s", host)
2358+
return False
2359+
if service.shutdown_event:
2360+
service.shutdown_event.wait(poll_interval)
2361+
else:
2362+
time.sleep(poll_interval)
23492363

23502364
try:
23512365
response = session.get(get_url, headers=headers, verify=cacert, timeout=request_timeout)
@@ -2393,7 +2407,7 @@ def _validate_fencing_inputs(fencing_data, agent_name) -> bool:
23932407
validations['username'] = fencing_data["login"]
23942408
return validate_inputs(validations, agent_name)
23952409

2396-
def _execute_ipmi_fence(host, action, fencing_data, timeout) -> bool:
2410+
def _execute_ipmi_fence(host, action, fencing_data, timeout, shutdown_event=None) -> bool:
23972411
"""Execute IPMI fencing operation with retry logic."""
23982412
timeout = max(5, min(300, int(timeout or 30)))
23992413
ipaddr, ipport, login, passwd = fencing_data["ipaddr"], fencing_data["ipport"], \
@@ -2415,7 +2429,8 @@ def _execute_ipmi_fence(host, action, fencing_data, timeout) -> bool:
24152429
if action == 'off':
24162430
return _wait_for_power_off(
24172431
lambda: _get_power_state('ipmi', ip=ipaddr, port=ipport, user=login, passwd=passwd, timeout=timeout),
2418-
timeout, host, 'CHASSIS POWER IS OFF', 'IPMI')
2432+
timeout, host, 'CHASSIS POWER IS OFF', 'IPMI',
2433+
shutdown_event=shutdown_event)
24192434

24202435
logging.info("IPMI %s successful for %s", action, host)
24212436
return True
@@ -2433,7 +2448,8 @@ def _fence_ipmi(host, action, fencing_data, service):
24332448
if not _validate_fencing_inputs(fencing_data, "IPMI"):
24342449
return False
24352450
timeout = fencing_data.get("timeout", service.config.get_config_value('FENCING_TIMEOUT'))
2436-
return _execute_ipmi_fence(host, action, fencing_data, timeout)
2451+
return _execute_ipmi_fence(host, action, fencing_data, timeout,
2452+
shutdown_event=service.shutdown_event)
24372453

24382454

24392455
def _fence_redfish(host, action, fencing_data, service):
@@ -2448,7 +2464,8 @@ def _fence_redfish(host, action, fencing_data, service):
24482464
redfish_action = "ForceOff" if action == "off" else "On"
24492465
timeout = fencing_data.get("timeout", service.config.get_config_value('FENCING_TIMEOUT'))
24502466
return _redfish_reset(url, fencing_data["login"], fencing_data["passwd"],
2451-
timeout, redfish_action, service.config)
2467+
timeout, redfish_action, service.config,
2468+
shutdown_event=service.shutdown_event)
24522469

24532470

24542471
def _fence_bmh(host, action, fencing_data, service):
@@ -3545,6 +3562,7 @@ def _sigterm_handler(signum, frame):
35453562

35463563
services = conn.services.list(binary="nova-compute")
35473564
if not services:
3565+
logging.info("Poll cycle completed: no compute services found, next poll in %ds", poll_interval)
35483566
service.shutdown_event.wait(poll_interval)
35493567
continue
35503568

@@ -3556,9 +3574,12 @@ def _sigterm_handler(signum, frame):
35563574
_process_stale_services(conn, service, services, compute_nodes_list, to_resume)
35573575
_process_reenabling(conn, service, to_reenable)
35583576

3577+
stale_count = len(compute_nodes_list)
35593578
if not service.ready:
35603579
service.ready = True
35613580
logging.info("Readiness probe active — first poll cycle completed")
3581+
logging.info("Poll cycle completed: %d compute services, %d stale, next poll in %ds",
3582+
len(services), stale_count, poll_interval)
35623583
consecutive_failures = 0
35633584
POLL_CONSECUTIVE_FAILURES.set(0)
35643585
POLL_CYCLE_TOTAL.labels(result='success').inc()

test/instanceha/test_config_features.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ def test_delay_zero_skips_sleep(self):
495495
mock_server.id = 'server-123'
496496

497497
# Mock the evacuation functions to avoid actual evacuation
498-
with patch('time.sleep') as mock_sleep:
498+
with patch('instanceha._interruptible_sleep') as mock_sleep:
499499
with patch('instanceha._get_evacuable_servers', return_value=[mock_server]):
500500
with patch('instanceha._traditional_evacuate', return_value=True):
501501
failed_service = Mock()
@@ -505,8 +505,8 @@ def test_delay_zero_skips_sleep(self):
505505
# Call _host_evacuate which contains the DELAY sleep
506506
result = instanceha._host_evacuate(mock_conn, failed_service, service)
507507

508-
# Verify sleep was called with 0 (DELAY=0)
509-
mock_sleep.assert_called_once_with(0)
508+
# Verify interruptible sleep was called with shutdown_event and 0 (DELAY=0)
509+
mock_sleep.assert_called_once_with(service.shutdown_event, 0)
510510

511511
def test_delay_non_zero_delays_evacuation(self):
512512
"""Test that DELAY>0 delays evacuation by specified seconds."""
@@ -524,7 +524,7 @@ def test_delay_non_zero_delays_evacuation(self):
524524
mock_server = Mock()
525525
mock_server.id = 'server-123'
526526

527-
with patch('time.sleep') as mock_sleep:
527+
with patch('instanceha._interruptible_sleep') as mock_sleep:
528528
with patch('instanceha._get_evacuable_servers', return_value=[mock_server]):
529529
with patch('instanceha._traditional_evacuate', return_value=True):
530530
failed_service = Mock()
@@ -533,8 +533,8 @@ def test_delay_non_zero_delays_evacuation(self):
533533

534534
result = instanceha._host_evacuate(mock_conn, failed_service, service)
535535

536-
# Verify sleep was called with configured DELAY value
537-
mock_sleep.assert_called_once_with(5)
536+
# Verify interruptible sleep was called with shutdown_event and configured DELAY value
537+
mock_sleep.assert_called_once_with(service.shutdown_event, 5)
538538

539539
def test_delay_with_smart_evacuation(self):
540540
"""Test that DELAY works with SMART_EVACUATION enabled."""
@@ -552,7 +552,7 @@ def test_delay_with_smart_evacuation(self):
552552
mock_server = Mock()
553553
mock_server.id = 'server-123'
554554

555-
with patch('time.sleep') as mock_sleep:
555+
with patch('instanceha._interruptible_sleep') as mock_sleep:
556556
with patch('instanceha._get_evacuable_servers', return_value=[mock_server]):
557557
with patch('instanceha._concurrent_evacuate', return_value=True):
558558
failed_service = Mock()
@@ -561,8 +561,8 @@ def test_delay_with_smart_evacuation(self):
561561

562562
result = instanceha._host_evacuate(mock_conn, failed_service, service)
563563

564-
# Verify sleep was called with DELAY before smart evacuation
565-
mock_sleep.assert_called_once_with(3)
564+
# Verify interruptible sleep was called with shutdown_event and DELAY before smart evacuation
565+
mock_sleep.assert_called_once_with(service.shutdown_event, 3)
566566

567567
def test_delay_boundary_values(self):
568568
"""Test DELAY boundary values (min=0, max=300)."""

test/instanceha/test_coverage_gaps.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,16 +274,20 @@ def test_invalid_ip_returns_false(self):
274274
def test_valid_params_calls_execute(self, mock_exec):
275275
"""Valid params delegates to _execute_ipmi_fence."""
276276
data = {"ipaddr": "192.168.1.10", "ipport": "623", "login": "admin", "passwd": "secret"}
277-
result = instanceha._fence_ipmi("host1", "off", data, self._make_service())
277+
svc = self._make_service()
278+
result = instanceha._fence_ipmi("host1", "off", data, svc)
278279
self.assertTrue(result)
279-
mock_exec.assert_called_once_with("host1", "off", data, 30)
280+
mock_exec.assert_called_once_with("host1", "off", data, 30,
281+
shutdown_event=svc.shutdown_event)
280282

281283
@patch('instanceha._execute_ipmi_fence', return_value=True)
282284
def test_uses_custom_timeout_from_fencing_data(self, mock_exec):
283285
"""Uses timeout from fencing_data if provided."""
284286
data = {"ipaddr": "192.168.1.10", "ipport": "623", "login": "admin", "passwd": "secret", "timeout": 60}
285-
instanceha._fence_ipmi("host1", "off", data, self._make_service())
286-
mock_exec.assert_called_once_with("host1", "off", data, 60)
287+
svc = self._make_service()
288+
instanceha._fence_ipmi("host1", "off", data, svc)
289+
mock_exec.assert_called_once_with("host1", "off", data, 60,
290+
shutdown_event=svc.shutdown_event)
287291

288292
@patch('instanceha._execute_ipmi_fence', return_value=False)
289293
def test_returns_false_when_execute_fails(self, mock_exec):

0 commit comments

Comments
 (0)