celery封裝耗時shell任務(wù)并實時獲取輸出的方法

背景

我們團隊使用flask+celery+ansible+mongodb開發(fā)了一個性能測試平臺,簡單來說要做的事情就是將用戶的一系列任務(wù)(主要是shell指令)通過ansible分發(fā)到不同的機器上去執(zhí)行,獲取任務(wù)的回顯和狀態(tài)在平臺上進行顯示和存儲。

問題

需要解決的問題如下:

  1. ansible的任務(wù)我們自動封裝成了playbook,執(zhí)行過程中的輸出希望實時進行輸出,方便定位問題;
  2. celery原生對于任務(wù)的結(jié)果是不會進行持久化存儲的,因此需要自己進行狀態(tài)和結(jié)果的同步;
  3. 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)

需要注意的是:

  1. 對于主動中止或者拋錯的任務(wù),celery默認只會返回Exception,導(dǎo)致運行過程中的輸出丟失,因此需要重載一下on_failure這個方法并且綁定到對應(yīng)任務(wù);
  2. 對于長時間執(zhí)行的任務(wù),如果直接調(diào)用subproces.run或者subprocess.check_ouput等方法,就無法實時獲取任務(wù)的輸出了,因此這里使用了Popen方法,并且將stdout重定向到subprocess.PIPE里,然后從PIPE里逐行去讀取結(jié)果;
  3. subprocess.Popen里面設(shè)置了參數(shù)universal_newlines=True,否則進程的輸出是bytes類型,需要額外處理一下,否則存儲到redis的時候會報錯的;
  4. 任務(wù)輸出的日志可能非常多,我這里限制了一下輸出的最大長度,我這里把中間的信息放到deque里面了,但是最后寫回后端數(shù)據(jù)庫的時候要注意轉(zhuǎn)成list類型,否則redis可能不知道如何序列化deque類型;
  5. 每有一行輸出就調(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)

注意點:

  1. state.tasks.get()方法返回的Task對象和CeleryTask不是一個類,因此沒有date_done屬性可以直接查任務(wù)的完成時間,取而代之的是一個timestamp的屬性原來標識任務(wù)的狀態(tài)切換時間戳;
  2. 需要單獨啟動一個monitor進程,建議可以用docker或者supervisor來進行管理;

總結(jié)

過程確實有點曲折,各種找資料和試錯,不過最終實踐下來目前這個方案效果還是可以的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,739評論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,634評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,653評論 0 377
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,063評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,835評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,235評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,315評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,459評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,000評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,819評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,004評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,560評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,257評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,676評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,937評論 1 288
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,717評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,003評論 2 374

推薦閱讀更多精彩內(nèi)容