Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
23 changes: 18 additions & 5 deletions apisix/discovery/consul/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ local core_sleep = require("apisix.core.utils").sleep
local resty_consul = require('resty.consul')
local http = require('resty.http')
local util = require("apisix.cli.util")
local discovery_utils = require("apisix.utils.discovery")
local ipairs = ipairs
local error = error
local ngx = ngx
Expand Down Expand Up @@ -78,15 +79,19 @@ function _M.all_nodes()
return all_services
end


function _M.nodes(service_name)
function _M.nodes(service_name, discovery_args)
if not all_services then
log.error("all_services is nil, failed to fetch nodes for : ", service_name)
return
end

local resp_list = all_services[service_name]

local metadata_match = discovery_args and discovery_args.metadata_match
if metadata_match then
resp_list = discovery_utils.nodes_metadata_match(resp_list, metadata_match)
end

if not resp_list then
log.error("fetch nodes failed by ", service_name, ", return default service")
return default_service and {default_service}
Expand All @@ -98,7 +103,6 @@ function _M.nodes(service_name)
return resp_list
end


local function update_all_services(consul_server_url, up_services)
-- clean old unused data
local old_services = consul_services[consul_server_url] or {}
Expand Down Expand Up @@ -511,11 +515,19 @@ function _M.connect(premature, consul_server, retry_delay)
local nodes = up_services[service_name]
local nodes_uniq = {}
for _, node in ipairs(result.body) do
if not node.Service then
local service = node.Service
if not service then
goto CONTINUE
end

local svc_address, svc_port = node.Service.Address, node.Service.Port
local svc_address = service.Address
local svc_port = service.Port
local metadata = service.Meta
-- ensure that metadata is an accessible table,
-- avoid userdata likes `null` returned by cjson
if type(metadata) ~= "table" then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls take a look, skip current service if the metadat is invalid

if type(metadata) == "cdata" then
   metadata = nil
elseif type(metadata) ~= "table" then
   core.log.error("wrong meta data, ...", ...)
   goto CONTINUE
end

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @membphis. I will add cdata check and error log, but I am not sure whether it is appropriate to skip this node.

If it is a container template release, it means that all the containers released in this batch will be skipped, and users only need to pay attention to whether it is valid when they need to use metadata. If it is skipped directly, the service quality will be affected. So I tend to keep these nodes, print logs and leave them empty.

metadata = nil
end
-- Handle nil or 0 port case - default to 80 for HTTP services
if not svc_port or svc_port == 0 then
svc_port = 80
Expand All @@ -533,6 +545,7 @@ function _M.connect(premature, consul_server, retry_delay)
host = svc_address,
port = tonumber(svc_port),
weight = default_weight,
metadata = metadata
})
nodes_uniq[service_id] = true
end
Expand Down
12 changes: 12 additions & 0 deletions apisix/schema_def.lua
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,18 @@ local upstream_schema = {
description = "group name",
type = "string",
},
metadata_match = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is recommended to unify it as metadata.

description = "metadata for filtering service instances",
type = "object",
additionalProperties = {
type = "array",
items = {
description = "candidate metadata value",
type = "string",
},
uniqueItems = true,
}
},
}
},
pass_host = {
Expand Down
66 changes: 66 additions & 0 deletions apisix/utils/discovery.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
--
-- 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.
--
local core = require("apisix.core")
local ipairs = ipairs
local pairs = pairs

local _M = {}

local function do_metadata_match(node, metadata_match)
local metadata = node.metadata
-- because metadata_match has already been checked in nodes_metadata_match,
-- there is at least one role, if there is no metadata in node, it's must not matched
if not metadata then
return false
end
for key, values in pairs(metadata_match) do
local matched = false
for _, value in ipairs(values) do
if metadata[key] == value then
matched = true
break
end
end
if not matched then
return false
end
end
return true
end

local function nodes_metadata_match(nodes, metadata_match)
if not nodes then
return nil
end

-- fast path: there is not metadata_match roles, all nodes are available,
-- and make a guarantee for do_metadata_match: at least one role
if not metadata_match then
return nodes
end

local result = {}
for _, node in ipairs(nodes) do
if do_metadata_match(node, metadata_match) then
core.table.insert(result, node)
end
end
return result
end
_M.nodes_metadata_match = nodes_metadata_match

return _M
55 changes: 55 additions & 0 deletions docs/en/latest/discovery/consul.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,61 @@ $ curl http://127.0.0.1:9180/apisix/admin/stream_routes/1 -H "X-API-KEY: $admin_
}'
```

### discovery_args

| Name | Type | Requirement | Default | Valid | Description |
|----------------| ------ | ----------- | ------- | ----- | ------------------------------------------------------------ |
| metadata_match | object | optional | {} | | Filter service instances by metadata using containment matching |

#### Metadata filtering

APISIX supports filtering service instances based on metadata. When a route is configured with metadata conditions, only service instances whose metadata matched with roles specified in the route's `metadata_match` 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", "b"], env: "prod"}`, but not routes configured with `{lane: ["c"]}` 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": "/consulWithMetadata/*",
"upstream": {
"service_name": "APISIX-CONSUL",
"type": "roundrobin",
"discovery_type": "consul",
"discovery_args": {
"metadata_match": {
"version": ["v1", "v2"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a question here, is it necessary to set the value to an array? I checked the service meta format of consul https://developer.hashicorp.com/consul/docs/reference/service#meta, and I think setting the value to a string should be sufficient to reduce complexity.

Copy link
Author

@jizhuozhi jizhuozhi Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @Baoyuantop , this feature has been running in our system since June, and we've benefited repeatedly from the ability to quickly recover from issues using an array rather than a string.

To limit the blast radius of new feature releases, we divide service releases into three phases: canary, prod-blue, and prod-green. Canary handles only a minimal amount of traffic to verify system stability, while prod-blue receives 50% of the online traffic to verify business issues. We also implement modular deployments for our online services, ensuring that all services are fully integrated across canary, prod-blue, and prod-green environments. If an issue is discovered in prod-blue, we quickly remove that phase from the gateway entry point and forward all traffic to prod-green.

With the current array-based configuration, if canary has some problem, we simply need to modify the apisix routing configuration:

From

{
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "discovery_args": {
            "metadata_match": {
                "env": ["canary", "prod-blue", "prod-green"]
            }
        }
    }
}

To

{
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "discovery_args": {
            "metadata_match": {
                "env": ["prod-blue", "prod-green"]
            }
        }
    }
}

But if we are using single string value, we must predefine a key likes env: prod to present this is a prod instance (even canary), and for staging deployment, we need define a secondary key likes staging: canary/prod-blue/prod-green. Normally, it would be easy to use env: prod (traffic is distributed to canary/prod-blue/prod-green based on instance weights), but when canary fails, we need to use a secondary key staging to avoid using canary. However, since only one value can be configured, I can only choose prod-blue or prod-green, which means I have to choose half of the instances to serve all the traffic, even if the other half are available.

From

{
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "discovery_args": {
            "metadata_match": {
                "env": "prod"
            }
        }
    }
}

To

{
    "upstream": {
        "type": "roundrobin",
        "discovery_type": "consul",
        "discovery_args": {
            "metadata_match": {
                "env": "prod",
                "staging": "prod-blue" // or "prod-green", cannot both "prod-blue" and "prod-green"
            }
        }
    }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And as the dimensions increase, I have to define multiple routes by cross-product to achieve the functions originally implemented using arrays.

}
}
}
}'
```

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

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": "/consulWithMultipleMetadata/*",
"upstream": {
"service_name": "APISIX-CONSUL",
"type": "roundrobin",
"discovery_type": "consul",
"discovery_args": {
"metadata_match": {
"lane": ["a"],
"env": ["prod"]
}
}
}
}'
```

This route will only route traffic to service instances that have both `lane: "a"` and `env: "prod"` in their metadata.

You could find more usage in the `apisix/t/discovery/stream/consul.t` file.

## Debugging API
Expand Down
68 changes: 68 additions & 0 deletions t/discovery/consul.t
Original file line number Diff line number Diff line change
Expand Up @@ -781,3 +781,71 @@ location /sleep {
qr//
]
--- ignore_error_log



=== TEST 16: test metadata_match with consul discovery
--- yaml_config eval: $::yaml_config
--- apisix_yaml
routes:
-
uri: /*
upstream:
service_name: service-a
type: roundrobin
discovery_type: consul
discovery_args:
metadata_match:
version:
- v2
- v3
#END
--- config
location /v1/agent {
proxy_pass http://127.0.0.1:8500;
}
location /sleep {
content_by_lua_block {
local args = ngx.req.get_uri_args()
local sec = args.sec or "2"
ngx.sleep(tonumber(sec))
ngx.say("ok")
}
}
--- timeout: 5
--- pipelined_requests eval
[
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service-a1\",\"Name\":\"service-a\",\"Address\":\"127.0.0.1\",\"Port\":30511,\"Meta\":{\"service_b_version\":\"4.1\",\"version\":\"v1\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service-a2\",\"Name\":\"service-a\",\"Address\":\"127.0.0.1\",\"Port\":30512,\"Meta\":{\"service_b_version\":\"4.1\",\"version\":\"v2\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service-a3\",\"Name\":\"service-a\",\"Address\":\"127.0.0.1\",\"Port\":30513,\"Meta\":{\"service_b_version\":\"4.1\",\"version\":\"v3\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service-a4\",\"Name\":\"service-a\",\"Address\":\"127.0.0.1\",\"Port\":30514,\"Meta\":{\"service_b_version\":\"4.1\",\"version\":\"v4\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"GET /sleep",

"GET /hello?run1",
"GET /hello?run2",
"GET /hello?run3",

"PUT /v1/agent/service/deregister/service-a1",
"PUT /v1/agent/service/deregister/service-a2",
"PUT /v1/agent/service/deregister/service-a3",
"PUT /v1/agent/service/deregister/service-a4",
]
--- response_body_like eval
[
qr//,
qr//,
qr//,
qr//,
qr/ok\n/,

qr/[2-3]/,
qr/[2-3]/,
qr/[2-3]/,

qr//,
qr//,
qr//,
qr//
]
--- no_error_log
[error]
8 changes: 4 additions & 4 deletions t/discovery/consul2.t
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1},{"host":"127.0.0.1","port":30512,"weight":1},{"host":"localhost","port":30511,"weight":1},{"host":"localhost","port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","port":80,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","metadata":{"service_version":"1.0"},"port":80,"weight":1}]}



Expand Down Expand Up @@ -223,7 +223,7 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1},{"host":"127.0.0.1","port":30512,"weight":1},{"host":"localhost","port":30511,"weight":1},{"host":"localhost","port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","port":80,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","metadata":{"service_version":"1.0"},"port":80,"weight":1}]}



Expand Down Expand Up @@ -257,7 +257,7 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1},{"host":"localhost","port":30511,"weight":1},{"host":"127.0.0.1","port":30512,"weight":1},{"host":"localhost","port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","port":80,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","metadata":{"service_version":"1.0"},"port":80,"weight":1}]}



Expand Down Expand Up @@ -291,7 +291,7 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1},{"host":"127.0.0.1","port":30512,"weight":1},{"host":"localhost","port":30511,"weight":1},{"host":"localhost","port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","port":80,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1},{"host":"localhost","metadata":{"service_a_version":"4.0"},"port":30512,"weight":1}],"service_no_port":[{"host":"127.0.0.1","metadata":{"service_version":"1.0"},"port":80,"weight":1}]}



Expand Down
14 changes: 9 additions & 5 deletions t/discovery/consul_dump.t
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@ location /v1/agent {
"PUT /v1/agent/service/deregister/service_a2",
"PUT /v1/agent/service/deregister/service_b1",
"PUT /v1/agent/service/deregister/service_b2",
"PUT /v1/agent/service/deregister/service_c1",
"PUT /v1/agent/service/deregister/service_c2",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service_a1\",\"Name\":\"service_a\",\"Tags\":[\"primary\",\"v1\"],\"Address\":\"127.0.0.1\",\"Port\":30511,\"Meta\":{\"service_a_version\":\"4.0\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service_b1\",\"Name\":\"service_b\",\"Tags\":[\"primary\",\"v1\"],\"Address\":\"127.0.0.1\",\"Port\":8002,\"Meta\":{\"service_b_version\":\"4.1\"},\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
"PUT /v1/agent/service/register\n" . "{\"ID\":\"service_c1\",\"Name\":\"service_c\",\"Tags\":[\"primary\",\"v1\"],\"Address\":\"127.0.0.1\",\"Port\":8003,\"EnableTagOverride\":false,\"Weights\":{\"Passing\":10,\"Warning\":1}}",
]
--- response_body eval
--- error_code eval
[200, 200, 200, 200, 200, 200]
[200, 200, 200, 200, 200, 200, 200, 200, 200]



Expand Down Expand Up @@ -95,7 +98,7 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1}],"service_b":[{"host":"127.0.0.1","port":8002,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1}],"service_b":[{"host":"127.0.0.1","metadata":{"service_b_version":"4.1"},"port":8002,"weight":1}],"service_c":[{"host":"127.0.0.1","port":8003,"weight":1}]}



Expand Down Expand Up @@ -141,9 +144,10 @@ location /v1/agent {
[
"PUT /v1/agent/service/deregister/service_a1",
"PUT /v1/agent/service/deregister/service_b1",
"PUT /v1/agent/service/deregister/service_c1",
]
--- error_code eval
[200, 200]
[200, 200, 200]



Expand Down Expand Up @@ -450,7 +454,7 @@ discovery:
--- request
GET /bonjour
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1}],"service_b":[{"host":"127.0.0.1","port":30517,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1}],"service_b":[{"host":"127.0.0.1","metadata":{"service_b_version":"4.1"},"port":30517,"weight":1}]}



Expand Down Expand Up @@ -508,4 +512,4 @@ discovery:
--- request
GET /t
--- response_body
{"service_a":[{"host":"127.0.0.1","port":30511,"weight":1}]}
{"service_a":[{"host":"127.0.0.1","metadata":{"service_a_version":"4.0"},"port":30511,"weight":1}]}
Loading