This is an automated email from the ASF dual-hosted git repository. shreemaan-abhishek pushed a commit to branch backport/14167-release-3.20 in repository https://gitbox.apache.org/repos/asf/apisix.git
commit 1b673c272346379ecd5e591a5dd0de4bf261e42b Author: Ming Wen <[email protected]> AuthorDate: Fri Nov 6 16:02:48 2026 +0800 fix(discovery): refresh the kubernetes endpoint cache by ttl instead of a version key (#14167) (cherry picked from commit 22ecbc60e4ea780abc0c76ee6396583a26ff7e68) --- apisix/discovery/kubernetes/core.lua | 28 +-- apisix/discovery/kubernetes/init.lua | 37 ++- apisix/discovery/tars/init.lua | 32 +-- docs/en/latest/discovery/kubernetes.md | 2 +- docs/zh/latest/discovery/kubernetes.md | 2 +- t/kubernetes/discovery/kubernetes2.t | 2 +- t/kubernetes/discovery/kubernetes3.t | 8 +- t/kubernetes/discovery/kubernetes4.t | 4 +- t/kubernetes/discovery/kubernetes5.t | 33 ++- t/kubernetes/discovery/kubernetes6.t | 372 +++++++++++++++++++++++++++++ t/kubernetes/discovery/kubernetes7.t | 135 +++++++++++ t/kubernetes/discovery/stream/kubernetes.t | 2 +- t/lib/k8s_discovery_e2e.lua | 235 ++++++++++++++++++ t/tars/discovery/tars.t | 20 ++ 14 files changed, 832 insertions(+), 80 deletions(-) diff --git a/apisix/discovery/kubernetes/core.lua b/apisix/discovery/kubernetes/core.lua index aaf0142a8..460bf0730 100644 --- a/apisix/discovery/kubernetes/core.lua +++ b/apisix/discovery/kubernetes/core.lua @@ -236,16 +236,12 @@ end -- ─── endpoint dict operations (exported) ────────────────────────────── function _M.update_endpoint_dict(handle, endpoints, endpoint_key) + -- one key per service, written by a single atomic safe_set: workers + -- re-read it when their cache entry expires, so there is no version key + -- that could be observed before the content (apache/apisix#14149) local endpoint_content = core.json.encode(endpoints, true) - local endpoint_version = ngx.crc32_long(endpoint_content) - local _, err - _, err = handle.endpoint_dict:safe_set(endpoint_key .. "#version", endpoint_version) + local _, err = handle.endpoint_dict:safe_set(endpoint_key, endpoint_content) if err then - return false, "set endpoint version into discovery DICT failed, " .. err - end - _, err = handle.endpoint_dict:safe_set(endpoint_key, endpoint_content) - if err then - handle.endpoint_dict:delete(endpoint_key .. "#version") return false, "set endpoint into discovery DICT failed, " .. err end return true @@ -255,8 +251,7 @@ end function _M.create_endpoint_lrucache(endpoint_dict, endpoint_key, endpoint_port) local endpoint_content = endpoint_dict:get(endpoint_key) if not endpoint_content then - core.log.error("get empty endpoint content from discovery DIC, this should not happen ", - endpoint_key) + core.log.info("endpoint not found in discovery DICT, key: ", endpoint_key) return nil end @@ -409,7 +404,6 @@ function _M.create_endpoint_callbacks(options) end if operate == "list" then handle.current_keys_hash[endpoint_key] = true - handle.current_keys_hash[endpoint_key .. "#version"] = true end end @@ -508,7 +502,6 @@ function _M.create_endpoint_callbacks(options) end if operate == "list" then handle.current_keys_hash[endpoint_key] = true - handle.current_keys_hash[endpoint_key .. "#version"] = true end end @@ -528,7 +521,6 @@ function _M.create_endpoint_callbacks(options) core.log.debug(core.json.delay_encode(endpoint)) local endpoint_key = build_endpoint_key( key_prefix, endpoint.metadata.namespace, endpoint.metadata.name) - handle.endpoint_dict:delete(endpoint_key .. "#version") handle.endpoint_dict:delete(endpoint_key) end @@ -711,13 +703,9 @@ function _M.resolve_nodes(endpoint_lrucache, service_name, pattern, dict_resolve return nil end - local endpoint_version = endpoint_dict:get(endpoint_key .. "#version") - if not endpoint_version then - core.log.info("get empty endpoint version from discovery DICT ", endpoint_key) - return nil - end - - return endpoint_lrucache(service_name, endpoint_version, + -- no version probe: the entry is re-read from the shared dict when its + -- ttl expires (see the lrucache options in init.lua) + return endpoint_lrucache(service_name, nil, _M.create_endpoint_lrucache, endpoint_dict, endpoint_key, endpoint_port) end diff --git a/apisix/discovery/kubernetes/init.lua b/apisix/discovery/kubernetes/init.lua index 11c8b0630..ffbcce2ab 100644 --- a/apisix/discovery/kubernetes/init.lua +++ b/apisix/discovery/kubernetes/init.lua @@ -31,9 +31,19 @@ local k8s_core = require("apisix.discovery.kubernetes.core") local ctx +-- The shared dict is the source of truth. A worker serves a cached node list +-- for at most ENDPOINT_CACHE_TTL seconds and then re-reads it from the dict +-- (invalid_stale: an expired entry is dropped, never handed back), so there +-- is no version probe and no way to cache a torn pair (apache/apisix#14149). +-- A miss is cached for the same time so an unknown service does not hit the +-- dict on every request. +local ENDPOINT_CACHE_TTL = 1 local endpoint_lrucache = core.lrucache.new({ - ttl = 300, - count = 1024 + ttl = ENDPOINT_CACHE_TTL, + count = 1024, + invalid_stale = true, + neg_ttl = ENDPOINT_CACHE_TTL, + neg_count = 64, }) @@ -164,9 +174,13 @@ end local function merge_cluster_nodes(endpoint_dicts, endpoint_key, endpoint_port) local nodes = {} local seen = {} + local found = false for _, endpoint_dict in ipairs(endpoint_dicts) do local cluster_nodes = k8s_core.create_endpoint_lrucache(endpoint_dict, endpoint_key, endpoint_port) + if cluster_nodes then + found = true + end for _, node in ipairs(cluster_nodes or {}) do local addr = node.host .. ":" .. tostring(node.port) if not seen[addr] then @@ -176,6 +190,12 @@ local function merge_cluster_nodes(endpoint_dicts, endpoint_key, endpoint_port) end end + -- nil, not an empty table, when no selected cluster knows the service, + -- so that the miss is negative-cached like in single cluster mode + if not found then + return nil + end + return nodes end @@ -203,7 +223,6 @@ local function selected_clusters_nodes(service_name, cluster_ids) local endpoint_key, endpoint_port = match[1], match[2] local endpoint_dicts = core.table.new(#cluster_ids, 0) - local versions = core.table.new(#cluster_ids, 0) local unknown_ids for _, id in ipairs(cluster_ids) do local endpoint_dict = ctx[id] @@ -211,11 +230,7 @@ local function selected_clusters_nodes(service_name, cluster_ids) unknown_ids = unknown_ids or {} core.table.insert(unknown_ids, id) else - local endpoint_version = endpoint_dict:get(endpoint_key .. "#version") - if endpoint_version then - core.table.insert(endpoint_dicts, endpoint_dict) - core.table.insert(versions, id .. "#" .. endpoint_version) - end + core.table.insert(endpoint_dicts, endpoint_dict) end end @@ -225,12 +240,12 @@ local function selected_clusters_nodes(service_name, cluster_ids) end if #endpoint_dicts == 0 then - core.log.info("get empty endpoint version from selected clusters for ", service_name) + core.log.info("no known kubernetes discovery cluster selected for ", service_name) return nil end - return endpoint_lrucache(service_name .. "#" .. core.table.concat(cluster_ids, ","), - core.table.concat(versions, ","), + -- merged from the selected dicts, refreshed when the cache entry expires + return endpoint_lrucache(service_name .. "#" .. core.table.concat(cluster_ids, ","), nil, merge_cluster_nodes, endpoint_dicts, endpoint_key, endpoint_port) end diff --git a/apisix/discovery/tars/init.lua b/apisix/discovery/tars/init.lua index 17bb2758b..e5cacd8e5 100644 --- a/apisix/discovery/tars/init.lua +++ b/apisix/discovery/tars/init.lua @@ -55,9 +55,15 @@ local default_weight local last_fetch_full_time = 0 local last_db_error +-- same model as the kubernetes discovery: the shared dict is the source of +-- truth and a cached entry is re-read when it expires, no version probe +local ENDPOINT_CACHE_TTL = 1 local endpoint_lrucache = core.lrucache.new({ - ttl = 300, - count = 1024 + ttl = ENDPOINT_CACHE_TTL, + count = 1024, + invalid_stale = true, + neg_ttl = ENDPOINT_CACHE_TTL, + neg_count = 64, }) local activated_buffer = core.table.new(10, 0) @@ -77,25 +83,16 @@ local endpoints_pattern = core.table.concat( local function update_endpoint(servant, nodes) local endpoint_content = core.json.encode(nodes, true) - local endpoint_version = ngx.crc32_long(endpoint_content) core.log.debug("set servant ", servant, endpoint_content) - local _, err - _, err = endpoint_dict:safe_set(servant .. "#version", endpoint_version) - if err then - core.log.error("set endpoint version into nginx shared dict failed, ", err) - return - end - _, err = endpoint_dict:safe_set(servant, endpoint_content) + local _, err = endpoint_dict:safe_set(servant, endpoint_content) if err then core.log.error("set endpoint into nginx shared dict failed, ", err) - endpoint_dict:delete(servant .. "#version") end end local function delete_endpoint(servant) core.log.info("delete servant ", servant) - endpoint_dict:delete(servant .. "#version") endpoint_dict:delete(servant) end @@ -103,7 +100,7 @@ end local function add_endpoint_to_lrucache(servant) local endpoint_content, err = endpoint_dict:get_stale(servant) if not endpoint_content then - core.log.error("get empty endpoint content, servant: ", servant, ", err: ", err) + core.log.info("endpoint not found in tars DICT, servant: ", servant, ", err: ", err) return nil end @@ -129,14 +126,7 @@ local function get_endpoint(servant) so we must use endpoint_dict:get_stale() to get value instead endpoint_dict:get() --]] - local endpoint_version, err = endpoint_dict:get_stale(servant .. "#version") - if not endpoint_version then - if err then - core.log.error("get empty endpoint version, servant: ", servant, ", err: ", err) - end - return nil - end - return endpoint_lrucache(servant, endpoint_version, add_endpoint_to_lrucache, servant) + return endpoint_lrucache(servant, nil, add_endpoint_to_lrucache, servant) end diff --git a/docs/en/latest/discovery/kubernetes.md b/docs/en/latest/discovery/kubernetes.md index b00e3750f..92589dbb1 100644 --- a/docs/en/latest/discovery/kubernetes.md +++ b/docs/en/latest/discovery/kubernetes.md @@ -327,7 +327,7 @@ Because lua-resty-http does not currently support mTLS, and basic authentication **Q: APISIX inherits Nginx's multiple process model, does it mean that each nginx worker process will [_List-Watch_](https://kubernetes.io/docs/reference/using-api/api-concepts/) kubernetes endpoints resources?** -A: The Kubernetes service discovery only uses privileged processes to [_List-Watch_](https://kubernetes.io/docs/reference/using-api/api-concepts/) Kubernetes endpoints resources, then store theirs value into `ngx.shared.DICT`, worker processes get results by querying `ngx.shared.DICT`. +A: The Kubernetes service discovery only uses privileged processes to [_List-Watch_](https://kubernetes.io/docs/reference/using-api/api-concepts/) Kubernetes endpoints resources, then store theirs value into `ngx.shared.DICT`, worker processes get results by querying `ngx.shared.DICT`. Each worker process caches the nodes of a service for one second and then reads them from `ngx.shared.DICT` again, so a change reaches every worker process within one second of the privileged process writi [...] **Q: What permissions do [_ServiceAccount_](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) require?** diff --git a/docs/zh/latest/discovery/kubernetes.md b/docs/zh/latest/discovery/kubernetes.md index 6ec9993ed..beae487db 100644 --- a/docs/zh/latest/discovery/kubernetes.md +++ b/docs/zh/latest/discovery/kubernetes.md @@ -325,7 +325,7 @@ A: 一般情况下,我们有三种方式可以完成与 Kubernetes APIServer **Q: APISIX 继承了 NGINX 的多进程模型,是否意味着每个 APISIX 工作进程都会监听 Kubernetes Endpoints?** -A: Kubernetes 服务发现只使用特权进程监听 Kubernetes Endpoints,然后将其值存储到 `ngx.shared.DICT` 中,工作进程通过查询 `ngx.shared.DICT` 来获取结果。 +A: Kubernetes 服务发现只使用特权进程监听 Kubernetes Endpoints,然后将其值存储到 `ngx.shared.DICT` 中,工作进程通过查询 `ngx.shared.DICT` 来获取结果。每个工作进程会把一个服务的节点缓存 1 秒,到期后重新从 `ngx.shared.DICT` 读取,因此一次变化会在特权进程写入 `ngx.shared.DICT` 后 1 秒内到达所有工作进程。 **Q: [_ServiceAccount_](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/) 需要的权限有哪些?** diff --git a/t/kubernetes/discovery/kubernetes2.t b/t/kubernetes/discovery/kubernetes2.t index 41a3819d4..8bdd024bd 100644 --- a/t/kubernetes/discovery/kubernetes2.t +++ b/t/kubernetes/discovery/kubernetes2.t @@ -105,7 +105,7 @@ _EOC_ local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - ngx.sleep(1) + ngx.sleep(1.1) ngx.req.read_body() local request_body = ngx.req.get_body_data() diff --git a/t/kubernetes/discovery/kubernetes3.t b/t/kubernetes/discovery/kubernetes3.t index 47b902e18..d7972e08c 100644 --- a/t/kubernetes/discovery/kubernetes3.t +++ b/t/kubernetes/discovery/kubernetes3.t @@ -127,7 +127,7 @@ _EOC_ local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - ngx.sleep(1) + ngx.sleep(1.1) ngx.req.read_body() local request_body = ngx.req.get_body_data() @@ -205,7 +205,7 @@ _EOC_ return 500 end - ngx.sleep(1) + ngx.sleep(1.1) local k8s = require("apisix.discovery.kubernetes") local data = k8s.dump_data() @@ -226,7 +226,7 @@ _EOC_ local http = require "resty.http" local httpc = http.new() - ngx.sleep(1) + ngx.sleep(1.1) local dump_uri = "http://127.0.0.1:" .. ngx.var.server_port .. "/v1/discovery/kubernetes/dump" local res, err = httpc:request_uri(dump_uri, { method = "GET"}) @@ -253,7 +253,7 @@ _EOC_ ngx.status = res.status return end - ngx.sleep(1) + ngx.sleep(1.1) end local httpc = http.new() local res, _ = httpc:request_uri(healthcheck_uri, {method = "GET", keepalive = false}) diff --git a/t/kubernetes/discovery/kubernetes4.t b/t/kubernetes/discovery/kubernetes4.t index 3214523ac..266f4992d 100644 --- a/t/kubernetes/discovery/kubernetes4.t +++ b/t/kubernetes/discovery/kubernetes4.t @@ -255,7 +255,7 @@ _EOC_ local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - ngx.sleep(1) + ngx.sleep(1.1) ngx.req.read_body() local request_body = ngx.req.get_body_data() @@ -337,7 +337,7 @@ _EOC_ return 500 end - ngx.sleep(1) + ngx.sleep(1.1) if res.status ~= 200 and res.status ~= 201 and res.status ~= 409 then return res.status diff --git a/t/kubernetes/discovery/kubernetes5.t b/t/kubernetes/discovery/kubernetes5.t index 572494343..555b99641 100644 --- a/t/kubernetes/discovery/kubernetes5.t +++ b/t/kubernetes/discovery/kubernetes5.t @@ -109,7 +109,7 @@ _EOC_ local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - ngx.sleep(1) + ngx.sleep(1.1) ngx.req.read_body() local request_body = ngx.req.get_body_data() @@ -285,10 +285,9 @@ skip unknown kubernetes discovery cluster ids: third, service: ns-b/ep:p1 location /t { content_by_lua_block { local core = require("apisix.core") - local function set_endpoints(id, key, endpoints, version) + local function set_endpoints(id, key, endpoints) local dict = ngx.shared["kubernetes-" .. id] dict:set(key, core.json.encode(endpoints)) - dict:set(key .. "#version", version) end local d = require("apisix.discovery.kubernetes") @@ -309,14 +308,14 @@ skip unknown kubernetes discovery cluster ids: third, service: ns-b/ep:p1 set_endpoints("first", "ns/svc", {p1 = { {host = "10.0.0.1", port = 80, weight = 50}, {host = "10.0.0.9", port = 80, weight = 50}, - }}, "1") + }}) set_endpoints("second", "ns/svc", {p1 = { {host = "10.0.0.2", port = 80, weight = 50}, {host = "10.0.0.9", port = 80, weight = 50}, - }}, "1") + }}) set_endpoints("first", "ns/only-first", {p1 = { {host = "10.0.1.1", port = 80, weight = 50}, - }}, "1") + }}) show("ns/svc:p1", {"first", "second"}) show("ns/svc:p1", {"first"}) @@ -328,7 +327,9 @@ skip unknown kubernetes discovery cluster ids: third, service: ns-b/ep:p1 set_endpoints("second", "ns/svc", {p1 = { {host = "10.0.0.2", port = 80, weight = 50}, {host = "10.0.0.3", port = 80, weight = 50}, - }}, "2") + }}) + -- a worker re-reads the shared dicts once its cache entry expires + ngx.sleep(1.1) show("ns/svc:p1", {"first", "second"}) } } @@ -386,22 +387,21 @@ routes: location /t { content_by_lua_block { local core = require("apisix.core") - local function set_endpoints(id, key, endpoints, version) + local function set_endpoints(id, key, endpoints) local dict = ngx.shared["kubernetes-" .. id] dict:set(key, core.json.encode(endpoints)) - dict:set(key .. "#version", version) end local http = require("resty.http") set_endpoints("first", "ns/svc", {p1 = { {host = "127.0.0.1", port = 1979, weight = 50}, - }}, "1") + }}) set_endpoints("second", "ns/svc", {p1 = { {host = "127.0.0.1", port = 1980, weight = 50}, - }}, "1") + }}) set_endpoints("second", "ns/only-second", {p1 = { {host = "127.0.0.1", port = 1980, weight = 50}, - }}, "1") + }}) local uri = "http://127.0.0.1:" .. ngx.var.server_port for _, path in ipairs({"/hello", "/hello", "/hello", "/hello1", "/hello_chunked"}) do @@ -454,7 +454,6 @@ discovery: dict:set("ns/svc", core.json.encode({p1 = { {host = "10.0.0.1", port = 80, weight = 50}, }})) - dict:set("ns/svc#version", "1") local nodes = d.nodes("ns/svc:p1") ngx.say(#nodes) @@ -511,7 +510,6 @@ routes: dict:set("ns/svc", core.json.encode({p1 = { {host = "127.0.0.1", port = 1980, weight = 50}, }})) - dict:set("ns/svc#version", "1") local uri = "http://127.0.0.1:" .. ngx.var.server_port for _, path in ipairs({"/hello", "/hello1"}) do @@ -556,20 +554,19 @@ stream_routes: local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - local function set_endpoints(id, key, endpoints, version) + local function set_endpoints(id, key, endpoints) local dict = ngx.shared["kubernetes-" .. id .. "-stream"] dict:set(key, core.json.encode(endpoints)) - dict:set(key .. "#version", version) end set_endpoints("first", "ns/svc", {p1 = { {host = "127.0.0.1", port = 1979, weight = 50}, {host = "127.0.0.1", port = 1995, weight = 50}, - }}, "1") + }}) set_endpoints("second", "ns/svc", {p1 = { {host = "127.0.0.1", port = 1995, weight = 50}, {host = "127.0.0.2", port = 1995, weight = 50}, - }}, "1") + }}) local nodes = d.nodes("ns/svc:p1", {cluster_ids = {"first", "second"}}) local addrs = {} diff --git a/t/kubernetes/discovery/kubernetes6.t b/t/kubernetes/discovery/kubernetes6.t new file mode 100644 index 000000000..750362694 --- /dev/null +++ b/t/kubernetes/discovery/kubernetes6.t @@ -0,0 +1,372 @@ +# +# 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. +# + +# End-to-end checks for the kubernetes discovery cache (apache/apisix#14149): +# the Endpoints object is changed through the real apiserver (kubectl proxy +# on 6445) and real requests are sent through APISIX to the fake backend on +# 1980/1981, so every worker's cache is exercised, not just the shared dict. +# +# Kubernetes rejects loopback addresses in Endpoints, so the fake backend is +# addressed through the first global IPv4 address of this host; the backend +# listens on every interface. + +BEGIN { + # listen with reuseport so that the probe connections are spread over + # the four workers instead of landing on whichever worker wakes first + $ENV{TEST_NGINX_REUSE_PORT} = 1; + + our $token_file = "/tmp/var/run/secrets/kubernetes.io/serviceaccount/token"; + our $token_value = eval {`cat $token_file 2>/dev/null`}; + our $backend_ip = eval {`ip -4 -o addr show scope global 2>/dev/null | awk '{print \$4}' | cut -d/ -f1 | head -n 1`}; + chomp($backend_ip) if defined $backend_ip; + + our $yaml_config = <<_EOC_; +apisix: + node_listen: 1984 +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +discovery: + kubernetes: + service: + host: "127.0.0.1" + port: "6443" + ssl_verify: false + client: + token_file: "/tmp/var/run/secrets/kubernetes.io/serviceaccount/token" +_EOC_ +} + +use t::APISIX 'no_plan'; + +repeat_each(1); +log_level('warn'); +no_root_location(); +no_shuffle(); +workers(4); + +add_block_preprocessor(sub { + my ($block) = @_; + + my $apisix_yaml = $block->apisix_yaml // <<_EOC_; +routes: + - + uri: /server_port + upstream: + service_name: ns-a/ep:p1 + discovery_type: kubernetes + type: roundrobin + timeout: + connect: 1 + send: 1 + read: 1 + - + uri: /hello + upstream: + service_name: ns-a/no-such-ep:p1 + discovery_type: kubernetes + type: roundrobin + timeout: + connect: 1 + send: 1 + read: 1 +#END +_EOC_ + + $block->set_value("apisix_yaml", $apisix_yaml); + + my $main_config = $block->main_config // <<_EOC_; +env KUBERNETES_SERVICE_HOST=127.0.0.1; +env KUBERNETES_SERVICE_PORT=6443; +env KUBERNETES_CLIENT_TOKEN=$::token_value; +env KUBERNETES_CLIENT_TOKEN_FILE=$::token_file; +env E2E_BACKEND_IP=$::backend_ip; +_EOC_ + + $block->set_value("main_config", $main_config); + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } + + if (!$block->timeout) { + $block->set_value("timeout", "60"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: a Service published through the apiserver is served by every worker +# The first probes may run before the initial list has finished or hit the +# addresses a previous test file left behind, which logs an [error] for a +# 503, so the default `no_error_log: [error]` would make this block flaky. +--- yaml_config eval: $::yaml_config +--- config + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + + ngx.say("backend ip known: ", t.backend_ip() ~= nil) + local status = t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("published: ", status == 200 or status == 201) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + ngx.say("answers not from 1980 in 40 requests: ", t.count_other_than("1980", 40)) + } + } +--- response_body +backend ip known: true +published: true +serving 1980 within 5s: true +answers not from 1980 in 40 requests: 0 +--- no_error_log +[alert] + + + +=== TEST 2: a change of the Endpoints reaches every worker within the cache ttl +# The first probes may run before the initial list has finished or hit the +# addresses a previous test file left behind, which logs an [error] for a +# 503, so the default `no_error_log: [error]` would make this block flaky. +--- yaml_config eval: $::yaml_config +--- config + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + + ngx.say("patch: ", t.set_subsets(t.port_subsets(1981))) + local elapsed, unexpected = t.wait_for("1981", 5, 5, {["1980"] = true, ["1981"] = true}) + ngx.say("switched to 1981 within 5s: ", elapsed ~= nil) + ngx.say("answers that were neither 1980 nor 1981 during the switch: ", unexpected) + ngx.say("answers not from 1981 in 40 requests: ", t.count_other_than("1981", 40)) + } + } +--- response_body +serving 1980 within 5s: true +patch: 200 +switched to 1981 within 5s: true +answers that were neither 1980 nor 1981 during the switch: 0 +answers not from 1981 in 40 requests: 0 +--- no_error_log +[alert] + + + +=== TEST 3: scaling the Endpoints to zero and back +--- yaml_config eval: $::yaml_config +--- config + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + + ngx.say("patch to empty: ", t.set_subsets({})) + ngx.say("503 within 5s: ", t.wait_for("503", 5, 5) ~= nil) + + ngx.say("patch back: ", t.set_subsets(t.port_subsets(1980))) + ngx.say("serving 1980 again within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + } + } +--- response_body +serving 1980 within 5s: true +patch to empty: 200 +503 within 5s: true +patch back: 200 +serving 1980 again within 5s: true +--- error_log +failed to set upstream + + + +=== TEST 4: deleting and recreating the Endpoints object +--- yaml_config eval: $::yaml_config +--- config + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + + ngx.say("delete: ", t.delete_endpoints()) + ngx.say("503 within 5s: ", t.wait_for("503", 5, 5) ~= nil) + + ngx.say("create: ", t.create_endpoints(t.port_subsets(1980))) + ngx.say("serving 1980 again within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + } + } +--- response_body +serving 1980 within 5s: true +delete: 200 +503 within 5s: true +create: 201 +serving 1980 again within 5s: true +--- error_log +failed to set upstream + + + +=== TEST 5: unknown service, dump without version keys, fixture restored +--- yaml_config eval: $::yaml_config +--- config + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + + ngx.say("unknown service: ", t.get("/hello")) + + local names = t.dump_names() + ngx.say("dump has ns-a/ep: ", names["ns-a/ep"] == true) + + -- the writer is the real privileged agent here, so look at the + -- shared dict itself, not at the dump, which filters version keys + local version_keys = 0 + for _, key in ipairs(ngx.shared["kubernetes"]:get_keys(0)) do + if key:sub(-8) == "#version" then + version_keys = version_keys + 1 + end + end + ngx.say("version keys in the shared dict: ", version_keys) + + local status = t.ensure_endpoints(t.fixture_subsets()) + ngx.say("fixture restored: ", status == 200 or status == 201) + } + } +--- response_body +serving 1980 within 5s: true +unknown service: 503 +dump has ns-a/ep: true +version keys in the shared dict: 0 +fixture restored: true +--- error_log +failed to set upstream + + + +=== TEST 6: every worker resolves the new nodes after a change +# The issue was diagnosed per worker with ngx.worker.id(): one worker kept +# a stale list while the others were fine. /worker_nodes answers from the +# worker that served the request, and the probes are spread over the four +# workers by reuseport, so each worker is checked on its own. The first +# probes may run before the initial list has finished, which logs an +# [error] for a 503, so the default `no_error_log: [error]` would be flaky. +--- yaml_config eval: $::yaml_config +--- config + location /worker_nodes { + content_by_lua_block { + local d = require("apisix.discovery.kubernetes") + local addrs = {} + for _, node in ipairs(d.nodes("ns-a/ep:p1") or {}) do + table.insert(addrs, node.host .. ":" .. node.port) + end + ngx.print(ngx.worker.id(), " ", table.concat(addrs, ",")) + } + } + + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + local ip = t.backend_ip() + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + local ok, seen = t.wait_for_workers(ip .. ":1980", 4, 10) + ngx.say("every worker on 1980: ", ok, ", workers seen: ", seen) + + ngx.say("patch: ", t.set_subsets(t.port_subsets(1981))) + ok, seen = t.wait_for_workers(ip .. ":1981", 4, 10) + ngx.say("every worker on 1981: ", ok, ", workers seen: ", seen) + } + } +--- response_body +serving 1980 within 5s: true +every worker on 1980: true, workers seen: 4 +patch: 200 +every worker on 1981: true, workers seen: 4 +--- no_error_log +[alert] + + + +=== TEST 7: a burst of changes under traffic leaves no worker behind +# The reported incidents followed rollouts and bulk scale-downs: many +# Endpoints changes in a short time while requests keep flowing. The same +# [error] caveat as above applies to the first probes. +--- yaml_config eval: $::yaml_config +--- config + location /worker_nodes { + content_by_lua_block { + local d = require("apisix.discovery.kubernetes") + local addrs = {} + for _, node in ipairs(d.nodes("ns-a/ep:p1") or {}) do + table.insert(addrs, node.host .. ":" .. node.port) + end + ngx.print(ngx.worker.id(), " ", table.concat(addrs, ",")) + } + } + + location /t { + content_by_lua_block { + local t = require("lib.k8s_discovery_e2e").new(ngx.var.server_port) + local ip = t.backend_ip() + + t.ensure_endpoints(t.port_subsets(1980)) + ngx.say("serving 1980 within 5s: ", t.wait_for("1980", 5, 5) ~= nil) + + -- twelve changes back and forth, three requests after each one; + -- the burst ends on 1980 + local allowed = {["1980"] = true, ["1981"] = true} + local patches, unexpected = 0, 0 + for i = 1, 12 do + local port = (i % 2 == 1) and 1981 or 1980 + if t.set_subsets(t.port_subsets(port)) == 200 then + patches = patches + 1 + end + for _ = 1, 3 do + if not allowed[t.get("/server_port")] then + unexpected = unexpected + 1 + end + end + end + ngx.say("patches applied: ", patches) + ngx.say("answers outside 1980 and 1981 during the burst: ", unexpected) + + -- let the last event land and every cache entry expire + ngx.sleep(1.5) + local ok, seen = t.wait_for_workers(ip .. ":1980", 4, 10) + ngx.say("every worker on the final state: ", ok, ", workers seen: ", seen) + ngx.say("answers not from 1980 in 40 requests: ", t.count_other_than("1980", 40)) + } + } +--- response_body +serving 1980 within 5s: true +patches applied: 12 +answers outside 1980 and 1981 during the burst: 0 +every worker on the final state: true, workers seen: 4 +answers not from 1980 in 40 requests: 0 +--- no_error_log +[alert] diff --git a/t/kubernetes/discovery/kubernetes7.t b/t/kubernetes/discovery/kubernetes7.t new file mode 100644 index 000000000..713ee6437 --- /dev/null +++ b/t/kubernetes/discovery/kubernetes7.t @@ -0,0 +1,135 @@ +# +# 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. +# + +# no apiserver listens on 6446, so the tests below own the endpoint dict +BEGIN { + our $offline_yaml_config = <<_EOC_; +apisix: + node_listen: 1984 +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +discovery: + kubernetes: + service: + schema: "http" + host: "127.0.0.1" + port: "6446" + client: + token: "fake" +_EOC_ +} + +use t::APISIX 'no_plan'; + +repeat_each(1); +log_level('warn'); +no_root_location(); +no_shuffle(); + +add_block_preprocessor(sub { + my ($block) = @_; + + my $apisix_yaml = $block->apisix_yaml // <<_EOC_; +routes: [] +#END +_EOC_ + + $block->set_value("apisix_yaml", $apisix_yaml); + + if (!$block->request) { + $block->set_value("request", "GET /t"); + } +}); + +run_tests(); + +__DATA__ + +=== TEST 1: a worker re-reads the shared dict once its cache entry expires (#14149) +# The dict holds one key per service and is the source of truth; there is no +# version key a worker could observe before the content. +--- yaml_config eval: $::offline_yaml_config +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local d = require("apisix.discovery.kubernetes") + local dict = ngx.shared["kubernetes"] + + local function publish(ip) + dict:set("ns/svc", core.json.encode({p1 = { + {host = ip, port = 80, weight = 50}, + }})) + end + local function first_host() + local nodes = d.nodes("ns/svc:p1") + return nodes and nodes[1].host or "nil" + end + + publish("10.0.0.1") + ngx.say(first_host()) + publish("10.0.0.2") + ngx.say(first_host()) + ngx.sleep(1.2) + ngx.say(first_host()) + } + } +--- response_body +10.0.0.1 +10.0.0.1 +10.0.0.2 +--- error_log +connect apiserver failed + + + +=== TEST 2: the writer publishes exactly one key per service +--- yaml_config eval: $::offline_yaml_config +--- config + location /t { + content_by_lua_block { + local k8s_core = require("apisix.discovery.kubernetes.core") + local real_dict = ngx.shared["kubernetes"] + + -- record every write that reaches the shared dict + local writing = {set = true, safe_set = true, add = true, + safe_add = true, replace = true} + local writes = {} + local recording_dict = setmetatable({}, { + __index = function(_, method) + return function(_, key, ...) + if writing[method] then + table.insert(writes, method .. " " .. key) + end + return real_dict[method](real_dict, key, ...) + end + end, + }) + + local ok, err = k8s_core.update_endpoint_dict( + {endpoint_dict = recording_dict}, + {p1 = {{host = "10.0.0.1", port = 80, weight = 50}}}, + "ns/svc2") + ngx.say(ok, " ", err or "", " ", table.concat(writes, ",")) + } + } +--- response_body +true safe_set ns/svc2 +--- error_log +connect apiserver failed diff --git a/t/kubernetes/discovery/stream/kubernetes.t b/t/kubernetes/discovery/stream/kubernetes.t index a9058f55d..e8a9086f4 100644 --- a/t/kubernetes/discovery/stream/kubernetes.t +++ b/t/kubernetes/discovery/stream/kubernetes.t @@ -157,7 +157,7 @@ _EOC_ local core = require("apisix.core") local d = require("apisix.discovery.kubernetes") - ngx.sleep(1) + ngx.sleep(1.1) local sock = ngx.req.socket() local request_body = sock:receive() diff --git a/t/lib/k8s_discovery_e2e.lua b/t/lib/k8s_discovery_e2e.lua new file mode 100644 index 000000000..e49b93603 --- /dev/null +++ b/t/lib/k8s_discovery_e2e.lua @@ -0,0 +1,235 @@ +-- +-- 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. +-- + +-- helpers for t/kubernetes/discovery/kubernetes6.t: change the ns-a/ep +-- Endpoints through the kubectl proxy and send real requests through APISIX + +local core = require("apisix.core") +local http = require("resty.http") +local ngx = ngx +local ipairs = ipairs +local pairs = pairs +local tostring = tostring +local os_getenv = os.getenv + +local _M = {} + +local apiserver = "http://127.0.0.1:6445" +local ep_path = "/api/v1/namespaces/ns-a/endpoints/ep" +local ep_collection = "/api/v1/namespaces/ns-a/endpoints" + + +function _M.new(gateway_port) + local t = {} + local gateway = "http://127.0.0.1:" .. tostring(gateway_port) + + local function k8s(method, path, body, content_type) + local httpc = http.new() + httpc:set_timeouts(5000, 5000, 5000) + local res, err = httpc:request_uri(apiserver .. path, { + method = method, + body = body, + headers = {["Content-Type"] = content_type or "application/json"}, + }) + if not res then + return "err:" .. tostring(err) + end + return res.status + end + + -- Kubernetes rejects loopback addresses in Endpoints, so the fake + -- backend is reached through a global address of this host, exported + -- by the test file as E2E_BACKEND_IP + function t.backend_ip() + local ip = os_getenv("E2E_BACKEND_IP") + if ip == nil or ip == "" then + return nil + end + return ip + end + + function t.port_subsets(port) + return {{ + addresses = {{ip = t.backend_ip() or "127.0.0.1"}}, + ports = {{name = "p1", port = port}}, + }} + end + + -- the subsets kubernetes2.t installs, so later test files find them + function t.fixture_subsets() + return { + { + addresses = {{ip = "10.0.0.1"}, {ip = "10.0.0.2"}}, + ports = {{name = "p1", port = 5001}}, + }, + { + addresses = {{ip = "20.0.0.1"}, {ip = "20.0.0.2"}}, + ports = {{name = "p2", port = 5002}}, + }, + } + end + + function t.set_subsets(subsets) + local patch + if #subsets == 0 then + patch = '[{"op":"replace","path":"/subsets","value":[]}]' + else + patch = core.json.encode({{op = "replace", path = "/subsets", value = subsets}}, true) + end + return k8s("PATCH", ep_path, patch, "application/json-patch+json") + end + + function t.delete_endpoints() + return k8s("DELETE", ep_path) + end + + -- patch the subsets, or create the Endpoints object when it is gone + -- (a previous run may have deleted it); 200 or 201 on success + function t.ensure_endpoints(subsets) + local status = t.set_subsets(subsets) + if status == 404 then + return t.create_endpoints(subsets) + end + return status + end + + function t.create_endpoints(subsets) + local body = core.json.encode({ + apiVersion = "v1", + kind = "Endpoints", + metadata = {name = "ep", namespace = "ns-a"}, + subsets = subsets, + }, true) + return k8s("POST", ep_collection, body) + end + + -- one request on a fresh connection, so that different requests can be + -- served by different workers; returns the body on 200, else the status + function t.get(path) + local httpc = http.new() + -- a probe proxied to a stale, unroutable address must fail fast + httpc:set_timeouts(2000, 2000, 2000) + local res, err = httpc:request_uri(gateway .. path, {keepalive = false}) + if not res then + return "err:" .. tostring(err) + end + if res.status ~= 200 then + return tostring(res.status) + end + return res.body + end + + local function probe() + return t.get("/server_port") + end + + -- polls until n consecutive answers equal expected; returns the seconds + -- it took (nil on timeout) and how many answers were outside allowed + function t.wait_for(expected, n, timeout, allowed) + ngx.update_time() + local start = ngx.now() + local unexpected = 0 + while true do + local ok = true + for _ = 1, n do + local got = probe() + if allowed and not allowed[got] then + unexpected = unexpected + 1 + end + if got ~= expected then + ok = false + break + end + end + ngx.update_time() + local elapsed = ngx.now() - start + if ok then + -- the probes may not have reached every worker yet: give every + -- worker's one second cache entry time to expire before the + -- caller runs its strict checks + ngx.sleep(1.2) + return elapsed, unexpected + end + if elapsed > timeout then + return nil, unexpected + end + ngx.sleep(0.2) + end + end + + -- polls the /worker_nodes location of the test block, which answers + -- "<worker id> <host:port,...>" from the worker that served the request, + -- until every one of n_workers workers has answered and the last answer + -- of each of them equals expected; returns whether that happened and + -- how many distinct workers answered + function t.wait_for_workers(expected, n_workers, timeout) + ngx.update_time() + local start = ngx.now() + local last = {} + while true do + local body = t.get("/worker_nodes") + local id, nodes = body:match("^(%d+) (.*)$") + if id then + last[id] = nodes + end + + local seen, converged = 0, 0 + for _, answer in pairs(last) do + seen = seen + 1 + if answer == expected then + converged = converged + 1 + end + end + if seen >= n_workers and converged == seen then + return true, seen + end + + ngx.update_time() + if ngx.now() - start > timeout then + return false, seen + end + ngx.sleep(0.05) + end + end + + function t.count_other_than(expected, n) + local other = 0 + for _ = 1, n do + if probe() ~= expected then + other = other + 1 + end + end + return other + end + + -- the names in the discovery dump, as a set + function t.dump_names() + local names = {} + local data = require("apisix.discovery.kubernetes").dump_data() + for _, cluster in ipairs(data.endpoints or {}) do + for _, ep in ipairs(cluster.endpoints or {}) do + names[ep.name] = true + end + end + return names + end + + return t +end + + +return _M diff --git a/t/tars/discovery/tars.t b/t/tars/discovery/tars.t index 206e4ccc6..b4a806057 100644 --- a/t/tars/discovery/tars.t +++ b/t/tars/discovery/tars.t @@ -389,3 +389,23 @@ where application = 'A' and server_name = 'AServer' and node_name = '172.16.1.1' [ "{ 1 0 1 1 }\n", ] + + + +=== TEST 12: the shared dict holds one key per servant and no version key +--- timeout: 4 +--- config + location /t { + content_by_lua_block { + ngx.sleep(2) + + -- exactly the servants left by the tests above, nothing else + local keys = ngx.shared["tars"]:get_keys(0) + table.sort(keys) + ngx.say(table.concat(keys, ",")) + } + } +--- request +GET /t +--- response_body +A.AServer.FirstObj,B.BServer.FirstObj,C.CServer.FirstObj
