scale_support.py 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import asyncio
  2. import json
  3. import os
  4. import subprocess
  5. from pathlib import Path
  6. from uuid import uuid4
  7. import httpx
  8. from sqlalchemy.engine import make_url
  9. from zhifu.core.config import Settings
  10. from zhifu.modules.knowledge.config import KnowledgeSettings
  11. from zhifu.operations.scale_capacity import ScaleRequest, inspect_capacity
  12. ROOT = Path(__file__).resolve().parents[1]
  13. class ScaleEnvironment:
  14. """仅管理本实例随机命名、带用途标识的 API 容器、网络和网关。"""
  15. def __init__(self):
  16. self.prefix = "zhifu-scale-" + uuid4().hex[:10]
  17. self.network = self.prefix
  18. self.created = []
  19. self.commands = 0
  20. self.monitor_requests = 0
  21. self.base_url = None
  22. self.monitor_url = None
  23. self.addresses = {}
  24. self.network_created = False
  25. self.budget = None
  26. async def docker(self, *arguments, environment=None):
  27. self.commands += 1
  28. if self.commands > 100:
  29. raise ValueError("容量验证 Docker 命令超过 100 次上限")
  30. result = await asyncio.to_thread(
  31. subprocess.run, ["docker", *arguments], check=True, capture_output=True,
  32. text=True, timeout=30, env=environment,
  33. )
  34. return result.stdout.strip()
  35. async def start_api(self, suffix, *, address=None):
  36. settings = Settings()
  37. knowledge = KnowledgeSettings()
  38. environment = dict(os.environ)
  39. for name in ("database_url", "cache_url", "state_url", "broker_url"):
  40. url = make_url(getattr(settings, name).get_secret_value())
  41. if url.host in {"localhost", "127.0.0.1"}:
  42. host, port = {
  43. "database_url": ("mysql", 3306), "cache_url": ("redis-cache", 6379),
  44. "state_url": ("redis-state", 6379), "broker_url": ("rabbitmq", 5672),
  45. }[name]
  46. url = url.set(host=host, port=port)
  47. environment["ZHIFU_" + name.upper()] = url.render_as_string(hide_password=False)
  48. environment.update({
  49. "ZHIFU_ENVIRONMENT": "test", "ZHIFU_POOL_SIZE": "8", "ZHIFU_MAX_OVERFLOW": "2",
  50. "ZHIFU_SSE_GLOBAL_LIMIT": "3000", "ZHIFU_SSE_ACTOR_LIMIT": "3",
  51. "KNOWLEDGE_MILVUS_URL": str(httpx.URL(knowledge.milvus_url).copy_with(
  52. host="milvus"
  53. )),
  54. "KNOWLEDGE_INFERENCE_URL": str(httpx.URL(knowledge.inference_url).copy_with(
  55. host="host.docker.internal"
  56. )),
  57. "KNOWLEDGE_COLLECTION": knowledge.collection, "PYTHONPATH": "/app/src",
  58. })
  59. exported = [
  60. "ZHIFU_DATABASE_URL", "ZHIFU_CACHE_URL", "ZHIFU_STATE_URL", "ZHIFU_BROKER_URL",
  61. "ZHIFU_ENVIRONMENT", "ZHIFU_POOL_SIZE", "ZHIFU_MAX_OVERFLOW",
  62. "ZHIFU_SSE_GLOBAL_LIMIT", "ZHIFU_SSE_ACTOR_LIMIT", "KNOWLEDGE_MILVUS_URL",
  63. "KNOWLEDGE_INFERENCE_URL", "KNOWLEDGE_COLLECTION", "PYTHONPATH",
  64. ]
  65. name = self.prefix + "-" + suffix
  66. arguments = [
  67. "create", "--name", name, "--network", self.network, "--network-alias", "api",
  68. "--label", "zhifu.purpose=scale-acceptance", "--memory", "1g", "--cpus", "2",
  69. "--read-only", "--tmpfs", "/app/.work:uid=10001,gid=10001,mode=0700",
  70. "--ulimit", "nofile=16384:16384", "--mount",
  71. f"type=bind,src={ROOT / 'src'},dst=/app/src,readonly",
  72. ]
  73. for key in exported:
  74. arguments.extend(["-e", key])
  75. if address is not None:
  76. arguments.extend(["--ip", str(address)])
  77. arguments.append(os.environ.get("SCALE_API_IMAGE", "zhifu-platform:local"))
  78. await self.docker(*arguments, environment=environment)
  79. self.created.append(name)
  80. await self.docker(
  81. "network", "connect",
  82. os.environ.get("SCALE_DEPENDENCY_NETWORK", "zhifu-platform_default"), name,
  83. )
  84. await self.docker("start", name)
  85. metadata = json.loads(await self.docker("inspect", name))[0]
  86. self.addresses[name] = metadata["NetworkSettings"]["Networks"][self.network]["IPAddress"]
  87. return name
  88. async def start(self, replicas=2):
  89. if not 2 <= replicas <= 16:
  90. raise ValueError("验证副本数必须在 2–16 之间")
  91. self.budget = await inspect_capacity(ScaleRequest(replicas=replicas))
  92. if self.budget["status"] != "READY":
  93. raise ValueError("当前 MySQL 连接额度不足以同时运行指定 API 副本")
  94. await self.docker(
  95. "network", "create", "--label", "zhifu.purpose=scale-acceptance", self.network
  96. )
  97. self.network_created = True
  98. first = await self.start_api("api-a")
  99. second = await self.start_api("api-b")
  100. for index in range(2, replicas):
  101. await self.start_api("api-" + str(index))
  102. name = self.prefix + "-gateway"
  103. await self.docker(
  104. "run", "-d", "--name", name, "--network", self.network,
  105. "--label", "zhifu.purpose=scale-acceptance", "--memory", "256m", "--cpus", "1",
  106. "--ulimit", "nofile=32768:32768", "-p", "127.0.0.1::8080", "-p", "127.0.0.1::8404",
  107. "--mount", f"type=bind,src={ROOT / 'ops/haproxy/runtime.cfg'},"
  108. "dst=/usr/local/etc/haproxy/haproxy.cfg,readonly", "haproxy:3.2-alpine",
  109. )
  110. self.created.append(name)
  111. metadata = json.loads(await self.docker("inspect", name))[0]["NetworkSettings"]["Ports"]
  112. self.base_url = "http://127.0.0.1:" + metadata["8080/tcp"][0]["HostPort"]
  113. self.monitor_url = "http://127.0.0.1:" + metadata["8404/tcp"][0]["HostPort"]
  114. await self.wait_servers(replicas)
  115. return first, second
  116. async def wait_servers(self, expected):
  117. # 只按真实网关统计的健康状态继续,最多 45 次查询、90 秒等待。
  118. async with asyncio.timeout(90), httpx.AsyncClient(timeout=3, trust_env=False) as client:
  119. for _ in range(45):
  120. self.monitor_requests += 1
  121. if self.monitor_requests > 180:
  122. raise ValueError("监控请求超过 180 次上限")
  123. response = (await asyncio.gather(
  124. client.get(self.monitor_url + "/stats;json"), return_exceptions=True
  125. ))[0]
  126. if isinstance(response, httpx.TransportError):
  127. await asyncio.sleep(2)
  128. continue
  129. if isinstance(response, BaseException):
  130. raise response
  131. response.raise_for_status()
  132. # HAProxy JSON 统计采用字段数组,使用标准 JSON 解码。
  133. rows = [{field["field"]["name"]: field["value"]["value"] for field in row}
  134. for row in response.json()]
  135. servers = [row for row in rows if row.get("pxname") == "api_replicas"
  136. and str(row.get("svname", "")).startswith("api")
  137. and row.get("status") == "UP"]
  138. if len(servers) == expected:
  139. return {row["svname"] for row in servers}
  140. await asyncio.sleep(2)
  141. raise TimeoutError("动态 API 副本未在 90 秒内达到预期健康数量")
  142. async def close(self):
  143. # 清理不使用测试请求额度,异常路径仍保证本次资源能够释放。
  144. for name in reversed(self.created):
  145. logs = await asyncio.to_thread(
  146. subprocess.run, ["docker", "logs", "--tail", "150", name],
  147. check=True, capture_output=True, timeout=15,
  148. )
  149. await asyncio.to_thread(
  150. (ROOT / ".work" / (name + ".log")).write_bytes, logs.stdout + logs.stderr
  151. )
  152. await asyncio.to_thread(
  153. subprocess.run, ["docker", "rm", "-fv", name], check=True,
  154. capture_output=True, timeout=30,
  155. )
  156. if self.network_created:
  157. await asyncio.to_thread(
  158. subprocess.run, ["docker", "network", "rm", self.network], check=True,
  159. capture_output=True, timeout=30,
  160. )