背景
我們團隊使用flask+celery+ansible+mongodb開發(fā)了一個性能測試平臺,簡單來說要做的事情就是將用戶的一系列任務(wù)(主要是shell指令)通過ansible分發(fā)到不同的機器上去執(zhí)行,獲取任務(wù)的回顯和狀態(tài)在平臺上進行顯示和存儲。
問題
需要解決的問題如下:
- ansible的任務(wù)我們自動封裝成了playbook,執(zhí)行過程中的輸出希望實時進行輸出,方便定位問題;
- celery原生對于任務(wù)的結(jié)果是不會進行持久化存儲的,因此需要自己進行狀態(tài)和結(jié)果的同步;
- celery原生對于異常和主動停止的任務(wù)是不會進行結(jié)果存儲的,同樣需要額外進行處理;
方案
方法挺多,目前嘗試了各種方案之后,最終使用了如下方案:
redis作為result-backend并且開啟result_extended,開啟一個monitor進程用于消費worker的任務(wù)狀態(tài)變化事件,具體來說分如下兩步:
生產(chǎn)者處理
第一步:在celery的task中設(shè)置實時更新狀態(tài)給result_backend;
import subprocess
import tempfile
import os
import time
from collections import deque
from typing import List
from celery import Celery, Task
celery_app = Celery(
'celery_app',
broker=os.getenv('REDIS_URI', "redis://localhost:6379"),
backend=os.getenv('REDIS_URI', "redis://localhost:6379")
)
celery_app.conf.update(
task_serializer="pickle",
result_serializer="pickle",
result_expires=0,
accept_content=['pickle'],
timezone="Asia/Shanghai",
enable_utc=False,
result_extended=True
)
class CallbackTask(Task):
def on_failure(self, exc, task_id, args, kwargs, einfo):
'''
exc – The exception raised by the task.
task_id – Unique id of the failed task.
args – Original arguments for the task that failed.
kwargs – Original keyword arguments for the task that failed.
'''
outputs = self.request.kwargs.get("outputs", [])
# 注意點1:任務(wù)失敗的時候記錄outputs信息并且主動存儲到后端
self.update_state(state="FAILURE", meta={"outputs": outputs})
self.backend.store_result(task_id, {"outputs": outputs}, "FAILURE")
super().on_failure(exc, task_id, args, kwargs, einfo)
# 注意點1:這里要指定base類為我們重載過on_failure方法的類
@celery_app.task(base=CallbackTask, bind=True)
def run_commands_as_playbook(self, pattern, commands: List[Command], envs: dict = None):
tmp = tempfile.NamedTemporaryFile(delete=False)
make_playbook(pattern, commands, tmp.name, envs)
def run_playbook_with_live_output(fname):
cmd = f"ansible-playbook -i {INVENTORY_FILE} {fname} "
# 注意點2、3:必須用Popen來獲取實時輸出,并且指定universal_newlines來獲取str類型的輸出
p = subprocess.Popen(cmd, shell=True, bufsize=1, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True)
# 注意點4:限制存儲的輸出的最大長度
outputs = deque(maxlen=65535)
outputs.append(cmd)
self.update_state(state="STARTED", meta={"outputs": list(outputs), "pid": p.pid})
last_update_time = time.time()
while True:
line = p.stdout.readline()
if line:
outputs.append(line)
current_time = time.time()
# 注意點5:限制一下更新output的頻率吧,1秒1次夠用了
if current_time - last_update_time > 1:
# 注意點1:這里就需要實時的保存用戶的輸出信息
self.update_state(state="STARTED", meta={"outputs": list(outputs), "pid": p.pid})
last_update_time = current_time
else:
break
return list(outputs)
return run_playbook_with_live_output(tmp.name)
需要注意的是:
- 對于主動中止或者拋錯的任務(wù),celery默認只會返回
Exception
,導(dǎo)致運行過程中的輸出丟失,因此需要重載一下on_failure
這個方法并且綁定到對應(yīng)任務(wù); - 對于長時間執(zhí)行的任務(wù),如果直接調(diào)用
subproces.run
或者subprocess.check_ouput
等方法,就無法實時獲取任務(wù)的輸出了,因此這里使用了Popen
方法,并且將stdout重定向到subprocess.PIPE
里,然后從PIPE里逐行去讀取結(jié)果; -
subprocess.Popen
里面設(shè)置了參數(shù)universal_newlines=True
,否則進程的輸出是bytes
類型,需要額外處理一下,否則存儲到redis的時候會報錯的; - 任務(wù)輸出的日志可能非常多,我這里限制了一下輸出的最大長度,我這里把中間的信息放到
deque
里面了,但是最后寫回后端數(shù)據(jù)庫的時候要注意轉(zhuǎn)成list類型,否則redis可能不知道如何序列化deque
類型; - 每有一行輸出就調(diào)用一次
self.update_state
操作,而這個操作涉及到將結(jié)果更新到數(shù)據(jù)庫,當某些命令輸出過長(比如上萬行時),執(zhí)行的效率就會非常的低,因此需要限制一下調(diào)用頻率;
消費者處理
第二步:在monitor進程中接收任務(wù)狀態(tài)變化的事件,然后將狀態(tài)寫回到數(shù)據(jù)庫;
import os
import logging
from pymongo import MongoClient
from runner import celery_app
from urllib.parse import urlparse
from celery import states
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger()
MONGO_URI = os.getenv("MONGO_URI", "mongodb://localhost:27017/myproject")
MONGO_DB = urlparse(MONGO_URI).path.strip("/")
mongo_client = MongoClient(MONGO_URI)
mongo_db = mongo_client[MONGO_DB]
def timestamp_to_local_time_str(ts: int):
return datetime.fromtimestamp(ts).strftime(DATETIME_FORMAT)
def my_monitor(app):
state = app.events.State()
def dump_state(event):
state.event(event)
task = state.tasks.get(event['uuid'])
task_collection = mongo_db["task"]
end_time = timestamp_to_local_time_str(task.timestamp) if task.state in states.READY_STATES else None
task_collection.update_one(
{"pid": task.uuid},
{"$set": {"status": task.state, "end_time": end_time}})
print('[%s] %s<%s> state changed: %s' % (
end_time, task.name, task.uuid, task.state,))
with app.connection() as connection:
recv = app.events.Receiver(connection, handlers={
'task-succeeded': dump_state,
'task-started': dump_state,
'task-failed': dump_state,
'task-revoked': dump_state,
'*': state.event,
})
recv.capture(limit=None, timeout=None, wakeup=True)
if __name__ == '__main__':
my_monitor(celery_app)
注意點:
-
state.tasks.get()
方法返回的Task
對象和CeleryTask
不是一個類,因此沒有date_done
屬性可以直接查任務(wù)的完成時間,取而代之的是一個timestamp
的屬性原來標識任務(wù)的狀態(tài)切換時間戳; - 需要單獨啟動一個monitor進程,建議可以用docker或者supervisor來進行管理;
總結(jié)
過程確實有點曲折,各種找資料和試錯,不過最終實踐下來目前這個方案效果還是可以的。