Python 开发笔记:配置生产环境中 Celery Worker 的独立进程启动方式

配置开发环境 Celery 在 FastAPI 的 lifespan 启动时自动开启,结束时自动关闭;生产环境中 Celery Worker 的独立进程启动方式

在 FastAPI 的 lifespan 中直接启动 Celery Worker 仅适用于‌开发环境 ‌或‌单进程演示场景‌。生产环境中,Celery Worker 必须作为独立进程运行,否则会导致资源竞争、信号处理异常及性能瓶颈。

创建 celeryconfig.py 文件

python 复制代码
import logging
import threading

from celery import Celery
from celery.worker import state as worker_state

from config.configure import (
    CELERY_CONCURRENCY,
    REDIS_DB,
    REDIS_HOST,
    REDIS_PORT,
)

logger = logging.getLogger(__name__)

REDIS_URL = f"redis://{REDIS_HOST}:{REDIS_PORT}"

celery_app = Celery(
    "python_mysql",
    broker=f"{REDIS_URL}/{REDIS_DB}",
    backend=f"{REDIS_URL}/{REDIS_DB + 1}",  # 任务结果存相邻 DB,避免与业务缓存混库
    include=[__name__],                     # worker 启动时自动导入本模块注册任务(
                                            # __name__ 同时兼容 celery -A celeryconfig
                                            # 与 config.celeryconfig 两种导入方式)
)

celery_app.conf.update(
    timezone="Asia/Shanghai",
    enable_utc=False,
    task_track_started=True,
    task_acks_late=True,                  # 任务执行成功后才确认,worker 崩溃时任务重新投递
    worker_prefetch_multiplier=1,         # 配合 acks_late,避免任务预取堆积
    broker_connection_retry_on_startup=True,
    **({"worker_concurrency": CELERY_CONCURRENCY} if CELERY_CONCURRENCY else {}),
)


@celery_app.task(
    autoretry_for=(Exception,),           # 抛出指定异常时自动重试
    retry_backoff=5,                      # 指数退避:第 n 次重试等待 5 * 2^(n-1) 秒
    retry_backoff_max=60,                 # 退避上限 60 秒
    retry_jitter=True,                    # 加入随机抖动,避免重试雪崩
    retry_kwargs={"max_retries": 3},      # 最多重试 3 次
)
def example_task(x: int, y: int) -> int:
    """示例任务:两数相加。除 0 触发异常可观察自动重试行为"""
    if x == 0 and y == 0:
        raise ValueError("x 和 y 不能同时为 0")  # 用于演示自动重试
    result = x + y
    logger.info("example_task: %s + %s = %s", x, y, result)
    return result


# ----------------------------------------------------------------------------
# 内嵌 worker:随 FastAPI lifespan 自动启动/关闭,无需单独执行 celery 命令
# ----------------------------------------------------------------------------

# macOS 上 billiard 默认 spawn 子进程,内嵌时用 solo 池(同进程线程内执行)最稳妥
EMBEDDED_WORKER_POOL = "solo"
EMBEDDED_WORKER_CONCURRENCY = 1

_embedded_worker = None
_worker_thread = None
_worker_ready = threading.Event()


def _run_embedded_worker() -> None:
    """在独立守护线程中构造并运行 worker,阻塞至 worker 退出。"""
    global _embedded_worker
    try:
        worker = celery_app.Worker(
            loglevel="info",
            pool=EMBEDDED_WORKER_POOL,
            concurrency=EMBEDDED_WORKER_CONCURRENCY,
        )
        _embedded_worker = worker
        _worker_ready.set()          # 通知主线程:worker 已构造完成
        worker.start()               # 阻塞运行,stop() 后返回
    except Exception:
        logger.exception("内嵌 Celery worker 运行异常")
        _worker_ready.set()


def start_embedded_worker(timeout: float = 10.0) -> None:
    """FastAPI lifespan 启动时调用:在守护线程中拉起 Celery worker。"""
    global _worker_thread
    if _worker_thread is not None and _worker_thread.is_alive():
        logger.warning("Celery worker 已在运行,跳过重复启动")
        return

    worker_state.should_stop = None  # 复位关闭标志,防止历史状态残留
    _worker_ready.clear()
    _worker_thread = threading.Thread(
        target=_run_embedded_worker,
        name="celery-worker",
        daemon=True,                 # 守护线程:异常情况下也不会拖住进程退出
    )
    _worker_thread.start()
    _worker_ready.wait(timeout=timeout)
    if _embedded_worker is None:
        logger.error("Celery 内嵌 worker 启动失败,请检查上方异常日志")
    else:
        logger.info("Celery 内嵌 worker 已启动(pool=%s)", EMBEDDED_WORKER_POOL)


def stop_embedded_worker(timeout: float = 10.0) -> None:
    """FastAPI lifespan 关闭时调用:优雅停止 worker 并等待线程退出。"""
    global _embedded_worker, _worker_thread
    worker, thread = _embedded_worker, _worker_thread
    if worker is None or thread is None:
        return

    # 置位 should_stop:事件循环与 broker 重连循环会据此退出,
    # 即便 worker 尚处在启动(连接 broker)阶段也能被打断
    worker_state.should_stop = True
    try:
        worker.stop()                # 优雅关闭(warm shutdown)
    except Exception:
        logger.exception("优雅关闭 Celery worker 失败,尝试强制终止")
        worker.terminate()

    thread.join(timeout=timeout)
    if thread.is_alive():
        logger.warning("Celery worker 未在 %ss 内退出,守护线程将随进程结束", timeout)

    _embedded_worker = None
    _worker_thread = None
    logger.info("Celery 内嵌 worker 已关闭")


"""
方式一(推荐,已接入 main.py 的 lifespan):
    随 FastAPI 启动自动开启、关闭自动结束,无需手动管理进程。

方式二(独立进程,项目根目录执行):
    celery -A config.celeryconfig worker -l info -P solo

注意:macOS 上 billiard 默认 spawn 子进程,prefork 池无法继承任务注册表,
     必须用 -P solo(单进程,本地验证)或 -P threads(多线程,支持并发)。

调用任务(Python 侧):
    from config.celeryconfig import example_task
    result = example_task.delay(1, 2)       # 异步投递
    print(result.get(timeout=10))           # 阻塞获取结果
"""

在 main.py 里

python 复制代码
from contextlib import asynccontextmanager
from config.database import init_db, close_db
from config.redis import close_redis

@asynccontextmanager
async def life_span(app: FastAPI):
    print("server is starting ...")
    await init_db()
    # 仅开发模式(CELERY_EMBEDDED=true,默认)内嵌 worker;
    # 生产环境使用独立进程:scripts/celery_worker.sh
    if CELERY_EMBEDDED:
        start_embedded_worker()
    else:
        logging.info(
            "CELERY_EMBEDDED=false,跳过内嵌 worker,请确认独立 Celery 进程已启动"
        )
    yield
    # 先停 Celery(需使用 Redis broker),再关闭 Redis/DB 连接
    if CELERY_EMBEDDED:
        stop_embedded_worker()
    await close_db()
    await close_redis()
    print("server has been shut down.")


app = FastAPI(lifespan=life_span)

在根目录创建脚本文件 celery_worker.sh 和 .env

celery_worker.sh

python 复制代码
#!/usr/bin/env bash
# =============================================================================
# Celery worker 独立进程管理脚本(生产部署 / macOS 本地验证均可用)
#
# 用法(项目根目录执行):
#   scripts/celery_worker.sh start     # 后台启动 worker(单实例,重复启动会被拒绝)
#   scripts/celery_worker.sh stop      # 优雅停止(SIGTERM warm shutdown,超时再 KILL)
#   scripts/celery_worker.sh restart   # 等同于 stop + start
#   scripts/celery_worker.sh status    # 查看运行状态(退出码 0=运行中,3=未运行)
#
# 可选环境变量(也可写在 .env 中由 config.configure 读取,CLI 参数优先级更高):
#   CELERY_POOL=solo|prefork|threads   # 不设置时:Linux=prefork(默认),macOS=solo
#   CELERY_CONCURRENCY=4               # worker 并发数,默认按 CPU 核数
#   CELERY_QUEUES=celery               # 监听队列,多个用逗号分隔
#   CELERY_HOSTNAME=worker@%h-1        # 节点名,多实例部署时区分
#   CELERY_LOG_LEVEL=info              # 日志级别
#
# 退出码:0 成功;1 参数或启动失败;3 未在运行(status)
# =============================================================================
set -euo pipefail

APP="config.celeryconfig"

# 定位项目根目录(脚本位于 <root>/scripts/ 下)
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$PROJECT_ROOT"

# 优先使用项目虚拟环境内的 celery,其次使用 PATH 中的
CELERY_BIN="$PROJECT_ROOT/venv/bin/celery"
if [ ! -x "$CELERY_BIN" ]; then
    CELERY_BIN="$(command -v celery)"
fi
if [ -z "$CELERY_BIN" ]; then
    echo "未找到 celery 可执行文件,请先安装依赖或激活虚拟环境" >&2
    exit 1
fi

# 运行参数 --------------------------------------------------------------
POOL="${CELERY_POOL:-}"
# macOS 上 billiard 默认 spawn,prefork 无法继承任务注册表,自动回退 solo
if [ -z "$POOL" ] && [ "$(uname -s)" = "Darwin" ]; then
    POOL="solo"
fi
CONCURRENCY="${CELERY_CONCURRENCY:-}"
QUEUES="${CELERY_QUEUES:-celery}"
HOSTNAME="${CELERY_HOSTNAME:-}"
LOG_LEVEL="${CELERY_LOG_LEVEL:-info}"

RUN_DIR="$PROJECT_ROOT/run"
LOG_DIR="$PROJECT_ROOT/logs"
PIDFILE="$RUN_DIR/celery_worker.pid"
LOGFILE="$LOG_DIR/celery_worker.log"
# 停止时最长等待秒数(warm shutdown,prefork 下会等在途任务执行完)
STOP_TIMEOUT=30

mkdir -p "$RUN_DIR" "$LOG_DIR"

is_running() {
    [ -f "$PIDFILE" ] || return 1
    local pid
    pid="$(cat "$PIDFILE" 2>/dev/null || true)"
    [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null
}

build_args() {
    ARGS=("-A" "$APP" "worker"
          "-l" "$LOG_LEVEL"
          "-Q" "$QUEUES"
          "--pidfile" "$PIDFILE"
          "--logfile" "$LOGFILE")
    if [ -n "$POOL" ]; then
        ARGS+=("-P" "$POOL")
    fi
    if [ -n "$CONCURRENCY" ]; then
        ARGS+=("-c" "$CONCURRENCY")
    fi
    if [ -n "$HOSTNAME" ]; then
        ARGS+=("-n" "$HOSTNAME")
    fi
}

start_worker() {
    if is_running; then
        echo "celery worker 已在运行 (pid=$(cat "$PIDFILE")),跳过启动"
        return 0
    fi
    # 清理上次异常退出残留的失效 pidfile
    rm -f "$PIDFILE"

    build_args
    echo "启动 celery worker: $CELERY_BIN ${ARGS[*]}"
    # nohup 后台运行,stdout/stderr 追加到同一日志文件(承接 celery logfile 之外的早期输出)
    nohup "$CELERY_BIN" "${ARGS[@]}" >> "$LOGFILE" 2>&1 &

    # 等待 celery 写入 pidfile 且进程存活(约 10s)
    for _ in $(seq 1 50); do
        if is_running; then
            echo "celery worker 已启动 (pid=$(cat "$PIDFILE")),日志: $LOGFILE"
            return 0
        fi
        sleep 0.2
    done
    echo "celery worker 启动失败,请查看日志: $LOGFILE" >&2
    exit 1
}

stop_worker() {
    if ! is_running; then
        echo "celery worker 未在运行"
        rm -f "$PIDFILE"
        return 0
    fi
    local pid
    pid="$(cat "$PIDFILE")"
    echo "优雅停止 celery worker (pid=$pid),最长等待 ${STOP_TIMEOUT}s ..."
    # SIGTERM:Celery warm shutdown,等待在途任务执行完毕后退出
    kill -TERM "$pid" 2>/dev/null || true

    local waited=0
    while kill -0 "$pid" 2>/dev/null; do
        if [ "$waited" -ge "$STOP_TIMEOUT" ]; then
            echo "等待超时,强制结束 worker 进程组 ..." >&2
            # 兜底:仅精确匹配本项目虚拟环境 + 本 app 的 worker 进程,避免误杀其他项目
            pkill -KILL -f "$CELERY_BIN.*-A $APP worker" 2>/dev/null || true
            break
        fi
        sleep 1
        waited=$((waited + 1))
    done
    rm -f "$PIDFILE"
    echo "celery worker 已停止"
}

status_worker() {
    if is_running; then
        echo "celery worker 运行中 (pid=$(cat "$PIDFILE"))"
        exit 0
    fi
    echo "celery worker 未运行"
    exit 3
}

case "${1:-}" in
    start)
        start_worker
        ;;
    stop)
        stop_worker
        ;;
    restart)
        stop_worker
        start_worker
        ;;
    status)
        status_worker
        ;;
    *)
        echo "用法: $0 {start|stop|restart|status}" >&2
        exit 1
        ;;
esac

.env

python 复制代码
REDIS_HOST=localhost
REDIS_PORT=6379

# Celery 运行模式:true=内嵌 worker(本地开发,随 FastAPI 自动启停)
# 生产环境改为 false,worker 用 scripts/celery_worker.sh 独立进程管理
CELERY_EMBEDDED=true
相关推荐
打工仔折腾 AI1 小时前
飞牛OS上用Docker Compose部署ExerciseDiary运动记录并配置远程访问
运维·人工智能·后端·python·docker·容器·ai agent 实战
小蒜学长1 小时前
基于Python的动物救助站管理系统的设计与实现(代码+数据库+LW)
数据库·后端·python·django·动物救助
小小张说故事1 小时前
Python 中 __new__ 和 __init__ 到底谁才是构造函数?一文讲透区别与 5 个坑
后端·python
言乐61 小时前
Python实现关税自动计算模型
开发语言·python·django·virtualenv·pygame
伞伞悦读1 小时前
从零打通 Hermes Agent:Windows + WSL2 + Clash 全链路安装、认证与首次实战
python·语言模型
happylifetree2 小时前
Python17:核心语法-数据存储与运算-输入与输出
python
梅雅达编程笔记2 小时前
04-Python CSV数据保存与翻页抓取
开发语言·爬虫·python·pandas·数据采集·csv
估值探索者2 小时前
【Python量化策略实战 #04】Donchian 通道假突破太多?用 ATR 阈值过滤跑通真实突破信号
开发语言·python·接口·api接口·数据api接口·股票数据api接口
oooost3 小时前
Jupyter Notebook 常用快捷键速查
ide·python·jupyter