Skip to content

feat(nacos): add metadata filtering support to nacos discovery #12445

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
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
32 changes: 32 additions & 0 deletions apisix/discovery/nacos/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ local http = require('resty.http')
local core = require('apisix.core')
local ipairs = ipairs
local pairs = pairs
local next = next
local type = type
local math = math
local math_random = math.random
Expand Down Expand Up @@ -54,6 +55,23 @@ local function get_key(namespace_id, group_name, service_name)
return namespace_id .. '.' .. group_name .. '.' .. service_name
end


local function metadata_contains(host_metadata, route_metadata)
if not route_metadata or not next(route_metadata) then
return true
end
if not host_metadata or not next(host_metadata) then
return false
end

for k, v in pairs(route_metadata) do
if host_metadata[k] ~= v then
return false
end
end
return true
end

local function request(request_uri, path, body, method, basic_auth)
local url = request_uri .. path
log.info('request url:', url)
Expand Down Expand Up @@ -319,6 +337,7 @@ local function fetch_full_registry(premature)
host = host.ip,
port = host.port,
weight = host.weight or default_weight,
metadata = host.metadata,
}
-- docs: https://github.com/yidongnan/grpc-spring-boot-starter/pull/496
if is_grpc(scheme) and host.metadata and host.metadata.gRPC_port then
Expand Down Expand Up @@ -355,6 +374,19 @@ function _M.nodes(service_name, discovery_args)
return nil
end
local nodes = core.json.decode(value)

-- Apply metadata filtering if specified
local route_metadata = discovery_args and discovery_args.metadata
if route_metadata and next(route_metadata) then
local filtered_nodes = {}
for _, node in ipairs(nodes) do
if metadata_contains(node.metadata, route_metadata) then
core.table.insert(filtered_nodes, node)
end
end
return filtered_nodes
end

return nodes
end

Expand Down
7 changes: 7 additions & 0 deletions apisix/schema_def.lua
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,13 @@ local upstream_schema = {
description = "group name",
type = "string",
},
metadata = {
description = "metadata for filtering service instances",
type = "object",
additionalProperties = {
type = "string"
}
},
}
},
pass_host = {
Expand Down
7 changes: 7 additions & 0 deletions ci/pod/docker-compose.first.yml
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ services:
- ci/pod/nacos/env/service.env
environment:
SUFFIX_NUM: 1
METADATA_LANE: "a"
METADATA_ENV: "prod"
METADATA_VERSION: "1.0"
restart: unless-stopped
ports:
- "18001:18001"
Expand All @@ -195,6 +198,8 @@ services:
- ci/pod/nacos/env/service.env
environment:
SUFFIX_NUM: 2
METADATA_LANE: "b"
METADATA_ENV: "test"
restart: unless-stopped
ports:
- "18002:18001"
Expand All @@ -213,6 +218,8 @@ services:
environment:
SUFFIX_NUM: 1
NAMESPACE: test_ns
METADATA_LANE: "b"
METADATA_ENV: "test"
restart: unless-stopped
ports:
- "18003:18001"
Expand Down
8 changes: 4 additions & 4 deletions ci/pod/nacos/service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ ENV GROUP=${GROUP:-DEFAULT_GROUP}

ADD https://raw.githubusercontent.com/api7/nacos-test-service/main/spring-nacos-1.0-SNAPSHOT.jar /app.jar

ENTRYPOINT ["java","-Djava.security.egd=file:/dev/./urandom","-jar","/app.jar",\
"--suffix.num=${SUFFIX_NUM}","--spring.cloud.nacos.discovery.server-addr=${NACOS_ADDR}",\
"--spring.application.name=${SERVICE_NAME}","--spring.cloud.nacos.discovery.group=${GROUP}",\
"--spring.cloud.nacos.discovery.namespace=${NAMESPACE}"]
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 18001
43 changes: 43 additions & 0 deletions ci/pod/nacos/service/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
#!/bin/bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

# Build Java command with proper environment variable expansion
JAVA_ARGS=(
"-Djava.security.egd=file:/dev/./urandom"
"-jar"
"/app.jar"
"--suffix.num=${SUFFIX_NUM}"
"--spring.cloud.nacos.discovery.server-addr=${NACOS_ADDR}"
"--spring.application.name=${SERVICE_NAME}"
"--spring.cloud.nacos.discovery.group=${GROUP}"
"--spring.cloud.nacos.discovery.namespace=${NAMESPACE}"
)

# Add metadata dynamically for all METADATA_* environment variables
for var in $(env | grep '^METADATA_' | cut -d= -f1); do
# Convert METADATA_LANE to lane, METADATA_ENV to env, etc.
metadata_key=$(echo "${var#METADATA_}" | tr '[:upper:]' '[:lower:]')
metadata_value=$(eval echo \$${var})

if [ -n "${metadata_value}" ]; then
JAVA_ARGS+=("--spring.cloud.nacos.discovery.metadata.${metadata_key}=${metadata_value}")
fi
done

# Execute Java with expanded arguments
exec java "${JAVA_ARGS[@]}"
50 changes: 50 additions & 0 deletions docs/en/latest/discovery/nacos.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ $ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_
| ------------ | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ |
| namespace_id | string | optional | public | | This parameter is used to specify the namespace of the corresponding service |
| group_name | string | optional | DEFAULT_GROUP | | This parameter is used to specify the group of the corresponding service |
| metadata | object | optional | {} | | Filter service instances by metadata using containment matching |

#### Specify the namespace

Expand Down Expand Up @@ -278,3 +279,52 @@ The formatted response as below:
}
}
```

#### Metadata filtering

APISIX supports filtering service instances based on metadata. When a route is configured with metadata conditions, only service instances whose metadata contains all the key-value pairs specified in the route's `metadata` configuration will be selected.

Example: If a service instance has metadata `{lane: "a", env: "prod", version: "1.0"}`, it will match routes configured with metadata `{lane: "a"}` or `{lane: "a", env: "prod"}`, but not routes configured with `{lane: "b"}` or `{lane: "a", region: "us"}`.

Example of routing a request with metadata filtering:

```shell
$ curl http://127.0.0.1:9180/apisix/admin/routes/5 -H "X-API-KEY: $admin_key" -X PUT -i -d '
{
"uri": "/nacosWithMetadata/*",
"upstream": {
"service_name": "APISIX-NACOS",
"type": "roundrobin",
"discovery_type": "nacos",
"discovery_args": {
"metadata": {
"version": "v1"
}
}
}
}'
```

This route will only route traffic to service instances that have the metadata field `version` set to `v1`.

For multiple metadata criteria:

```shell
$ curl http://127.0.0.1:9180/apisix/admin/routes/6 -H "X-API-KEY: $admin_key" -X PUT -i -d '
{
"uri": "/nacosWithMultipleMetadata/*",
"upstream": {
"service_name": "APISIX-NACOS",
"type": "roundrobin",
"discovery_type": "nacos",
"discovery_args": {
"metadata": {
"lane": "a",
"env": "prod"
}
}
}
}'
```

This route will only route traffic to service instances that have both `lane: "a"` and `env: "prod"` in their metadata.
50 changes: 50 additions & 0 deletions docs/zh/latest/discovery/nacos.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ $ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_
| ------------ | ------ | ----------- | ------- | ----- | ------------------------------------------------------------ |
| namespace_id | string | 可选 | public | | 服务所在的命名空间 |
| group_name | string | 可选 | DEFAULT_GROUP | | 服务所在的组 |
| metadata | object | 可选 | {} | | 使用包含匹配方式根据元数据过滤服务实例 |

#### 指定命名空间

Expand Down Expand Up @@ -281,3 +282,52 @@ $ curl http://127.0.0.1:9180/apisix/admin/routes/4 -H "X-API-KEY: $admin_key" -X
}
}
```

#### 使用元数据过滤服务实例

APISIX 支持根据元数据过滤服务实例。当路由配置了元数据条件时,只有服务实例的元数据包含路由配置中指定的所有键值对,该服务实例才会被选中。

举例:如果服务实例的元数据是 `{lane: "a", env: "prod", version: "1.0"}`,那么它能匹配配置了元数据 `{lane: "a"}` 或 `{lane: "a", env: "prod"}` 的路由,但不能匹配配置了 `{lane: "b"}` 或 `{lane: "a", region: "us"}` 的路由。

使用元数据过滤的路由配置示例:

```shell
$ curl http://127.0.0.1:9180/apisix/admin/routes/5 -H "X-API-KEY: $admin_key" -X PUT -i -d '
{
"uri": "/nacosWithMetadata/*",
"upstream": {
"service_name": "APISIX-NACOS",
"type": "roundrobin",
"discovery_type": "nacos",
"discovery_args": {
"metadata": {
"version": "v1"
}
}
}
}'
```

此路由只会将流量转发到元数据字段 `version` 为 `v1` 的服务实例。

使用多个元数据条件的示例:

```shell
$ curl http://127.0.0.1:9180/apisix/admin/routes/6 -H "X-API-KEY: $admin_key" -X PUT -i -d '
{
"uri": "/nacosWithMultipleMetadata/*",
"upstream": {
"service_name": "APISIX-NACOS",
"type": "roundrobin",
"discovery_type": "nacos",
"discovery_args": {
"metadata": {
"lane": "a",
"env": "prod"
}
}
}
}'
```

此路由只会将流量转发到元数据中同时包含 `lane: "a"` 和 `env: "prod"` 的服务实例。
99 changes: 99 additions & 0 deletions t/core/schema_def.t
Original file line number Diff line number Diff line change
Expand Up @@ -237,3 +237,102 @@ passed
}
--- response_body
passed



=== TEST 5: discovery_args metadata validation
--- config
location /t {
content_by_lua_block {
local schema_def = require("apisix.schema_def")
local core = require("apisix.core")

-- Create a schema that includes discovery_args
local upstream_schema = schema_def.upstream

-- Test cases using table-driven approach
local test_cases = {
-- Valid cases
{
name = "valid metadata with multiple string values",
should_pass = true,
upstream = {
service_name = "test-service",
discovery_type = "nacos",
type = "roundrobin",
discovery_args = {
namespace_id = "test-ns",
group_name = "test-group",
metadata = {
version = "v1",
env = "prod",
lane = "a"
}
}
}
},
{
name = "valid metadata with empty object",
should_pass = true,
upstream = {
service_name = "test-service",
discovery_type = "nacos",
type = "roundrobin",
discovery_args = {
metadata = {}
}
}
},

-- Invalid cases
{
name = "invalid metadata with non-string values",
should_pass = false,
upstream = {
service_name = "test-service",
discovery_type = "nacos",
type = "roundrobin",
discovery_args = {
metadata = {
version = 123, -- should be string
env = true, -- should be string
count = 456, -- should be string
config = { -- should be string
port = 8080
}
}
}
},
expected_error_pattern = "discovery_args.*validation failed.*metadata.*validation failed.*wrong type.*expected string"
},
}

-- Execute all test cases
for i, test_case in ipairs(test_cases) do
local ok, err = core.schema.check(upstream_schema, test_case.upstream)

if test_case.should_pass then
assert(ok, string.format("Test case %d (%s) should pass validation: %s",
i, test_case.name, err or ""))
else
assert(not ok, string.format("Test case %d (%s) should fail validation",
i, test_case.name))
assert(err ~= nil, string.format("Test case %d (%s) should have error message",
i, test_case.name))

-- Execute test case specific error assertions
if test_case.expected_error_pattern then
assert(string.find(err, test_case.expected_error_pattern),
string.format("Test case %d (%s) error should match pattern '%s', but got: %s",
i, test_case.name, test_case.expected_error_pattern, err))
-- Log the actual error for debugging
ngx.log(ngx.INFO, string.format("Test case %d (%s) actual error: %s", i, test_case.name, err))
end
end
end

ngx.say("passed")
}
}
--- response_body
passed
Loading