Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 99 additions & 4 deletions apisix/control/v1.lua
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ local healthcheck_manager = require("apisix.healthcheck_manager")
local get_upstreams = upstream_mod.upstreams
local collectgarbage = collectgarbage
local ipairs = ipairs
local pairs = pairs
local pcall = pcall
local setmetatable = setmetatable
local str_format = string.format
Expand Down Expand Up @@ -77,12 +78,14 @@ function _M.schema()
end

local healthcheck
local function extra_checker_info(value)
-- `value` is anything get_healthchecker_name() accepts: a resource config, or a
-- bare {resource_key = ...} for a checker a plugin owns.
local function get_checker_nodes(value)
if not healthcheck then
healthcheck = require("resty.healthcheck")
end

local name = healthcheck_manager.get_healthchecker_name(value.value)
local name = healthcheck_manager.get_healthchecker_name(value)
local nodes, err = healthcheck.get_target_list(name, "upstream-healthcheck")
if err then
core.log.error("healthcheck.get_target_list failed: ", err)
Expand All @@ -92,9 +95,14 @@ local function extra_checker_info(value)
-- so keep the field a JSON array to report `[]` instead of `{}` then
setmetatable(nodes, core.json.array_mt)
end
return nodes
end


local function extra_checker_info(value)
return {
name = value.key,
nodes = nodes,
nodes = get_checker_nodes(value.value),
}
end

Expand All @@ -108,6 +116,41 @@ local function get_checker_type(checks)
end


-- A plugin can run active health checks of its own on nodes that belong to no
-- upstream -- ai-proxy-multi probes every LLM instance and skips the unhealthy
-- ones when it picks a target. Those checkers are keyed by the resource key plus
-- a JSON path, a layout only the plugin knows, so ask the plugin for them
-- instead of guessing. What a checker stands for is the plugin's business too:
-- it names itself in `meta`, reported verbatim.
local function add_plugin_healthcheck_info(infos, value)
local plugins = value.value.plugins
if not plugins then
return
end

for name, plugin_conf in pairs(plugins) do
-- a disabled plugin never runs, so its checkers never exist; and its
-- config is kept even when check_schema() rejects it, so it must not be
-- handed to the plugin either
if not plugin.check_disable(plugin_conf) then
local plugin_obj = plugin.get(name)
if plugin_obj and plugin_obj.list_healthcheck_targets then
local targets = plugin_obj.list_healthcheck_targets(plugin_conf, value.key)
for _, target in ipairs(targets or {}) do
core.table.insert(infos, {
name = target.resource_path,
plugin = name,
meta = target.meta,
type = get_checker_type(target.checks),
nodes = get_checker_nodes({resource_key = target.resource_path}),
})
end
end
end
end
end


local function iter_and_add_healthcheck_info(infos, values)
if not values then
return
Expand All @@ -120,6 +163,7 @@ local function iter_and_add_healthcheck_info(infos, values)
info.type = get_checker_type(checks)
core.table.insert(infos, info)
end
add_plugin_healthcheck_info(infos, value)
end
end

Expand Down Expand Up @@ -241,15 +285,50 @@ local function iter_and_find_healthcheck_info(values, src_type, src_id)
end


-- Every checker a resource owns, in the same entry shape the /v1/healthcheck
-- listing uses. A resource can own more than one -- its upstream plus one per
-- plugin instance -- which the single object returned by
-- /v1/healthcheck/{src_type}/{src_id} cannot express, so this is a sub-resource
-- of its own rather than a new field on that object. A resource with no health
-- check at all is not an error here: it owns an empty set of checkers.
local function iter_and_find_resource_checkers(values, src_type, src_id)
if not values then
return nil, str_format("%s[%s] not found", src_type, src_id)
end

for _, value in core.config_util.iterate_values(values) do
if value.value.id == src_id then
local infos = core.table.new(1, 0)
local checks = value.value.checks or
(value.value.upstream and value.value.upstream.checks)
if checks then
local info = extra_checker_info(value)
info.type = get_checker_type(checks)
core.table.insert(infos, info)
end
add_plugin_healthcheck_info(infos, value)
-- an empty result must still serialize as [], not {}
return setmetatable(infos, core.json.array_mt)
end
end

return nil, str_format("%s[%s] not found", src_type, src_id)
end


function _M.get_health_checker()
local uri_segs = core.utils.split_uri(ngx_var.uri)
core.log.info("healthcheck uri: ", core.json.delay_encode(uri_segs))

local src_type, src_id = uri_segs[4], uri_segs[5]
local src_type, src_id, sub_res = uri_segs[4], uri_segs[5], uri_segs[6]
if not src_id then
return 404, {error_msg = str_format("missing src id for src type %s", src_type)}
end

if sub_res and (sub_res ~= "checkers" or uri_segs[7]) then
return 400, {error_msg = str_format("invalid sub resource %s", sub_res)}
end

local values
if src_type == "routes" then
values = get_routes()
Expand All @@ -263,6 +342,22 @@ function _M.get_health_checker()
return 400, {error_msg = str_format("invalid src type %s", src_type)}
end

if sub_res then
local infos, err = iter_and_find_resource_checkers(values, src_type, src_id)
if not infos then
return 404, {error_msg = err}
end
local out, err = try_render_html({stats = infos})
if out then
core.response.set_header("Content-Type", "text/html")
return 200, out
end
if err then
return 503, {error_msg = err}
end
return 200, infos
end

local info, err = iter_and_find_healthcheck_info(values, src_type, src_id)
if not info then
return 404, {error_msg = err}
Expand Down
3 changes: 3 additions & 0 deletions apisix/plugin.lua
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ local function check_disable(plugin_conf)

return plugin_conf._meta.disable
end
-- exposed for callers that must not act on a plugin config which never runs,
-- such as the control API reporting the health checkers a plugin owns
_M.check_disable = check_disable

local PLUGIN_TYPE_HTTP = 1
local PLUGIN_TYPE_STREAM = 2
Expand Down
37 changes: 34 additions & 3 deletions apisix/plugins/ai-proxy-multi.lua
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,16 @@ local function create_server_picker(conf, ups_tab, checkers)
end


-- Identity of the health checker of the i-th instance: the parent resource key
-- plus the JSON path of the instance. The health check manager parses it back
-- to reach construct_upstream(), so both ends must agree on this exact layout.
local function instance_resource_path(resource_key, index)
-- json path is 0 indexed so we need to decrement the index
return resource_key .. "#plugins['" .. plugin_name .. "'].instances["
.. index - 1 .. "]"
end


local function get_instance_conf(instances, name)
for _, ins in ipairs(instances) do
if ins.name == name then
Expand All @@ -616,9 +626,8 @@ local function pick_target(ctx, conf, ups_tab)
for i, instance in ipairs(conf.instances) do
if instance.checks then
resolve_endpoint(instance)
-- json path is 0 indexed so we need to decrement i
local resource_path = conf._meta.parent.resource_key ..
"#plugins['ai-proxy-multi'].instances[" .. i-1 .. "]"
local resource_path = instance_resource_path(
conf._meta.parent.resource_key, i)
local resource_version = conf._meta.parent.resource_version
if instance._nodes_ver then
resource_version = resource_version .. instance._nodes_ver
Expand Down Expand Up @@ -982,6 +991,28 @@ local function retry_on_error(ctx, conf, code, body)
return code
end

-- Each instance can run its own active health check, whose checker is keyed by
-- instance_resource_path(). Nothing outside the plugin can guess that layout, so
-- expose it for the control API, which reports the health status of every
-- configured checker (control/v1.lua). `meta` describes the checker to whoever
-- reports it -- what an instance is is the plugin's business, so the control API
-- passes it through instead of knowing about it.
function _M.list_healthcheck_targets(conf, resource_key)
local targets = {}
for i, instance in ipairs(conf.instances or {}) do
if instance.checks then
core.table.insert(targets, {
resource_path = instance_resource_path(resource_key, i),
checks = instance.checks,
meta = {instance = instance.name},
})
end
end

return targets
end


function _M.construct_upstream(instance)
if not instance then
return nil, "instance configuration is nil"
Expand Down
64 changes: 64 additions & 0 deletions docs/en/latest/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,38 @@ Each of the returned objects contain the following fields:
* nodes[i].counter.tcp_failure: tcp connect/read/write failures count.
* nodes[i].counter.timeout_failure: timeout count.

A plugin can run active health checks of its own, on nodes that belong to no
upstream. [ai-proxy-multi](./plugins/ai-proxy-multi.md) does this: it probes
every LLM instance and skips the unhealthy ones when it picks a target. Such a
checker is reported with two extra fields:

```json
{
"name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
"plugin": "ai-proxy-multi",
"meta": {
"instance": "openai"
},
"type": "http",
"nodes": [
{
"ip": "52.86.68.46",
"port": 443,
"status": "healthy",
"counter": {
"http_failure": 0,
"success": 2,
"timeout_failure": 0,
"tcp_failure": 0
}
}
]
}
```

* plugin: name of the plugin owning the health checker. Absent for an upstream health checker.
* meta: filled by the plugin to describe what the checker stands for. Its content is plugin specific; `ai-proxy-multi` reports the instance name.

You can also use `/v1/healthcheck/$src_type/$src_id` to get the health status of specific nodes.

For example, `GET /v1/healthcheck/upstreams/1` returns:
Expand Down Expand Up @@ -193,6 +225,38 @@ If you use browser to access the control API URL, then you will get the HTML out

![Health Check Status Page](https://raw.githubusercontent.com/apache/apisix/master/docs/assets/images/health_check_status_page.png)

### GET /v1/healthcheck/{src_type}/{src_id}/checkers

Returns every health checker one resource owns, as an array of the entries
described above. A resource can own more than one -- its upstream plus one per
plugin instance -- which `GET /v1/healthcheck/$src_type/$src_id` cannot express,
since it returns a single object.

For example, `GET /v1/healthcheck/routes/1/checkers` returns:

```json
[
{
"name": "/apisix/routes/1",
"type": "http",
"nodes": [...]
},
{
"name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
"plugin": "ai-proxy-multi",
"meta": {
"instance": "openai"
},
"type": "http",
"nodes": [...]
}
]
```

A resource with no health check at all is not an error here: it owns an empty
set of checkers, and the endpoint returns `[]`. `404` is returned only when the
resource itself does not exist.

### POST /v1/gc

Introduced in [v2.8](https://github.com/apache/apisix/releases/tag/2.8).
Expand Down
2 changes: 2 additions & 0 deletions docs/en/latest/plugins/ai-proxy-multi.md
Original file line number Diff line number Diff line change
Expand Up @@ -2630,6 +2630,8 @@ kubectl apply -f ai-proxy-multi-ic.yaml

For verification, the behaviours should be consistent with the verification in [active health checks](../tutorials/health-check.md).

The status these checks produce is reported by the [Control API](../control-api.md): `GET /v1/healthcheck` lists one entry per instance, and `GET /v1/healthcheck/routes/{id}/checkers` returns every checker the Route owns. Each entry carries the instance name in `meta.instance`.

### Include LLM Information in Access Log

The following example demonstrates how you can log LLM request related information in the gateway's access log to improve analytics and audit. The following variables are available:
Expand Down
60 changes: 60 additions & 0 deletions docs/zh/latest/control-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,36 @@ APISIX 中一些插件添加了自己的 control API。如果你对他们感兴
* nodes[i].counter.tcp_failure: TCP 连接或读写的失败计数器。
* nodes[i].counter.timeout_failure: 超时计数器。

插件也可以对不属于任何上游的节点进行主动健康检查。[ai-proxy-multi](./plugins/ai-proxy-multi.md)
就是这样:它探测每个 LLM 实例,并在选择目标时跳过不健康的实例。这类检查器会额外带上两个字段:

```json
{
"name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
"plugin": "ai-proxy-multi",
"meta": {
"instance": "openai"
},
"type": "http",
"nodes": [
{
"ip": "52.86.68.46",
"port": 443,
"status": "healthy",
"counter": {
"http_failure": 0,
"success": 2,
"timeout_failure": 0,
"tcp_failure": 0
}
}
]
}
```

* plugin: 拥有该健康检查器的插件名。上游自身的健康检查器没有该字段。
* meta: 由插件填写,用于描述该检查器代表什么,内容由插件自行定义;`ai-proxy-multi` 在其中报告实例名。

用户也可以通过 `/v1/healthcheck/$src_type/$src_id` 来获取指定 health checker 的状态。

例如,`GET /v1/healthcheck/upstreams/1` 返回:
Expand Down Expand Up @@ -188,6 +218,36 @@ APISIX 中一些插件添加了自己的 control API。如果你对他们感兴

![Health Check Status Page](https://raw.githubusercontent.com/apache/apisix/master/docs/assets/images/health_check_status_page.png)

### GET /v1/healthcheck/{src_type}/{src_id}/checkers

以数组形式返回某个资源拥有的全部健康检查器,数组元素与上文描述的 entry 结构一致。
一个资源可能拥有多个健康检查器——它自身的上游,加上每个插件实例各一个——而
`GET /v1/healthcheck/$src_type/$src_id` 返回的是单个对象,无法表达这种情况。

例如,`GET /v1/healthcheck/routes/1/checkers` 返回:

```json
[
{
"name": "/apisix/routes/1",
"type": "http",
"nodes": [...]
},
{
"name": "/apisix/routes/1#plugins['ai-proxy-multi'].instances[0]",
"plugin": "ai-proxy-multi",
"meta": {
"instance": "openai"
},
"type": "http",
"nodes": [...]
}
]
```

资源没有配置任何健康检查在这里不算错误:它拥有一个空的检查器集合,接口返回 `[]`。
只有资源本身不存在时才返回 `404`。

### POST /v1/gc

引入自 2.8 版本
Expand Down
Loading
Loading