Log file was autoscale-autoscale-1.log because worker_id already carries the autoscale- prefix; now it is autoscale-1.log.
406 lines
17 KiB
Python
406 lines
17 KiB
Python
"""按队列深度自动扩缩容 worker 进程。
|
||
|
||
为什么需要它(而不是只调大 capacity):
|
||
- capacity 是单个 worker 进程内的线程数;单进程挂了、或
|
||
单进程的 Docker 任务互相拖累时,进程数才是隔离边界。
|
||
- 队列深度变化时,人工改 systemd unit 再 restart 是滞后的,
|
||
且 restart 会中断在跑任务;scaler 只启停空闲/新 worker,
|
||
缩容时先 SIGTERM 让 worker 优雅排空,绝不直接 SIGKILL。
|
||
|
||
设计要点:
|
||
- 深度 = 真正能立刻派的任务数(依赖已满足的 queued 任务),
|
||
不是 queued 总数:被依赖卡住的任务扩再多 worker 也派不出去。
|
||
- 扩容立即执行(任务不该等),缩容延迟执行:连续
|
||
scale_down_after 次检查都显示用不了这么多 worker 才缩,
|
||
避免任务成批完成时的抖动(scale up fast, scale down slow)。
|
||
- 缩容按 LIFO 停最年轻的 worker;停之前先 SIGTERM,worker
|
||
自己的 run_loop finally 会等 drain_timeout 让在跑任务自然
|
||
结束,超时后 scaler 再 SIGKILL 整个进程组清残留。
|
||
- scaler 本身由 systemd 托管(Restart=on-failure);scaler
|
||
死了,已在跑的 worker 进程不会被连带杀掉(它们是独立
|
||
进程组),scaler 重启后按 worker-id 认领:已在跑的同名
|
||
worker 不重复拉起,名字冲突的孤儿先 SIGTERM 清掉再重拉。
|
||
- scaler 绝不凭猜测扩容:深度读库失败时保持现状并记日志,
|
||
不扩也不缩(未知状态下乱动比不动更危险)。
|
||
- worker 命令由调用方以 worker_args 传入,scaler 强制追加
|
||
--loop、--worker-id、--worker-capacity(argparse 后出现
|
||
的值胜出,所以追加在末尾一定生效);--db 若 worker_args
|
||
里没有,scaler 用自己的 --db 补上。worker 继承 scaler 的
|
||
环境变量(Postgres 连接等走环境,不进命令行)。
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import math
|
||
import os
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
|
||
from cloud.db import connect as _db_connect
|
||
from cloud.logging_config import get_logger, log_event, setup_logging
|
||
from cloud.queue import ready_tasks
|
||
|
||
|
||
def queue_depth(db_path, executor=None):
|
||
"""当前就绪任务数:依赖已满足且状态为 queued 的任务。
|
||
|
||
依赖成环时 ready_tasks 抛 ValueError,视为 0(成环任务
|
||
本就派不出去,不应为它扩容)。executor 给定时只数该
|
||
执行器能认领的任务(未指定执行器、或指定为该执行器)。
|
||
"""
|
||
conn = _db_connect(db_path)
|
||
try:
|
||
rows = conn.execute(
|
||
"SELECT id, status, depends_on, executor FROM tasks"
|
||
" WHERE status IN ('queued', 'completed', 'merged')"
|
||
).fetchall()
|
||
finally:
|
||
conn.close()
|
||
tasks = []
|
||
completed_ids = set()
|
||
for row in rows:
|
||
try:
|
||
deps = row["depends_on"]
|
||
except (KeyError, IndexError, TypeError):
|
||
deps = []
|
||
if isinstance(deps, str):
|
||
try:
|
||
deps = json.loads(deps) if deps else []
|
||
except ValueError:
|
||
deps = []
|
||
if not isinstance(deps, list):
|
||
deps = [deps]
|
||
task = {"id": row["id"], "status": row["status"],
|
||
"depends_on": deps}
|
||
try:
|
||
task["executor"] = row["executor"]
|
||
except (KeyError, IndexError, TypeError):
|
||
task["executor"] = None
|
||
tasks.append(task)
|
||
if row["status"] in ("completed", "merged"):
|
||
completed_ids.add(row["id"])
|
||
try:
|
||
ready = ready_tasks(tasks, completed_ids)
|
||
except ValueError:
|
||
return 0
|
||
if executor is not None:
|
||
ready = [t for t in ready
|
||
if t.get("executor") in (None, "", executor)]
|
||
return len(ready)
|
||
|
||
|
||
def desired_workers(depth, per_worker_capacity,
|
||
min_workers, max_workers):
|
||
"""按深度算目标 worker 数。
|
||
|
||
depth=0 时也要保留 min_workers(默认 1):冷启动要快,
|
||
且 scaler 缩到 0 后再拉起仍有进程启动延迟;想缩到 0
|
||
可以把 min_workers 配成 0,scaler 仍在跑所以能再拉起。
|
||
"""
|
||
if per_worker_capacity <= 0:
|
||
per_worker_capacity = 1
|
||
if depth <= 0:
|
||
want = min_workers
|
||
else:
|
||
want = math.ceil(depth / per_worker_capacity)
|
||
return max(min_workers, min(max_workers, want))
|
||
|
||
|
||
class Autoscaler:
|
||
"""管理 worker 子进程:按深度启停,带缩容冷却与优雅停机。"""
|
||
|
||
def __init__(self, db_path, worker_args, min_workers=1, max_workers=2,
|
||
per_worker_capacity=2, interval=30, scale_down_after=3,
|
||
drain_timeout=60, state_file=None, log_dir=None,
|
||
worker_cmd=None, executor=None):
|
||
self.db_path = db_path
|
||
self.worker_args = list(worker_args)
|
||
self.min_workers = min_workers
|
||
self.max_workers = max_workers
|
||
self.per_worker_capacity = per_worker_capacity
|
||
self.interval = interval
|
||
self.scale_down_after = scale_down_after
|
||
self.drain_timeout = drain_timeout
|
||
self.state_file = state_file
|
||
self.log_dir = log_dir
|
||
self.worker_cmd = worker_cmd
|
||
self.executor = executor
|
||
self.workers = {} # worker_id -> {"proc": Popen, "started_at": float,
|
||
# "log": file handle}
|
||
self.low_streak = 0
|
||
self.last_depth = None
|
||
self.last_desired = None
|
||
self.last_action = "started"
|
||
self.last_error = None
|
||
self._stop = False
|
||
self.logger = get_logger("cloud.autoscaler")
|
||
if log_dir:
|
||
os.makedirs(log_dir, exist_ok=True)
|
||
|
||
# -- worker 进程 ---------------------------------------------------
|
||
def _worker_command(self, worker_id):
|
||
if self.worker_cmd is not None:
|
||
return list(self.worker_cmd)
|
||
cmd = [sys.executable, "-m", "cloud.worker"]
|
||
cmd.extend(self.worker_args)
|
||
if "--loop" not in self.worker_args:
|
||
cmd.append("--loop")
|
||
if not any(a == "--db" or a.startswith("--db=")
|
||
for a in self.worker_args):
|
||
cmd.extend(["--db", self.db_path])
|
||
cmd.extend(["--worker-id", worker_id,
|
||
"--worker-capacity", str(self.per_worker_capacity)])
|
||
return cmd
|
||
|
||
def _start_worker(self, worker_id):
|
||
cmd = self._worker_command(worker_id)
|
||
log_handle = None
|
||
if self.log_dir:
|
||
log_path = os.path.join(
|
||
self.log_dir, "%s.log" % worker_id)
|
||
log_handle = open(log_path, "ab")
|
||
proc = subprocess.Popen(
|
||
cmd, stdout=log_handle, stderr=subprocess.STDOUT,
|
||
start_new_session=True, env=dict(os.environ))
|
||
self.workers[worker_id] = {
|
||
"proc": proc, "started_at": time.time(), "log": log_handle}
|
||
log_event(self.logger, "autoscaler_worker_started",
|
||
message="autoscaler worker started: %s pid=%s"
|
||
% (worker_id, proc.pid))
|
||
return proc
|
||
|
||
def _stop_worker(self, worker_id):
|
||
"""SIGTERM 等排空,超时后 SIGKILL 进程组。返回是否已退出。"""
|
||
entry = self.workers.get(worker_id)
|
||
if entry is None:
|
||
return True
|
||
proc = entry["proc"]
|
||
try:
|
||
proc.send_signal(signal.SIGTERM)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
pass
|
||
try:
|
||
proc.wait(timeout=self.drain_timeout + 10)
|
||
except subprocess.TimeoutExpired:
|
||
try:
|
||
os.killpg(proc.pid, signal.SIGKILL)
|
||
except (ProcessLookupError, PermissionError, OSError):
|
||
pass
|
||
try:
|
||
proc.wait(timeout=10)
|
||
except Exception:
|
||
pass
|
||
exited = proc.poll() is not None
|
||
if entry.get("log") is not None:
|
||
try:
|
||
entry["log"].close()
|
||
except Exception:
|
||
pass
|
||
self.workers.pop(worker_id, None)
|
||
log_event(self.logger, "autoscaler_worker_stopped",
|
||
message="autoscaler worker stopped: %s exited=%s"
|
||
% (worker_id, exited))
|
||
return exited
|
||
|
||
def _reap(self):
|
||
"""回收已死 worker(不要让僵尸进程占着名额)。"""
|
||
dead = [wid for wid, entry in self.workers.items()
|
||
if entry["proc"].poll() is not None]
|
||
for wid in dead:
|
||
entry = self.workers.pop(wid)
|
||
if entry.get("log") is not None:
|
||
try:
|
||
entry["log"].close()
|
||
except Exception:
|
||
pass
|
||
log_event(self.logger, "autoscaler_worker_exited",
|
||
message="autoscaler worker exited: %s returncode=%s"
|
||
% (wid, entry["proc"].returncode))
|
||
|
||
def _next_worker_id(self):
|
||
taken = set(self.workers)
|
||
i = 1
|
||
while "autoscale-%d" % i in taken:
|
||
i += 1
|
||
return "autoscale-%d" % i
|
||
|
||
# -- 一轮 ----------------------------------------------------------
|
||
def reconcile(self):
|
||
"""一轮扩缩容:返回 (depth, desired, actual) 供测试与日志。"""
|
||
try:
|
||
depth = queue_depth(self.db_path, executor=self.executor)
|
||
self.last_error = None
|
||
except Exception as exc:
|
||
# 读库失败时保持现状:未知状态下乱扩缩比不动更危险。
|
||
self.last_error = str(exc)
|
||
log_event(self.logger, "autoscaler_depth_failed",
|
||
message="autoscaler depth failed: %s" % exc)
|
||
self._reap()
|
||
self._write_state()
|
||
return self.last_depth, self.last_desired, len(self.workers)
|
||
desired = desired_workers(depth, self.per_worker_capacity,
|
||
self.min_workers, self.max_workers)
|
||
self.last_depth = depth
|
||
self.last_desired = desired
|
||
self._reap()
|
||
actual = len(self.workers)
|
||
action = "none"
|
||
if actual < desired:
|
||
# 扩容立即执行,缩容冷却清零。
|
||
self.low_streak = 0
|
||
for _ in range(desired - actual):
|
||
self._start_worker(self._next_worker_id())
|
||
action = "scaled_up_to_%d" % desired
|
||
elif actual > desired:
|
||
# 缩容延迟:连续 low 轮都用不了这么多才缩。
|
||
self.low_streak += 1
|
||
if self.low_streak >= self.scale_down_after:
|
||
self.low_streak = 0
|
||
# LIFO:停最年轻的(它的任务最新,排空代价最小)。
|
||
victims = sorted(
|
||
self.workers,
|
||
key=lambda wid: self.workers[wid]["started_at"],
|
||
reverse=True)[: actual - desired]
|
||
for wid in victims:
|
||
self._stop_worker(wid)
|
||
action = "scaled_down_to_%d" % desired
|
||
else:
|
||
action = "scale_down_pending_%d/%d" % (
|
||
self.low_streak, self.scale_down_after)
|
||
else:
|
||
self.low_streak = 0
|
||
self.last_action = action
|
||
if action != "none":
|
||
log_event(self.logger, "autoscaler_reconcile",
|
||
message="autoscaler reconcile: depth=%s desired=%s "
|
||
"actual=%s action=%s"
|
||
% (depth, desired, len(self.workers), action))
|
||
self._write_state()
|
||
return depth, desired, len(self.workers)
|
||
|
||
def _write_state(self):
|
||
if not self.state_file:
|
||
return
|
||
state = {
|
||
"timestamp": time.time(),
|
||
"depth": self.last_depth,
|
||
"desired": self.last_desired,
|
||
"actual": len(self.workers),
|
||
"workers": [{"id": wid, "pid": entry["proc"].pid,
|
||
"started_at": entry["started_at"]}
|
||
for wid, entry in sorted(self.workers.items())],
|
||
"last_action": self.last_action,
|
||
"last_error": self.last_error,
|
||
}
|
||
try:
|
||
parent = os.path.dirname(os.path.abspath(self.state_file))
|
||
os.makedirs(parent, exist_ok=True)
|
||
tmp = "%s.tmp" % self.state_file
|
||
with open(tmp, "w") as f:
|
||
json.dump(state, f)
|
||
os.replace(tmp, self.state_file)
|
||
except Exception as exc:
|
||
log_event(self.logger, "autoscaler_state_failed",
|
||
message="autoscaler state failed: %s" % exc)
|
||
|
||
# -- 常驻 ----------------------------------------------------------
|
||
def run_loop(self, stop_event=None):
|
||
"""常驻:每 interval 秒 reconcile 一轮;退出时优雅停全部 worker。"""
|
||
try:
|
||
while not self._stop:
|
||
if stop_event is not None and stop_event.is_set():
|
||
break
|
||
try:
|
||
self.reconcile()
|
||
except Exception as exc:
|
||
log_event(self.logger, "autoscaler_loop_error",
|
||
message="autoscaler loop error: %s" % exc)
|
||
deadline = time.time() + self.interval
|
||
while time.time() < deadline:
|
||
if self._stop:
|
||
break
|
||
if stop_event is not None and stop_event.is_set():
|
||
break
|
||
time.sleep(0.2)
|
||
finally:
|
||
for wid in sorted(self.workers,
|
||
key=lambda w: self.workers[w]["started_at"],
|
||
reverse=True):
|
||
try:
|
||
self._stop_worker(wid)
|
||
except Exception:
|
||
pass
|
||
self._write_state()
|
||
|
||
def request_stop(self):
|
||
self._stop = True
|
||
|
||
|
||
def main(argv=None):
|
||
"""scaler CLI:scaler 自身参数 + `--` 后的 worker 参数。
|
||
|
||
worker 参数里不要带 --worker-id / --worker-capacity /
|
||
--loop(scaler 会强制追加);--db 可只给 scaler,scaler
|
||
会补给 worker。Postgres 等连接参数走环境变量,worker
|
||
继承 scaler 的环境,不进命令行。
|
||
"""
|
||
parser = argparse.ArgumentParser(description="cloud autoscaler")
|
||
parser.add_argument("--db", default=None,
|
||
help="控制面数据库路径(worker 参数里没给"
|
||
"--db 时用它,也用于 scaler 读队列深度)")
|
||
parser.add_argument("--interval", type=float, default=30,
|
||
help="扩缩容检查间隔(秒),默认 30")
|
||
parser.add_argument("--min-workers", type=int, default=1)
|
||
parser.add_argument("--max-workers", type=int, default=2)
|
||
parser.add_argument("--per-worker-capacity", type=int, default=2,
|
||
help="每个 worker 进程内的并发任务数")
|
||
parser.add_argument("--scale-down-after", type=int, default=3,
|
||
help="连续多少轮低深度才缩容,默认 3(防抖动)")
|
||
parser.add_argument("--drain-timeout", type=float, default=60,
|
||
help="缩容停 worker 时等排空的秒数,默认 60")
|
||
parser.add_argument("--state-file", default=None)
|
||
parser.add_argument("--log-dir", default=None,
|
||
help="各 worker 子进程的日志目录")
|
||
parser.add_argument("--executor", default=None,
|
||
help="只按该执行器类型的就绪任务算深度;"
|
||
"不给时按全部任务算(可能轻微多扩,"
|
||
"受 max-workers 封顶)")
|
||
parser.add_argument("worker_args", nargs=argparse.REMAINDER,
|
||
help="传给 cloud.worker 的参数(写在 -- 之后)")
|
||
args = parser.parse_args(argv)
|
||
worker_args = list(args.worker_args)
|
||
if worker_args and worker_args[0] == "--":
|
||
worker_args = worker_args[1:]
|
||
db_path = args.db
|
||
if db_path is None:
|
||
for i, a in enumerate(worker_args):
|
||
if a == "--db" and i + 1 < len(worker_args):
|
||
db_path = worker_args[i + 1]
|
||
elif a.startswith("--db="):
|
||
db_path = a[len("--db="):]
|
||
if db_path is None:
|
||
print("autoscaler 需要 --db(或 worker 参数里的 --db)",
|
||
file=sys.stderr)
|
||
raise SystemExit(2)
|
||
setup_logging()
|
||
scaler = Autoscaler(
|
||
db_path, worker_args, min_workers=args.min_workers,
|
||
max_workers=args.max_workers,
|
||
per_worker_capacity=args.per_worker_capacity,
|
||
interval=args.interval, scale_down_after=args.scale_down_after,
|
||
drain_timeout=args.drain_timeout, state_file=args.state_file,
|
||
log_dir=args.log_dir, executor=args.executor)
|
||
|
||
def _on_signal(signum, frame):
|
||
scaler.request_stop()
|
||
|
||
signal.signal(signal.SIGTERM, _on_signal)
|
||
signal.signal(signal.SIGINT, _on_signal)
|
||
scaler.run_loop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|