Skip to content

Commit 1ab76a0

Browse files
committed
dispatcher: mark nodes down on ansible hardware failures
We already mark a node down after it fails to reimage 10 times in a row. When ceph-cm-ansible's "Ensure we found enough OSD disks" task fails, though, we know right away that the node is missing a disk, and every job that lands on it until someone notices will fail the same way. Teach FailureAnalyzer to spot failure messages that mean the hardware itself is broken, and have the supervisor mark those nodes down once the job process exits. The job process only archives the ansible failure log as it always has; node manipulation stays on the supervisor side so that a running job never depends on the lock server being reachable. Only the node's status is updated. The job still holds the lock at that point, and unlock_targets() refuses to unlock a node whose description no longer matches the job's archive path. The behavior can be turned off with mark_down_on_hardware_failure, and is documented in docs/node_health.rst. Fixes: https://tracker.ceph.com/issues/75669 Signed-off-by: David Galloway <david.galloway@ibm.com>
1 parent 1fcba62 commit 1ab76a0

8 files changed

Lines changed: 348 additions & 1 deletion

File tree

docs/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Content Index
1414
downburst_vms.rst
1515
INSTALL.rst
1616
LAB_SETUP.rst
17+
node_health.rst
1718
exporter.rst
1819
commands/list.rst
1920
ChangeLog.rst

docs/node_health.rst

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
.. _node_health:
2+
3+
===================
4+
Marking Nodes Down
5+
===================
6+
7+
Every node in the lock server (paddles) has an ``up`` flag. A node that is
8+
marked down is never handed out to a job, so a broken machine stops eating
9+
scheduled jobs until someone looks at it. Nodes are marked down by hand with
10+
``teuthology-lock --update --status down <node>``, and automatically by
11+
``teuthology-supervisor`` in the two cases described below.
12+
13+
All of this happens in the supervisor process, not in the job process. Tasks
14+
never talk to paddles about node status: a job should not depend on the lock
15+
server being reachable, and the supervisor is what owns the nodes for the
16+
duration of a job anyway.
17+
18+
Repeated reimaging failures
19+
===========================
20+
21+
If reimaging a node fails, the supervisor asks paddles for that node's last 10
22+
jobs. If all 10 of them failed to reimage, the node is marked down with the
23+
description ``reimage failed 10 times``. See
24+
``check_for_reimage_failures_and_mark_down()`` in
25+
``teuthology/dispatcher/supervisor.py``.
26+
27+
Hardware failures reported by ansible
28+
=====================================
29+
30+
Some ansible failures tell us right away that the node itself is broken - a
31+
disk that is missing or dead, for example. Waiting for such a node to fail ten
32+
more jobs is pure waste, so the supervisor marks it down as soon as it sees
33+
one.
34+
35+
How it works
36+
------------
37+
38+
#. The ``ansible`` task sets ``ANSIBLE_FAILURE_LOG`` when it runs
39+
``ansible-playbook``. ceph-cm-ansible's ``failure_log`` callback plugin
40+
(``callback_plugins/failure_log.py``) writes every task failure to that
41+
file as YAML.
42+
#. When the playbook fails, the task archives the log to
43+
``ansible_failures.yaml`` in the job's archive directory.
44+
#. After the job process exits, the supervisor reads that file back and passes
45+
it to ``FailureAnalyzer.find_hardware_failures()``, in
46+
``teuthology/task/ansible.py``.
47+
#. Any node whose failure message matches a known hardware-failure pattern is
48+
marked down, as long as it is one of the job's own targets. This is
49+
``check_for_hardware_failures_and_mark_down()`` in
50+
``teuthology/dispatcher/supervisor.py``.
51+
52+
Only the node's status is changed; its lock description is left alone. The job
53+
still holds the lock at that point, and ``unlock_targets()`` refuses to unlock
54+
a node whose description no longer matches the job's archive path.
55+
56+
Expected format
57+
---------------
58+
59+
The failure log is a YAML document keyed by hostname, with each value being the
60+
ansible result dict for the failed task. Only the ``msg`` field is examined -
61+
either on the record itself, or on each entry of its ``results`` list::
62+
63+
trial007.front.sepia.ceph.com:
64+
_ansible_no_log: false
65+
changed: false
66+
msg: 'Wanted 2 disks of ~1700 GB (rotational=False), but only matched 1: [''nvme1n1'']'
67+
68+
Note that the callback plugin records the result, not the name of the task that
69+
produced it, so matching is done on the message text. Whitespace in ``msg`` is
70+
collapsed before matching, because ansible wraps long messages across lines.
71+
72+
The patterns themselves live in ``FailureAnalyzer.hardware_failure_patterns``.
73+
Each is a regular expression matched case-insensitively against the message.
74+
Currently there is one:
75+
76+
.. list-table::
77+
:header-rows: 1
78+
79+
* - Pattern
80+
- Produced by
81+
* - ``Wanted \d+ disks? of .+ but only matched \d+``
82+
- ceph-cm-ansible's ``Ensure we found enough OSD disks`` task, in
83+
``roles/testnode/tasks/configure_lvm.yml``
84+
85+
This is a coupling worth keeping in mind: adding a pattern here means relying
86+
on the exact wording of a task in ceph-cm-ansible, so changing one of those
87+
messages means changing the matching pattern too. Only add patterns for
88+
failures that really do mean the hardware is broken - a node marked down stays
89+
down until a human brings it back up.
90+
91+
Disabling it
92+
------------
93+
94+
Set ``mark_down_on_hardware_failure: false`` in :ref:`site_config` to turn this
95+
behavior off. The failure log is still archived either way.

docs/siteconfig.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ Here is a sample configuration with many of the options set and documented::
9696
# it is killed by the supervisor process.
9797
max_job_time: 259200
9898

99+
# Whether the supervisor should mark a node down when ansible reports a
100+
# hardware failure on it. See :ref:`node_health`.
101+
mark_down_on_hardware_failure: true
102+
99103
# The template from which the URL of the repository containing packages
100104
# is built.
101105
#
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import os
2+
import yaml
3+
4+
from unittest.mock import patch
5+
6+
from teuthology.dispatcher import supervisor
7+
8+
9+
HARDWARE_FAILURE = {
10+
'trial007.front.sepia.ceph.com': {
11+
'_ansible_no_log': False,
12+
'changed': False,
13+
'msg': "Wanted 2 disks of ~1700 GB (rotational=False), but only"
14+
" matched 1: ['nvme1n1']",
15+
},
16+
}
17+
18+
OTHER_FAILURE = {
19+
'trial007.front.sepia.ceph.com': {
20+
'changed': False,
21+
'msg': 'Failure talking to yum: failure',
22+
},
23+
}
24+
25+
26+
class TestCheckHardwareFailureMarkDown(object):
27+
def setup_method(self):
28+
self.the_function = supervisor.check_for_hardware_failures_and_mark_down
29+
30+
def job_config(self, tmp_path, failure_log=None, targets=None):
31+
if failure_log is not None:
32+
path = os.path.join(str(tmp_path), supervisor.FAILURE_LOG_NAME)
33+
with open(path, 'w') as f:
34+
yaml.safe_dump(failure_log, f)
35+
if targets is None:
36+
targets = {
37+
'ubuntu@trial007.front.sepia.ceph.com': 'ssh-ed25519',
38+
}
39+
return dict(
40+
archive_path=str(tmp_path),
41+
targets=targets,
42+
)
43+
44+
@patch('teuthology.lock.ops.update_lock')
45+
def test_hardware_failure(self, m_update_lock, tmp_path):
46+
job_config = self.job_config(tmp_path, HARDWARE_FAILURE)
47+
self.the_function(job_config)
48+
m_update_lock.assert_called_once_with('trial007', status='down')
49+
50+
@patch('teuthology.lock.ops.update_lock')
51+
def test_other_failure(self, m_update_lock, tmp_path):
52+
job_config = self.job_config(tmp_path, OTHER_FAILURE)
53+
self.the_function(job_config)
54+
assert m_update_lock.called is False
55+
56+
@patch('teuthology.lock.ops.update_lock')
57+
def test_no_failure_log(self, m_update_lock, tmp_path):
58+
job_config = self.job_config(tmp_path)
59+
self.the_function(job_config)
60+
assert m_update_lock.called is False
61+
62+
@patch('teuthology.lock.ops.update_lock')
63+
def test_host_is_not_a_target(self, m_update_lock, tmp_path):
64+
job_config = self.job_config(
65+
tmp_path,
66+
HARDWARE_FAILURE,
67+
targets={'ubuntu@trial008.front.sepia.ceph.com': 'ssh-ed25519'},
68+
)
69+
self.the_function(job_config)
70+
assert m_update_lock.called is False
71+
72+
@patch('teuthology.lock.ops.update_lock')
73+
def test_disabled(self, m_update_lock, tmp_path):
74+
job_config = self.job_config(tmp_path, HARDWARE_FAILURE)
75+
with patch.object(
76+
supervisor.teuth_config, 'mark_down_on_hardware_failure', False
77+
):
78+
self.the_function(job_config)
79+
assert m_update_lock.called is False
80+
81+
@patch('teuthology.lock.ops.update_lock')
82+
def test_unparseable_failure_log(self, m_update_lock, tmp_path):
83+
job_config = self.job_config(tmp_path)
84+
path = os.path.join(str(tmp_path), supervisor.FAILURE_LOG_NAME)
85+
with open(path, 'w') as f:
86+
f.write('{not: valid: yaml')
87+
self.the_function(job_config)
88+
assert m_update_lock.called is False

tests/task/test_ansible.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,75 @@ def test_lines(self, line, result):
5656
obj = self.klass()
5757
assert obj.analyze_line(line) == result
5858

59+
@mark.parametrize(
60+
'failure_log,result',
61+
[
62+
[
63+
"",
64+
{},
65+
],
66+
[
67+
yaml.safe_dump({
68+
"smithi001.front.sepia.ceph.com": {
69+
"changed": False,
70+
"msg": "Failure talking to yum: failure",
71+
},
72+
}),
73+
{},
74+
],
75+
[
76+
yaml.safe_dump({
77+
"trial007.front.sepia.ceph.com": {
78+
"_ansible_no_log": False,
79+
"changed": False,
80+
"msg": "Wanted 2 disks of ~1700 GB (rotational=False),"
81+
" but only matched 1: ['nvme1n1']",
82+
},
83+
}),
84+
{
85+
"trial007.front.sepia.ceph.com":
86+
"Wanted 2 disks of ~1700 GB (rotational=False), but"
87+
" only matched 1: ['nvme1n1']",
88+
},
89+
],
90+
[
91+
# ansible wraps the message onto multiple lines
92+
yaml.safe_dump({
93+
"trial007.front.sepia.ceph.com": {
94+
"msg": "Wanted 2 disks of ~1700 GB\n"
95+
"(rotational=False), but only matched\n"
96+
"1: ['nvme1n1']",
97+
},
98+
}),
99+
{
100+
"trial007.front.sepia.ceph.com":
101+
"Wanted 2 disks of ~1700 GB (rotational=False), but"
102+
" only matched 1: ['nvme1n1']",
103+
},
104+
],
105+
[
106+
yaml.safe_dump({
107+
"trial007.front.sepia.ceph.com": {
108+
"results": [
109+
{"msg": "Failure talking to yum: failure"},
110+
{"msg": "Wanted 2 disks of ~1700 GB"
111+
" (rotational=False), but only matched 0:"
112+
" []"},
113+
],
114+
},
115+
}),
116+
{
117+
"trial007.front.sepia.ceph.com":
118+
"Wanted 2 disks of ~1700 GB (rotational=False), but"
119+
" only matched 0: []",
120+
},
121+
],
122+
]
123+
)
124+
def test_find_hardware_failures(self, failure_log, result):
125+
obj = self.klass()
126+
assert obj.find_hardware_failures(failure_log) == result
127+
59128

60129
class TestAnsibleTask(TestTask):
61130
klass = Ansible

teuthology/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ class TeuthologyConfig(YamlConfig):
165165
'job_threshold': 500,
166166
'lab_domain': 'front.sepia.ceph.com',
167167
'lock_server': 'https://paddles.front.sepia.ceph.com/',
168+
'mark_down_on_hardware_failure': True,
168169
'max_job_age': 1209600, # 2 weeks
169170
'max_job_time': 259200, # 3 days
170171
'nsupdate_url': 'https://nsupdate.front.sepia.ceph.com/update',

teuthology/dispatcher/supervisor.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from teuthology.config import FakeNamespace
1717
from teuthology.lock import ops as lock_ops
1818
from teuthology.task import internal
19+
from teuthology.task.ansible import FailureAnalyzer, FAILURE_LOG_NAME
1920
from teuthology.misc import decanonicalize_hostname as shortname
2021
from teuthology.lock import query
2122
from teuthology.util import sentry
@@ -181,6 +182,7 @@ def run_job(job_config: dict,
181182
else:
182183
log.info('Success!')
183184
if 'targets' in job_config:
185+
check_for_hardware_failures_and_mark_down(job_config)
184186
unlock_targets(job_config)
185187
return p.returncode
186188

@@ -227,6 +229,48 @@ def check_for_reimage_failures_and_mark_down(targets, count=10):
227229
)
228230

229231

232+
def check_for_hardware_failures_and_mark_down(job_config):
233+
"""
234+
Mark nodes down as soon as ansible tells us their hardware is broken,
235+
instead of waiting for them to fail a whole streak of jobs first.
236+
237+
The job process itself never talks to paddles about this; it just archives
238+
the ansible failure log, which we read back here. See docs/node_health.rst.
239+
240+
:param job_config: dict, job config data
241+
"""
242+
if not teuth_config.mark_down_on_hardware_failure:
243+
return
244+
failure_log = os.path.join(job_config['archive_path'], FAILURE_LOG_NAME)
245+
if not os.path.exists(failure_log):
246+
return
247+
try:
248+
with open(failure_log) as f:
249+
failures = FailureAnalyzer().find_hardware_failures(f.read())
250+
except Exception:
251+
log.exception("Failed to check %s for hardware failures", failure_log)
252+
return
253+
targets = set(shortname(t) for t in job_config.get('targets', dict()))
254+
for hostname, msg in sorted(failures.items()):
255+
machine_name = shortname(hostname)
256+
if machine_name not in targets:
257+
log.warning(
258+
"Not marking %s down; it is not a target of this job",
259+
machine_name,
260+
)
261+
continue
262+
log.error(
263+
"Marking %s down due to a hardware failure: %s", machine_name, msg
264+
)
265+
# Only the status is updated here: the job still holds the lock, and
266+
# unlock_targets() refuses to unlock a node whose description no longer
267+
# matches the job's archive path.
268+
try:
269+
lock_ops.update_lock(machine_name, status='down')
270+
except Exception:
271+
log.exception("Failed to mark %s down", machine_name)
272+
273+
230274
def reimage(job_config):
231275
# Reimage the targets specified in job config
232276
# and update their keys in config after reimaging

0 commit comments

Comments
 (0)