diff --git a/README.md b/README.md index c6533c6e387b..062d38b29a10 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ A/B testing, canary release, blue-green deployment, limit rate, defense against - [Health Checks](doc/health-check.md): Enable health check on the upstream node, and will automatically filter unhealthy nodes during load balancing to ensure system stability. - Circuit-Breaker: Intelligent tracking of unhealthy upstream services. - [Proxy Mirror](doc/plugins/proxy-mirror.md): Provides the ability to mirror client requests. + - [Traffic Split](doc/plugins/traffic-split.md): Allows users to incrementally direct percentages of traffic between various upstreams. - **Fine-grained routing** diff --git a/README_CN.md b/README_CN.md index 275dd546ad5b..6ca2fec9dcc2 100644 --- a/README_CN.md +++ b/README_CN.md @@ -97,6 +97,7 @@ A/B 测试、金丝雀发布(灰度发布)、蓝绿部署、限流限速、抵 - [健康检查](doc/zh-cn/health-check.md):启用上游节点的健康检查,将在负载均衡期间自动过滤不健康的节点,以确保系统稳定性。 - 熔断器: 智能跟踪不健康上游服务。 - [代理镜像](doc/zh-cn/plugins/proxy-mirror.md): 提供镜像客户端请求的能力。 + - [流量拆分](doc/zh-cn/plugins/traffic-split.md): 允许用户逐步控制各个上游之间的流量百分比。 - **精细化路由** diff --git a/apisix/init.lua b/apisix/init.lua index 01dc7729d3c4..f1e6e91a8b52 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -205,6 +205,7 @@ local function parse_domain(host) return nil, "failed to parse domain" end end +_M.parse_domain = parse_domain local function parse_domain_for_nodes(nodes) diff --git a/apisix/plugins/traffic-split.lua b/apisix/plugins/traffic-split.lua new file mode 100644 index 000000000000..d74b08c406b7 --- /dev/null +++ b/apisix/plugins/traffic-split.lua @@ -0,0 +1,323 @@ +-- +-- 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 upstream = require("apisix.upstream") +local schema_def = require("apisix.schema_def") +local init = require("apisix.init") +local roundrobin = require("resty.roundrobin") +local ipmatcher = require("resty.ipmatcher") +local expr = require("resty.expr.v1") +local pairs = pairs +local ipairs = ipairs +local type = type +local table_insert = table.insert + +local lrucache = core.lrucache.new({ + ttl = 0, count = 512 +}) + + +local vars_schema = { + type = "array", + items = { + type = "array", + items = { + { + type = "string", + minLength = 1, + maxLength = 100 + }, + { + type = "string", + minLength = 1, + maxLength = 2 + } + }, + additionalItems = { + anyOf = { + {type = "string"}, + {type = "number"}, + {type = "boolean"}, + { + type = "array", + items = { + anyOf = { + { + type = "string", + minLength = 1, maxLength = 100 + }, + { + type = "number" + }, + { + type = "boolean" + } + } + }, + uniqueItems = true + } + } + }, + minItems = 0, + maxItems = 10 + } +} + + +local match_schema = { + type = "array", + items = { + type = "object", + properties = { + vars = vars_schema + } + }, + -- When there is no `match` rule, the default rule passes. + -- Perform upstream logic of plugin configuration. + default = {{ vars = {{"server_port", ">", 0}}}} +} + + +local upstreams_schema = { + type = "array", + items = { + type = "object", + properties = { + upstream_id = schema_def.id_schema, -- todo: support upstream_id method + upstream = schema_def.upstream, + weight = { + description = "used to split traffic between different" .. + "upstreams for plugin configuration", + type = "integer", + default = 1, + minimum = 0 + } + } + }, + -- When the upstream configuration of the plugin is missing, + -- the upstream of `route` is used by default. + default = { + { + weight = 1 + } + }, + minItems = 1, + maxItems = 20 +} + + +local schema = { + type = "object", + properties = { + rules = { + type = "array", + items = { + type = "object", + properties = { + match = match_schema, + weighted_upstreams = upstreams_schema + } + } + } + } +} + +local plugin_name = "traffic-split" + +local _M = { + version = 0.1, + priority = 966, + name = plugin_name, + schema = schema +} + +function _M.check_schema(conf) + local ok, err = core.schema.check(schema, conf) + + if not ok then + return false, err + end + + return true +end + + +local function parse_domain_for_node(node) + if not ipmatcher.parse_ipv4(node) + and not ipmatcher.parse_ipv6(node) + then + local ip, err = init.parse_domain(node) + if ip then + return ip + end + + if err then + return nil, err + end + end + + return node +end + + +local function set_pass_host(ctx, upstream_info, host) + -- Currently only supports a single upstream of the domain name. + -- When the upstream is `IP`, do not do any `pass_host` operation. + if not core.utils.parse_ipv4(host) + and not core.utils.parse_ipv6(host) + then + local pass_host = upstream_info.pass_host or "pass" + if pass_host == "pass" then + ctx.var.upstream_host = ctx.var.host + return + end + + if pass_host == "rewrite" then + ctx.var.upstream_host = upstream_info.upstream_host + return + end + + ctx.var.upstream_host = host + return + end + + return +end + + +local function set_upstream(upstream_info, ctx) + local nodes = upstream_info.nodes + local new_nodes = {} + if core.table.isarray(nodes) then + for _, node in ipairs(nodes) do + set_pass_host(ctx, upstream_info, node.host) + node.host = parse_domain_for_node(node.host) + node.port = node.port + node.weight = node.weight + table_insert(new_nodes, node) + end + else + for addr, weight in pairs(nodes) do + local node = {} + local ip, port, host + host, port = core.utils.parse_addr(addr) + set_pass_host(ctx, upstream_info, host) + ip = parse_domain_for_node(host) + node.host = ip + node.port = port + node.weight = weight + table_insert(new_nodes, node) + end + end + core.log.info("upstream_host: ", ctx.var.upstream_host) + + local up_conf = { + name = upstream_info.name, + type = upstream_info.type, + nodes = new_nodes, + timeout = { + send = upstream_info.timeout and upstream_info.timeout.send or 15, + read = upstream_info.timeout and upstream_info.timeout.read or 15, + connect = upstream_info.timeout and upstream_info.timeout.connect or 15 + } + } + + local ok, err = upstream.check_schema(up_conf) + if not ok then + return 500, err + end + + local matched_route = ctx.matched_route + local upstream_key = up_conf.type .. "#route_" .. + matched_route.value.id .. "_" ..upstream_info.vid + core.log.info("upstream_key: ", upstream_key) + upstream.set(ctx, upstream_key, ctx.conf_version, up_conf, matched_route) + + return +end + + +local function new_rr_obj(weighted_upstreams) + local server_list = {} + for i, upstream_obj in ipairs(weighted_upstreams) do + if not upstream_obj.upstream then + -- If the `upstream` object has only the `weight` value, it means + -- that the `upstream` weight value on the default `route` has been reached. + -- Need to set an identifier to mark the empty upstream. + upstream_obj.upstream = "empty_upstream" + end + + if type(upstream_obj.upstream) == "table" then + -- Add a virtual id field to uniquely identify the upstream `key`. + upstream_obj.upstream.vid = i + end + server_list[upstream_obj.upstream] = upstream_obj.weight + end + + return roundrobin:new(server_list) +end + + +function _M.access(conf, ctx) + if not conf or not conf.rules then + return + end + + local weighted_upstreams, match_flag + for _, rule in ipairs(conf.rules) do + match_flag = true + for _, single_match in ipairs(rule.match) do + local expr, err = expr.new(single_match.vars) + if err then + core.log.error("vars expression does not match: ", err) + return 500, err + end + + match_flag = expr:eval() + if match_flag then + break + end + end + + if match_flag then + weighted_upstreams = rule.weighted_upstreams + break + end + end + core.log.info("match_flag: ", match_flag) + + if not match_flag then + return + end + + local rr_up, err = lrucache(weighted_upstreams, nil, new_rr_obj, weighted_upstreams) + if not rr_up then + core.log.error("lrucache roundrobin failed: ", err) + return 500 + end + + local upstream = rr_up:find() + if upstream and upstream ~= "empty_upstream" then + core.log.info("upstream: ", core.json.encode(upstream)) + return set_upstream(upstream, ctx) + end + + return +end + + +return _M diff --git a/conf/config-default.yaml b/conf/config-default.yaml index dd8a931ed249..1029da4a74b4 100644 --- a/conf/config-default.yaml +++ b/conf/config-default.yaml @@ -246,6 +246,7 @@ plugins: # plugin list (sorted in alphabetical order) - wolf-rbac - zipkin # - server-info + - traffic-split stream_plugins: - mqtt-proxy diff --git a/doc/README.md b/doc/README.md index 24fa72404224..050c0dd096bf 100644 --- a/doc/README.md +++ b/doc/README.md @@ -81,6 +81,7 @@ * [request-validation](plugins/request-validation.md): Validates requests before forwarding to upstream. * [proxy-mirror](plugins/proxy-mirror.md): Provides the ability to mirror client requests. * [api-breaker](plugins/api-breaker.md): Circuit Breaker for API that stops requests forwarding to upstream in case of unhealthy state. +* [traffic-split](plugins/traffic-split.md): Allows users to incrementally direct percentages of traffic between various upstreams. ### Monitoring diff --git a/doc/plugins/traffic-split.md b/doc/plugins/traffic-split.md new file mode 100644 index 000000000000..9f92c6a28d66 --- /dev/null +++ b/doc/plugins/traffic-split.md @@ -0,0 +1,453 @@ + + +- [中文](../zh-cn/plugins/traffic-split.md) + +# Summary + +- [**Name**](#name) +- [**Attributes**](#attributes) +- [**How To Enable**](#how-to-enable) +- [**Example**](#example) + - [**Grayscale Release**](#grayscale-release) + - [**Blue-green Release**](#blue-green-release) + - [**Custom Release**](#custom-release) +- [**Disable Plugin**](#disable-plugin) + +## Name + +The traffic split plugin allows users to incrementally direct percentages of traffic between various upstreams. + +Note: The ratio between each upstream may not so accurate since the drawback of weighted round robin algorithm (especially when the wrr state is reset). + +## Attributes + +| Name | Type | Requirement | Default | Valid | Description | +| ---------------- | ------- | ----------- | ------- | ------- | ---------------------------------------------------------------------------------------- | +| rules.match | array[object] | optional | | | List of matching rules. | +| rules.match.vars | array[array] | optional | | | A list consisting of one or more {var, operator, val} elements, like this: {{var, operator, val}, {var, operator, val}, ...}}. For example: {"arg_name", "==", "json"}, which means that the current request parameter name is json. The var here is consistent with the naming of Nginx internal variables, so request_uri, host, etc. can also be used; for the operator part, the currently supported operators are ==, ~=, ~~, >, <, in, has and !. For specific usage of operators, please see the `operator-list` part of [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list). | +| rules.weighted_upstreams | array[object] | optional | | | List of upstream configuration rules. | +| rules.weighted_upstreams.upstream_id | string or integer | optional | | | The upstream id is bound to the corresponding upstream(not currently supported). | +| rules.weighted_upstreams.upstream | object | optional | | | Upstream configuration information. | +| rules.weighted_upstreams.upstream.type | enum | optional | roundrobin | [roundrobin, chash] | roundrobin supports weighted load, chash consistent hashing, the two are alternatives. | +| rules.weighted_upstreams.upstream.nodes | object | optional | | | In the hash table, the key of the internal element is the list of upstream machine addresses, in the format of address + Port, where the address part can be an IP or a domain name, such as 192.168.1.100:80, foo.com:80, etc. value is the weight of the node. In particular, when the weight value is 0, it has special meaning, which usually means that the upstream node is invalid and never wants to be selected. | +| rules.weighted_upstreams.upstream.timeout | object | optional | 15 | | Set the timeout period for connecting, sending and receiving messages (time unit: second, all default to 15 seconds). | +| rules.weighted_upstreams.upstream.pass_host | enum | optional | "pass" | ["pass", "node", "rewrite"] | pass: pass the host requested by the client, node: pass the host requested by the client; use the host configured with the upstream node, rewrite: rewrite the host with the value configured by the upstream_host. | +| rules.weighted_upstreams.upstream.name | string | optional | | | Identify the upstream service name, usage scenario, etc. | +| rules.weighted_upstreams.upstream.upstream_host | string | optional | | | Only valid when pass_host is configured as rewrite. | +| rules.weighted_upstreams.weight | integer | optional | weight = 1 | | The traffic is divided according to the `weight` value, and the roundrobin algorithm is used to divide multiple `weight`. | + +The traffic-split plugin is mainly composed of two parts: `match` and `weighted_upstreams`. `match` is a custom conditional rule, and `weighted_upstreams` is upstream configuration information. If you configure `match` and `weighted_upstreams` information, then after the `match` rule is verified, it will be based on the `weight` value in `weighted_upstreams`; the ratio of traffic between each upstream in the plug-in will be guided, otherwise, all traffic will be directly Reach the `upstream` configured on `route` or `service`. Of course, you can also configure only the `weighted_upstreams` part, which will directly guide the traffic ratio between each upstream in the plugin based on the `weight` value in `weighted_upstreams`. + +>Note: 1. In `match`, the expression in vars is the relationship of `and`, and the relationship between multiple `vars` is the relationship of `or`. 2. There is only a `weight` value in the weighted_upstreams of the plug-in, which means reaching the upstream traffic weight value configured on `route` or `service`. Such as: + +```json +{ + "weight": 2 +} +``` + +## How To Enable + +Create a route and enable the `traffic-split` plugin: + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + }, + "timeout": { + "connect": 15, + "send": 15, + "read": 15 + } + }, + "weight": 1 + }, + { + "weight": 1 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +## Example + +### Grayscale Release + +The `match` rule part is missing, and the traffic is split according to the `weight` value configured by the `weighted_upstreams` in the plugin. Divide `plug-in upstream` and `route's upstream` according to the traffic ratio of 3:2, of which 60% of the traffic reaches the upstream of the `1981` port in the plugin, and 40% of the traffic reaches the default `1980` port on the route Upstream. + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + }, + "timeout": { + "connect": 15, + "send": 15, + "read": 15 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +**Test plugin:** + +There are 5 requests, 3 requests hit the upstream of port 1981 of the plug-in, and 2 requests hit the upstream of port 1980 of `route`. + +```shell +$ curl http://127.0.0.1:9080/index.html -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 + +hello 1980 + +$ curl http://127.0.0.1:9080/index.html -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 + +world 1981 + +...... +``` + +### Blue-green Release + +Get the `match` rule parameter through the request header (you can also get it through the request parameter or NGINX variable). After the `match` rule is matched, it means that all requests hit the upstream configured by the plugin, otherwise the request only hits the `route` configured upstream. + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["http_release","==","new_release"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + } + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +**Test plugin:** + +The rule of `match` is matched, and all requests hit the upstream port 1981 configured by the plugin: + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'release: new_release' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +The `match` rule fails to match, and all requests hit the 1980 port upstream configured on the `route`: + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'release: old_release' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +### Custom Release + +Multiple `vars` rules can be set in `match`. Multiple expressions in `vars` have an `add` relationship, and multiple `vars` rules have an `or` relationship; as long as one of the vars is required If the rule passes, the entire `match` passes. + +**Example 1: Only one `vars` rule is configured, and multiple expressions in `vars` are in the relationship of `add`. In `weighted_upstreams`, the traffic is divided into 3:2 according to the value of `weight`, of which only the part of the `weight` value represents the proportion of upstream on the `route`. When `match` fails to pass, all traffic will only hit the upstream on the route.** + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["arg_name","==","jack"], + ["http_user-id",">","23"], + ["http_apisix-key","~~","[a-z]+"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +The plugin sets the requested `match` rule and upstream with port `1981`, and the route has upstream with port `1980`. + +**Test plugin:** + +>1. After the verification of the `match` rule is passed, 60% of the requests hit the upstream of the plug-in port 1981, and 40% of the requests hit the upstream of the 1980 port of the `route`. + +The match rule is successfully verified, and the upstream port of `1981` is hit. + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'apisix-key: hello' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +The match rule fails to verify, and it hits the upstream of the default port of `1980`. + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'apisix-key: hello' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +After 5 requests, the service of port `1981` was hit 3 times, and the service of port `1980` was hit 2 times. + +**Example 2: Configure multiple `vars` rules. Multiple expressions in `vars` are `add` relationships, and multiple `vars` are `and` relationships. According to the `weight` value in `weighted_upstreams`, the traffic is divided into 3:2, where only the part of the `weight` value represents the proportion of upstream on the route. When `match` fails to pass, all traffic will only hit the upstream on the route.** + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["arg_name","==","jack"], + ["http_user-id",">","23"], + ["http_apisix-key","~~","[a-z]+"] + ], + "vars": [ + ["arg_name2","==","rose"], + ["http_user-id2","!",">","33"], + ["http_apisix-key2","~~","[a-z]+"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +The plugin sets the requested `match` rule and the upstream port of `1981`, and the route has upstream port of `1980`. + +**Test plugin:** + +>1. The expressions of the two `vars` are matched successfully. After the `match` rule is verified, 60% of the requests hit the 1981 port upstream of the plugin, and 40% of the requests hit the 1980 port upstream of the `route`. + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack&name2=rose' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack&name2=rose' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +After 5 requests, the service of port `1981` was hit 3 times, and the service of port `1980` was hit 2 times. + +>2. The second expression of `vars` failed to match (missing the `name2` request parameter). After the `match` rule was verified, 60% of the requests hit the plug-in's 1981 port upstream, and 40% of the request traffic hits Go upstream to the 1980 port of `route`. + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +After 5 requests, the service of port `1981` was hit 3 times, and the service of port `1980` was hit 2 times. + +>3. The expression verification of two `vars` failed (missing the request parameters of `name` and `name2`), the `match` rule verification failed, and the response is the upstream data `hello 1980` of the default `route`. + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +## Disable Plugin + +When you want to remove the traffic-split plugin, it's very simple, just delete the corresponding json configuration in the plugin configuration, no need to restart the service, it will take effect immediately: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` diff --git a/doc/zh-cn/README.md b/doc/zh-cn/README.md index 53250ab9a4b6..ab699a2db602 100644 --- a/doc/zh-cn/README.md +++ b/doc/zh-cn/README.md @@ -81,6 +81,7 @@ * [request-validation](plugins/request-validation.md): 请求验证。 * [proxy-mirror](plugins/proxy-mirror.md):代理镜像插件提供镜像客户端请求的能力。 * [api-breaker](plugins/api-breaker.md): API的断路器,在状态不正常的情况下停止将请求转发到上游。 +* [traffic-split](plugins/traffic-split.md):允许用户逐步控制各个上游之间的流量百分比。 ### Monitoring diff --git a/doc/zh-cn/plugins/traffic-split.md b/doc/zh-cn/plugins/traffic-split.md new file mode 100644 index 000000000000..834c46836679 --- /dev/null +++ b/doc/zh-cn/plugins/traffic-split.md @@ -0,0 +1,464 @@ + + +- [English](../../plugins/traffic-split.md) + +# 目录 + +- [名字](#名字) +- [属性](#属性) +- [如何启用](#如何启用) +- [示例](#示例) + - [灰度发布](#灰度发布) + - [蓝绿发布](#蓝绿发布) + - [自定义发布](#自定义发布) +- [禁用插件](#禁用插件) + +## 名字 + +traffic-split 插件使用户可以逐步引导各个上游之间的流量百分比。 + +注:由于加权循环算法(特别是在重置wrr状态时)的缺点,因此每个上游之间的比率可能不太准确。 + +## 属性 + +| 参数名 | 类型 | 可选项 | 默认值 | 有效值 | 描述 | +| ------------ | ------------- | ------ | ------ | ------ | -------------------- | +| rules.match | array[object] | 可选 | | | 匹配规则列表 | +| rules.match.vars | array[array] | 可选 | | | 由一个或多个{var, operator, val}元素组成的列表,类似这样:{{var, operator, val}, {var, operator, val}, ...}}。例如:{"arg_name", "==", "json"},表示当前请求参数 name 是 json。这里的 var 与 Nginx 内部自身变量命名是保持一致,所以也可以使用 request_uri、host 等;对于 operator 部分,目前已支持的运算符有 ==、~=、~~、>、<、in、has 和 ! 。操作符的具体用法请看 [lua-resty-expr](https://github.com/api7/lua-resty-expr#operator-list) 的 `operator-list` 部分。 | +| rules.weighted_upstreams | array[object] | 可选 | | | 上游配置规则列表。 | +| rules.weighted_upstreams.upstream_id | string or integer | 可选 | | | 通过上游 id 绑定对应上游(暂不支持)。 | +| rules.weighted_upstreams.upstream | object | 可选 | | | 上游配置信息。 | +| rules.weighted_upstreams.upstream.type | enum | 可选 | roundrobin | [roundrobin, chash] | roundrobin 支持权重的负载,chash 一致性哈希,两者是二选一的(目前只支持 `roundrobin`)。 | +| rules.weighted_upstreams.upstream.nodes | object | 可选 | | | 哈希表,内部元素的 key 是上游机器地址 列表,格式为地址 + Port,其中地址部 分可以是 IP 也可以是域名,⽐如 192.168.1.100:80、foo.com:80等。 value 则是节点的权重,特别的,当权重 值为 0 有特殊含义,通常代表该上游节点 失效,永远不希望被选中。 | +| rules.weighted_upstreams.upstream.timeout | object | 可选 | 15 | | 设置连接、发送消息、接收消息的超时时间(时间单位:秒,都默认为 15 秒)。 | +| rules.weighted_upstreams.upstream.pass_host | enum | 可选 | "pass" | ["pass", "node", "rewrite"] | pass: 透传客户端请求的 host, node: 不透传客户端请求的 host; 使用 upstream node 配置的 host, rewrite: 使用 upstream_host 配置的值重写 host 。 | +| rules.weighted_upstreams.upstream.name | string | 可选 | | | 标识上游服务名称、使⽤场景等。 | +| rules.weighted_upstreams.upstream.upstream_host | string | 可选 | | | 只在 pass_host 配置为 rewrite 时有效。 | +| rules.weighted_upstreams.weight | integer | 可选 | weight = 1 | | 根据 `weight` 值做流量划分,多个 weight 之间使用 roundrobin 算法划分。| + +traffic-split 插件主要由 `match` 和 `weighted_upstreams` 两部分组成,`match` 是自定义的条件规则,`weighted_upstreams` 是 upstream 的配置信息。如果配置 `match` 和 `weighted_upstreams` 信息,那么在 `match` 规则校验通过后,会根据 `weighted_upstreams` 中的 `weight` 值;引导插件中各个 upstream 之间的流量比例,否则,所有流量直接到达 `route` 或 `service` 上配置的 `upstream`。当然你也可以只配置 `weighted_upstreams` 部分,这样会直接根据 `weighted_upstreams` 中的 `weight` 值,引导插件中各个 upstream 之间的流量比例。 + +>注:1、在 `match` 里,vars 中的表达式是 `and` 的关系,多个 `vars` 之间是 `or` 的关系。2、在插件的 weighted_upstreams 中只有 `weight` 值,表示到达 `route` 或 `service` 上配置的 upstream 流量权重值。如: + +```json +{ + "weight": 2 +} +``` + +## 如何启用 + +创建一个路由并启用 `traffic-split` 插件: + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + }, + "timeout": { + "connect": 15, + "send": 15, + "read": 15 + } + }, + "weight": 1 + }, + { + "weight": 1 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +## 示例 + +### 灰度发布 + +缺少 `match` 规则部分,根据插件中 `weighted_upstreams` 配置的 `weight` 值做流量分流。将 `插件的 upstream` 与 `route 的 upstream` 按 3:2 的流量比例进行划分,其中 60% 的流量到达插件中的 `1981` 端口的 upstream, 40% 的流量到达 route 上默认 `1980` 端口的 upstream。 + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + }, + "timeout": { + "connect": 15, + "send": 15, + "read": 15 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +**插件测试:** + +请求5次,3次请求命中插件1981端口的 upstream, 2次请求命中 `route` 的1980端口 upstream。 + +```shell +$ curl http://127.0.0.1:9080/index.html -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 + +hello 1980 + +$ curl http://127.0.0.1:9080/index.html -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 + +world 1981 + +...... +``` + +### 蓝绿发布 + +通过请求头获取 `match` 规则参数(也可以通过请求参数获取或NGINX变量),在 `match` 规则匹配通过后,表示所有请求都命中到插件配置的 upstream ,否则所以请求只命中 `route` 上配置的 upstream 。 + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["http_release","==","new_release"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + } + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +**插件测试:** + +`match` 规则匹配通过,所有请求都命中插件配置的1981端口 upstream : + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'release: new_release' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +`match` 规则匹配失败,所有请求都命中 `route` 上配置的 1980端口 upstream : + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'release: old_release' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +### 自定义发布 + +`match` 中可以设置多个 `vars` 规则,`vars` 中的多个表达式之间是 `add` 的关系, 多个 `vars` 规则之间是 `or` 的关系;只要其中一个 vars 规则通过,则整个 `match` 通过。 + +**示例1:只配置了一个 `vars` 规则, `vars` 中的多个表达式是 `add` 的关系。在 `weighted_upstreams` 中根据 `weight` 值将流量按 3:2 划分,其中只有 `weight` 值的部分表示 `route` 上的 upstream 所占的比例。 当 `match` 匹配不通过时,所有的流量只会命中 route 上的 upstream 。** + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["arg_name","==","jack"], + ["http_user-id",">","23"], + ["http_apisix-key","~~","[a-z]+"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +插件设置了请求的 `match` 规则及端口为`1981`的 upstream,route 上具有端口为`1980`的 upstream。 + +**插件测试:** + +>1、在 `match` 规则校验通过后, 60% 的请求命中到插件的1981端口的 upstream, 40% 的请求命中到 `route` 的1980端口的 upstream。 + +match 规则校验成功, 命中端口为`1981`的 upstream。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'apisix-key: hello' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +match 规则校验失败,,命中默认端口为`1980`的 upstream。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'apisix-key: hello' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +在请求5次后,3次命中 `1981` 端口的服务,2次命中 `1980` 端口的服务。 + +>2、`match` 规则校验失败(缺少请求头 `apisix-key` ), 响应都为默认 upstream 的数据 `hello 1980`。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +**示例2:配置多个 `vars` 规则, `vars` 中的多个表达式是 `add` 的关系, 多个 `vars` 之间是 `and` 的关系。根据 `weighted_upstreams` 中的 `weight` 值将流量按 3:2 划分,其中只有 `weight` 值的部分表示 route 上的 upstream 所占的比例。 当 `match` 匹配不通过时,所有的流量只会命中 route 上的 upstream 。** + +```shell +curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [ + ["arg_name","==","jack"], + ["http_user-id",">","23"], + ["http_apisix-key","~~","[a-z]+"] + ], + "vars": [ + ["arg_name2","==","rose"], + ["http_user-id2","!",">","33"], + ["http_apisix-key2","~~","[a-z]+"] + ] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":10 + } + }, + "weight": 3 + }, + { + "weight": 2 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` + +插件设置了请求的 `match` 规则及端口为`1981`的 upstream,route 上具有端口为`1980`的 upstream 。 + +**测试插件:** + +>1、两个 `vars` 的表达式匹配成功, `match` 规则校验通过后, 60% 的请求命中到插件的1981端口 upstream, 40% 的请求命中到 `route` 的1980端口upstream。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack&name2=rose' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack&name2=rose' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +在请求5次后,3次命中 `1981` 端口的服务,2次命中 `1980` 端口的服务。 + +>2、第二个 `vars` 的表达式匹配失败(缺少 `name2` 请求参数),`match` 规则校验通过后, 60% 的请求命中到插件的1981端口 upstream, 40% 的请求流量命中到 `route` 的1980端口 upstream。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +world 1981 +``` + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -H 'user-id2:22' -H 'apisix-key: hello' -H 'apisix-key2: world' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +在请求5次后,3次命中 `1981` 端口的服务,2次命中 `1980` 端口的服务。 + +>3、两个 `vars` 的表达式校验失败(缺少 `name` 和 `name2` 请求参数),`match` 规则校验失败, 响应都为默认 `route` 的 upstream 数据 `hello 1980`。 + +```shell +$ curl 'http://127.0.0.1:9080/index.html?name=jack' -H 'user-id:30' -i +HTTP/1.1 200 OK +Content-Type: text/html; charset=utf-8 +...... + +hello 1980 +``` + +## 禁用插件 + +当你想去掉 traffic-split 插件的时候,很简单,在插件的配置中把对应的 json 配置删除即可,无须重启服务,即刻生效: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -d ' +{ + "uri": "/index.html", + "plugins": {}, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } +}' +``` diff --git a/t/admin/plugins.t b/t/admin/plugins.t index f81697a95fa1..db19467885ba 100644 --- a/t/admin/plugins.t +++ b/t/admin/plugins.t @@ -40,7 +40,7 @@ __DATA__ --- request GET /apisix/admin/plugins/list --- response_body_like eval -qr/\["zipkin","request-id","fault-injection","serverless-pre-function","batch-requests","cors","ip-restriction","referer-restriction","uri-blocker","request-validation","openid-connect","wolf-rbac","hmac-auth","basic-auth","jwt-auth","key-auth","consumer-restriction","authz-keycloak","proxy-mirror","proxy-cache","proxy-rewrite","api-breaker","limit-conn","limit-count","limit-req","redirect","response-rewrite","grpc-transcode","prometheus","echo","http-logger","sls-logger","tcp-logger","kafka-logger","syslog","udp-logger","example-plugin","serverless-post-function"\]/ +qr/\["zipkin","request-id","fault-injection","serverless-pre-function","batch-requests","cors","ip-restriction","referer-restriction","uri-blocker","request-validation","openid-connect","wolf-rbac","hmac-auth","basic-auth","jwt-auth","key-auth","consumer-restriction","authz-keycloak","proxy-mirror","proxy-cache","proxy-rewrite","api-breaker","limit-conn","limit-count","limit-req","traffic-split","redirect","response-rewrite","grpc-transcode","prometheus","echo","http-logger","sls-logger","tcp-logger","kafka-logger","syslog","udp-logger","example-plugin","serverless-post-function"\]/ --- no_error_log [error] diff --git a/t/debug/debug-mode.t b/t/debug/debug-mode.t index 5e5f1a1b44e9..efe4255b97c5 100644 --- a/t/debug/debug-mode.t +++ b/t/debug/debug-mode.t @@ -71,6 +71,7 @@ loaded plugin and sort by priority: 1005 name: api-breaker loaded plugin and sort by priority: 1003 name: limit-conn loaded plugin and sort by priority: 1002 name: limit-count loaded plugin and sort by priority: 1001 name: limit-req +loaded plugin and sort by priority: 966 name: traffic-split loaded plugin and sort by priority: 900 name: redirect loaded plugin and sort by priority: 899 name: response-rewrite loaded plugin and sort by priority: 506 name: grpc-transcode diff --git a/t/plugin/traffic-split.t b/t/plugin/traffic-split.t new file mode 100644 index 000000000000..72935193dfcd --- /dev/null +++ b/t/plugin/traffic-split.t @@ -0,0 +1,1289 @@ +# +# 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. +# +use t::APISIX 'no_plan'; + +repeat_each(1); +no_long_string(); +no_root_location(); +no_shuffle(); + +run_tests; + +__DATA__ + +=== TEST 1: schema validation passed +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.traffic-split") + local ok, err = plugin.check_schema({ + rules = { + { + match = { + { + vars = { + {"arg_name", "==", "jack"}, + {"arg_age", "!", "<", "16"} + } + }, + { + vars = { + {"arg_name", "==", "rose"}, + {"arg_age", "!", ">", "32"} + } + } + }, + weighted_upstreams = { + { + upstream = { + name = "upstream_A", + type = "roundrobin", + nodes = {["127.0.0.1:1981"]=2}, + timeout = {connect = 15, send = 15, read = 15} + }, + weight = 2 + }, + { + upstream = { + name = "upstream_B", + type = "roundrobin", + nodes = {["127.0.0.1:1982"]=2}, + timeout = {connect = 15, send = 15, read = 15} + }, + weight = 2 + }, + { + weight = 1 + } + } + } + } + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 2: schema validation passed, and `match` configuration is missing +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.traffic-split") + local ok, err = plugin.check_schema({ + rules = { + { + weighted_upstreams = { + { + upstream = { + name = "upstream_A", + type = "roundrobin", + nodes = {["127.0.0.1:1981"]=2}, + timeout = {connect = 15, send = 15, read = 15} + }, + weight = 2 + }, + { + weight = 1 + } + } + } + } + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body +done +--- no_error_log +[error] + + + +=== TEST 3: schema validation failed, `vars` expression operator type is wrong +--- config + location /t { + content_by_lua_block { + local plugin = require("apisix.plugins.traffic-split") + local ok, err = plugin.check_schema({ + rules = { + { + match = { + { + vars = { + {"arg_name", 123, "jack"} + } + } + }, + weighted_upstreams = { + { + upstream = { + name = "upstream_A", + type = "roundrobin", + nodes = {["127.0.0.1:1981"]=2}, + timeout = {connect = 15, send = 15, read = 15} + }, + weight = 2 + }, + { + weight = 1 + } + } + } + } + }) + if not ok then + ngx.say(err) + end + + ngx.say("done") + } + } +--- request +GET /t +--- response_body eval +qr/property "rules" validation failed:.* failed to validate item 2: wrong type: expected string, got number/ +--- no_error_log +[error] + + + +=== TEST 4: missing `rules` configuration, the upstream of the default `route` takes effect +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/server_port", + "plugins": { + "traffic-split": {} + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: the upstream of the default `route` takes effect +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 6 do + local _, _, body = t('/server_port', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1980, 1980, 1980, 1980 +--- no_error_log +[error] + + + +=== TEST 6: when `weighted_upstreams` is empty, the upstream of `route` is used by default +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [{}] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 7: the upstream of the default `route` takes effect +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 6 do + local _, _, body = t('/server_port', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1980, 1980, 1980, 1980 +--- no_error_log +[error] + + + +=== TEST 8: single `vars` expression and single plugin `upstream`, and the upstream traffic on `route` accounts for 1/3 +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["arg_name", "==", "jack"],["arg_age", "!","<", "16"]] + } + ], + "weighted_upstreams": [ + { + "upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": {"127.0.0.1:1981":2}, "timeout": {"connect": 15, "send": 15, "read": 15}}, + "weight": 2 + }, + { + "weight": 1 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 9: expression validation failed, return to the default `route` upstream port `1980` +--- request +GET /server_port?name=jack&age=14 +--- response_body eval +1980 +--- no_error_log +[error] + + + +=== TEST 10: the expression passes and initiated multiple requests, the upstream traffic of `route` accounts for 1/3, and the upstream traffic of plugins accounts for 2/3 +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 6 do + local _, _, body = t('/server_port?name=jack&age=16', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1981, 1981, 1981, 1981 +--- no_error_log +[error] + + + +=== TEST 11: Multiple vars rules and multiple plugin upstream +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + {"vars": [["arg_name", "==", "jack"], ["arg_age", "~~", "^[1-9]{1,2}"]]}, + {"vars": [["arg_name2", "in", ["jack", "rose"]], ["arg_age2", "!", "<", 18]]} + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": {"127.0.0.1:1981":20}}, "weight": 2}, + {"upstream": {"name": "upstream_B", "type": "roundrobin", "nodes": {"127.0.0.1:1982":10}}, "weight": 2}, + {"weight": 1} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 12: expression validation failed, return to the default `route` upstream port `1980` +--- request +GET /server_port?name=jack&age=0 +--- response_body eval +1980 +--- no_error_log +[error] + + + +=== TEST 13: the expression passes and initiated multiple requests, the upstream traffic of `route` accounts for 1/5, and the upstream traffic of plugins accounts for 4/5 +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 5 do + local _, _, body = t('/server_port?name=jack&age=22', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981, 1981, 1982, 1982 +--- no_error_log +[error] + + + +=== TEST 14: Multiple vars rules and multiple plugin upstream, do not split traffic to the upstream of `route` +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + {"vars": [["arg_name", "==", "jack"], ["arg_age", "~~", "^[1-9]{1,2}"]]}, + {"vars": [["arg_name2", "in", ["jack", "rose"]], ["arg_age2", "!", "<", 18]]} + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": {"127.0.0.1:1981":20}}, "weight": 2}, + {"upstream": {"name": "upstream_B", "type": "roundrobin", "nodes": {"127.0.0.1:1982":10}}, "weight": 2} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 15: the expression passes and initiated multiple requests, do not split traffic to the upstream of `route` +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 6 do + local _, _, body = t('/server_port?name=jack&age=22', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1981, 1981, 1981, 1982, 1982, 1982 +--- no_error_log +[error] + + + +=== TEST 16: support multiple ip configuration of `nodes`, and missing upstream configuration on `route` +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["arg_name", "==", "jack"], ["arg_age", "~~", "^[1-9]{1,2}"]] + } + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": {"127.0.0.1:1980":1, "127.0.0.1:1981":2, "127.0.0.1:1982":2}, "timeout": {"connect": 15, "send": 15, "read": 15}}, "weight": 1} + ] + } + ] + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 17: the expression passes and initiated multiple requests, roundrobin the ip of nodes +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 5 do + local _, _, body = t('/server_port?name=jack&age=22', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981, 1981, 1982, 1982 +--- no_error_log + + + +=== TEST 18: host is domain name +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "foo.com:80": 0 + } + }, + "weight": 2 + }, + { + "weight": 1 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 19: domain name resolved successfully +--- request +GET /server_port +--- error_code: 502 +--- error_log eval +qr/dns resolver domain: foo.com to \d+.\d+.\d+.\d+/ + + + +=== TEST 20: mock Grayscale Release +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":1 + } + }, + "weight": 2 + }, + { + "weight": 1 + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 21: 2/3 request traffic hits the upstream of the plugin, 1/3 request traffic hits the upstream of `route` +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 6 do + local _, _, body = t('/server_port', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1981, 1981, 1981, 1981 +--- no_error_log +[error] + + + +=== TEST 22: mock Blue-green Release +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["http_release","==","blue"]] + } + ], + "weighted_upstreams": [ + { + "upstream": { + "name": "upstream_A", + "type": "roundrobin", + "nodes": { + "127.0.0.1:1981":1 + } + } + } + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 23: release is equal to `blue` +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + local headers = {} + headers["release"] = "blue" + for i = 1, 6 do + local _, _, body = t('/server_port', ngx.HTTP_GET, "", nil, headers) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1981, 1981, 1981, 1981, 1981, 1981 +--- no_error_log +[error] + + + +=== TEST 24: release is equal to `green` +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + local headers = {} + headers["release"] = "green" + for i = 1, 6 do + local _, _, body = t('/server_port', ngx.HTTP_GET, "", nil, headers) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1980, 1980, 1980, 1980 +--- no_error_log +[error] + + + +=== TEST 25: mock Custom Release +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["arg_name", "==", "jack"], ["arg_age", ">", "23"],["http_appkey", "~~", "[a-z]{1,5}"]] + } + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": {"127.0.0.1:1981":20}}, "weight": 2}, + {"upstream": {"name": "upstream_B", "type": "roundrobin", "nodes": {"127.0.0.1:1982":10}}, "weight": 2}, + {"weight": 1} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 26: `match` rule passed +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 5 do + local _, _, body = t('/server_port?name=jack&age=36', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981, 1981, 1982, 1982 +--- no_error_log +[error] + + + +=== TEST 27: `match` rule failed, `age` condition did not match +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + local headers = {} + headers["release"] = "green" + for i = 1, 6 do + local _, _, body = t('/server_port?name=jack&age=16', ngx.HTTP_GET, "", nil, headers) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1980, 1980, 1980, 1980, 1980 +--- no_error_log +[error] + + + +=== TEST 28: upstream nodes are array type and node is the domain name +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": [{"host":"foo.com", "port": 80, "weight": 0}]}, "weight": 2} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 29: domain name resolved successfully +--- request +GET /server_port +--- error_code: 502 +--- error_log eval +qr/dns resolver domain: foo.com to \d+.\d+.\d+.\d+/ + + + +=== TEST 30: the nodes of upstream are array type, with multiple nodes +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["arg_name", "==", "jack"], ["arg_age", ">", "23"],["http_appkey", "~~", "[a-z]{1,5}"]] + } + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1981, "weight": 2}, {"host":"127.0.0.1", "port":1982, "weight": 2}]}, "weight": 4}, + {"weight": 1} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 31: `match` rule passed +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 5 do + local _, _, body = t('/server_port?name=jack&age=36', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981, 1981, 1982, 1982 +--- no_error_log +[error] + + + +=== TEST 32: the upstream node is an array type and has multiple upstream +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "match": [ + { + "vars": [["arg_name", "==", "jack"], ["arg_age", ">", "23"],["http_appkey", "~~", "[a-z]{1,5}"]] + } + ], + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1981, "weight": 2}]}, "weight": 2}, + {"upstream": {"name": "upstream_B", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1982, "weight": 2}]}, "weight": 2}, + {"weight": 1} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 33: `match` rule passed +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 5 do + local _, _, body = t('/server_port?name=jack&age=36', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981, 1981, 1982, 1982 +--- no_error_log +[error] + + + +=== TEST 34: multi-upstream, test with unique upstream key +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1981, "weight": 2}]}, "weight": 2}, + {"upstream": {"name": "upstream_B", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1982, "weight": 2}]}, "weight": 2} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 35: the upstream `key` is unique +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 2 do + local _, _, body = t('/server_port', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1981, 1982 +--- grep_error_log eval +qr/upstream_key: roundrobin#route_1_\d/ +--- grep_error_log_out +upstream_key: roundrobin#route_1_1 +upstream_key: roundrobin#route_1_2 +--- no_error_log +[error] + + + +=== TEST 36: has empty upstream, test the upstream key is unique +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_PUT, + [=[{ + "uri": "/server_port", + "plugins": { + "traffic-split": { + "rules": [ + { + "weighted_upstreams": [ + {"upstream": {"name": "upstream_A", "type": "roundrobin", "nodes": [{"host":"127.0.0.1", "port":1981, "weight": 2}]}, "weight": 1}, + {"weight": 1} + ] + } + ] + } + }, + "upstream": { + "type": "roundrobin", + "nodes": { + "127.0.0.1:1980": 1 + } + } + }]=] + ) + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 37: the upstream `key` is unique +--- config +location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local bodys = {} + for i = 1, 2 do + local _, _, body = t('/server_port', ngx.HTTP_GET) + bodys[i] = body + end + table.sort(bodys) + ngx.say(table.concat(bodys, ", ")) + } +} +--- request +GET /t +--- response_body +1980, 1981 +--- grep_error_log eval +qr/upstream_key: roundrobin#route_1_\d/ +--- grep_error_log_out +upstream_key: roundrobin#route_1_1 +--- no_error_log +[error]