(本文基于Ansible 2.7)
在使用ansible执行运维作业的过程中经常会遇到某些目标服务器由于种种五花八门的原因失去响应,尤其是在作业在远端主机执行过程中失去响应的情况,由于本地的子进程无法收到运行结果,可能卡上几天都不退出。例如我们曾经遇到过的,需要在大约15000+虚拟机上执行批量任务,其中两台虚拟机在建立ssh连接时还正常,但随后内存耗尽,远端任务失去响应,这个批量作业最后只能通过查找并强行终止子进程的方式退出。
Ansible本身的超时机制(如PERSISTENT_COMMAND_TIMEOUT等),对这种情况并不生效,因此我萌生了加入一些超时机制的想法。
在Ansible 源码解析:forks并发机制的实现这篇文章中,我们提到StrategyBase类的_queue_task方法(lib/ansible/plugins/strategy/_init_.py,279-336行)用来启动task的执行子进程,不过并没有深入讨论子进程本身的内容。
加入task超时机制的思路与此有关,想法是通过task对象本身传入一个超时时限,这样使用者可以根据task的内容判断执行多久还没有返回即可认为任务已经失败,可以终止执行进程。在子进程内部可以取到这个时限的值,并使用计时器触发结束当前进程。同时,我们不希望过多干涉Ansible已有的进程调度机制和结果处理机制。
StrategyBase使用WorkerProcess类(lib/ansible/executor/process/worker.py, 继承自multiprocessing.Process)来启动工作子进程,最初我们尝试用在WorkerProcess类中加入计时器,任务执行超时时直接终止WorkerProcess进程的运行这种简单粗暴的方法来实现超时终止任务,但后来发现由于WorkerProcess与父进程间通过multiprocessing.Queue来进行通信,强行终止WorkerProcess可能导致死锁,故改而采取设法在TaskExecutor.run()方法中抛出异常来实现目的。代价则是不得不引入新的依赖psutil来清理WorkerProcess产生的子进程(有可能成为孤儿进程)
_task_timeout = FieldAttribute(isa='int', default=0)
class AnsibleTaskTimeoutError(AnsibleRuntimeError):
'''an ansible task running lasts beyond the limit given'''
pass
def __init__(self, host, task, job_vars, play_context, new_stdin, loader, shared_loader_obj, final_q, task_start_time, task_timeout=0):
self._host = host
self._task = task
self._job_vars = job_vars
self._play_context = play_context
self._new_stdin = new_stdin
self._loader = loader
self._shared_loader_obj = shared_loader_obj
self._connection = None
self._final_q = final_q
self._start_time = task_start_time
self._loop_eval_error = None
self._task_timeout = task_timeout
self._task.squash()
def exec_timeout_handler(self,signal,frame):
if signal == 14:
display.debug('Task %s: execution timed out!' % self._task._uuid)
raise AnsibleTaskTimeoutError
if self._task_timeout is not None and self._task_timeout > 0:
signal.signal(signal.SIGALRM, self.exec_timeout_handler)
signal.alarm(self._task_timeout)
这样在任务运行达到超时时间限制的时候,TaskExecutor类就会抛出异常。
在TaskExecutor.run()中增加新的except语句来处理AnsibleTaskTimeoutError:
except AnsibleTaskTimeoutError as e:
return dict(failed=True, task_timeout=True, msg='Task execution timed out, limit is: %s second(s)' % self._task_timeout)
并在finally语句块中加入以下代码来清理子进程:
import psutil
for p in psutil.Process(os.getpid()).children():
if p.is_running():
p.terminate()
display.debug("task execution subprocess: %d terminated" % p.pid)
(这里如果求保险的话其实可以写成children(recursive=True),然后再通过psutil.wait_procs来赶尽杀绝。但考虑到此时整个play尚未结束,没必要进行彻底的进程清理,于是将这部分放到了TaskQueueManager._cleanup_processes里,这块有机会再写)
executor_result = TaskExecutor(
self._host,
self._task,
self._task_vars,
self._play_context,
self._new_stdin,
self._loader,
self._shared_loader_obj,
self._final_q,
self._task.task_timeout
).run()
这个解决方案目前已经在生产环境稳定运行超过两个月,没有再出现进程死锁或产生孤儿进程的情况,问题应该是解决了。