Skip to content

Commit b349536

Browse files
authored
fix: clean every checker each window and release periodic lock when idle (#55)
1 parent 3a6ddf0 commit b349536

15 files changed

Lines changed: 820 additions & 38 deletions

lib/resty/healthcheck.lua

Lines changed: 67 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ local hcs = setmetatable({}, {
236236
})
237237

238238
local active_check_timer
239-
local last_cleanup_check
240239

241240
-- serialize a table to a string
242241
local serialize = codec.encode
@@ -1726,26 +1725,36 @@ function _M.new(opts)
17261725
self:log(DEBUG, "worker ", ngx_worker_id(), " (pid: ", ngx_worker_pid(), ") ",
17271726
"starting active check timer")
17281727
local shm, key = self.shm, self.PERIODIC_LOCK
1729-
last_cleanup_check = ngx_now()
1728+
local cleanup_key = key .. ":cleanup"
17301729
active_check_timer, err = resty_timer({
17311730
recurring = true,
17321731
interval = CHECK_INTERVAL,
17331732
jitter = CHECK_JITTER,
17341733
detached = false,
17351734
expire = function()
17361735

1737-
if get_periodic_lock(shm, key) then
1738-
active_check_timer.interval = CHECK_INTERVAL
1739-
renew_periodic_lock(shm, key)
1740-
else
1741-
active_check_timer.interval = CHECK_INTERVAL * 10
1742-
return
1743-
end
1744-
17451736
local cur_time = ngx_now()
1746-
for _, checker_obj in pairs(hcs) do
17471737

1748-
if (last_cleanup_check + CLEANUP_INTERVAL) < cur_time then
1738+
-- Stale-target cleanup is decoupled from active probing and the
1739+
-- periodic lock. A passive-only deployment (checks.passive but no
1740+
-- active interval) has no active checker on any worker, yet still marks
1741+
-- targets via delayed_clear() and must purge them; gating cleanup on
1742+
-- the periodic lock (which only an active worker ever holds) would leak
1743+
-- those targets forever (apache/apisix#13385).
1744+
--
1745+
-- A single worker per window is elected via an atomic shm:add on
1746+
-- cleanup_key (TTL = CLEANUP_INTERVAL): any worker can win, including a
1747+
-- passive-only one, so the leak fix holds, while the other workers skip
1748+
-- the pass and do not all contend on each checker's TARGET_LIST_LOCK.
1749+
-- The elected worker purges every checker in its own `hcs` in one pass.
1750+
-- Purging writes to the shared shm target_list, so cleaning a checker on
1751+
-- any one worker is globally effective; if checkers are distributed
1752+
-- asymmetrically across workers, a given upstream is purged in whichever
1753+
-- window a worker that owns its checker wins the election -- eventually
1754+
-- consistent rather than every-worker-every-window.
1755+
local won, add_err = shm:add(cleanup_key, ngx_worker_pid(), CLEANUP_INTERVAL)
1756+
if won then
1757+
for _, checker_obj in pairs(hcs) do
17491758
-- clear targets marked for delayed removal
17501759
locking_target_list(checker_obj, function(target_list)
17511760
local removed_targets = {}
@@ -1774,10 +1783,45 @@ function _M.new(opts)
17741783
end
17751784
end
17761785
end)
1786+
end
1787+
elseif add_err ~= "exists" then
1788+
ngx_log(ERR, "failed to elect cleanup worker for '", cleanup_key, "': ", add_err)
1789+
end
1790+
1791+
-- The periodic lock distributes ACTIVE probing to a single worker. A
1792+
-- worker with no active checker must release the lock (if it holds it)
1793+
-- and back off, so a worker that still owns active checkers can take
1794+
-- over; otherwise active health checks stay stuck after a disable ->
1795+
-- re-enable cycle (apache/apisix#13235). This is gated on
1796+
-- has_active_checker rather than on `hcs` being non-empty, because a
1797+
-- disabled checker lingers in the weak `hcs` table with active=false
1798+
-- until it is garbage collected.
1799+
local has_active_checker = false
1800+
for _, checker_obj in pairs(hcs) do
1801+
if checker_obj.checks.active.healthy.active or
1802+
checker_obj.checks.active.unhealthy.active then
1803+
has_active_checker = true
1804+
break
1805+
end
1806+
end
17771807

1778-
last_cleanup_check = cur_time
1808+
if not has_active_checker then
1809+
-- release the lock only if we are still the holder, so a worker that
1810+
-- still owns active checkers can take over
1811+
if shm:get(key) == ngx_worker_pid() then
1812+
shm:delete(key)
17791813
end
1814+
return
1815+
end
17801816

1817+
if get_periodic_lock(shm, key) then
1818+
renew_periodic_lock(shm, key)
1819+
else
1820+
return
1821+
end
1822+
1823+
-- active probing: only the periodic-lock holder runs the probes
1824+
for _, checker_obj in pairs(hcs) do
17811825
if checker_obj.checks.active.healthy.active and
17821826
(checker_obj.checks.active.healthy.last_run +
17831827
checker_obj.checks.active.healthy.interval <= cur_time)
@@ -1868,4 +1912,14 @@ function _M.get_target_list(name, shm_name)
18681912
end
18691913

18701914

1915+
if TESTING then
1916+
-- test-only hook: shorten the stale-target cleanup window so the periodic
1917+
-- cleanup path can be exercised deterministically without waiting for the
1918+
-- default CLEANUP_INTERVAL (CHECK_INTERVAL * 25).
1919+
function _M._set_cleanup_interval(interval)
1920+
CLEANUP_INTERVAL = interval
1921+
end
1922+
end
1923+
1924+
18711925
return _M

t/with_resty-events/14-tls_active_probes.t

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ plan tests => blocks() * 2;
88
my $pwd = cwd();
99
$ENV{TEST_NGINX_SERVROOT} = server_root();
1010

11+
# A local self-signed TLS server is used instead of an external host so that
12+
# the active TLS probe tests are deterministic and do not depend on network
13+
# access. The certificate has CN/SAN "example.test"; probing with that
14+
# hostname (SNI) and certificate verification enabled succeeds, while probing
15+
# with a mismatching hostname and verification enabled fails.
16+
$ENV{TEST_NGINX_TLS_CERT} = "$pwd/t/with_resty-events/util/tls_probe_cert.pem";
17+
$ENV{TEST_NGINX_TLS_KEY} = "$pwd/t/with_resty-events/util/tls_probe_key.pem";
18+
1119
our $HttpConfig = qq{
1220
lua_package_path "$pwd/lib/?.lua;;";
1321
lua_shared_dict test_shm 8m;
@@ -22,6 +30,16 @@ our $HttpConfig = qq{
2230
}
2331
}
2432
}
33+
34+
server {
35+
listen 2115 ssl;
36+
ssl_certificate $ENV{TEST_NGINX_TLS_CERT};
37+
ssl_certificate_key $ENV{TEST_NGINX_TLS_KEY};
38+
server_name example.test;
39+
location / {
40+
return 200 'ok';
41+
}
42+
}
2543
};
2644

2745
run_tests();
@@ -34,7 +52,7 @@ __DATA__
3452
--- http_config eval: $::HttpConfig
3553
--- config
3654
location = /t {
37-
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
55+
lua_ssl_trusted_certificate $TEST_NGINX_TLS_CERT;
3856
lua_ssl_verify_depth 2;
3957
content_by_lua_block {
4058
local we = require "resty.events.compat"
@@ -43,7 +61,7 @@ __DATA__
4361
local checker = healthcheck.new({
4462
name = "testing",
4563
shm_name = "test_shm",
46-
events_module = "resty.events",
64+
events_module = "resty.events",
4765
checks = {
4866
active = {
4967
type = "https",
@@ -59,9 +77,9 @@ events_module = "resty.events",
5977
},
6078
}
6179
})
62-
local ok, err = checker:add_target("104.154.89.105", 443, "badssl.com", false)
80+
local ok, err = checker:add_target("127.0.0.1", 2115, "example.test", false)
6381
ngx.sleep(8) -- wait for 4x the check interval
64-
ngx.say(checker:get_target_status("104.154.89.105", 443, "badssl.com")) -- true
82+
ngx.say(checker:get_target_status("127.0.0.1", 2115, "example.test")) -- true
6583
}
6684
}
6785
--- request
@@ -75,7 +93,7 @@ true
7593
--- http_config eval: $::HttpConfig
7694
--- config
7795
location = /t {
78-
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
96+
lua_ssl_trusted_certificate $TEST_NGINX_TLS_CERT;
7997
lua_ssl_verify_depth 2;
8098
content_by_lua_block {
8199
local we = require "resty.events.compat"
@@ -84,7 +102,7 @@ true
84102
local checker = healthcheck.new({
85103
name = "testing",
86104
shm_name = "test_shm",
87-
events_module = "resty.events",
105+
events_module = "resty.events",
88106
checks = {
89107
active = {
90108
type = "https",
@@ -100,9 +118,9 @@ events_module = "resty.events",
100118
},
101119
}
102120
})
103-
local ok, err = checker:add_target("104.154.89.105", 443, "wrong.host.badssl.com", true)
121+
local ok, err = checker:add_target("127.0.0.1", 2115, "wrong.host.test", true)
104122
ngx.sleep(8) -- wait for 4x the check interval
105-
ngx.say(checker:get_target_status("104.154.89.105", 443, "wrong.host.badssl.com")) -- false
123+
ngx.say(checker:get_target_status("127.0.0.1", 2115, "wrong.host.test")) -- false
106124
}
107125
}
108126
--- request
@@ -116,7 +134,7 @@ false
116134
--- http_config eval: $::HttpConfig
117135
--- config
118136
location = /t {
119-
lua_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
137+
lua_ssl_trusted_certificate $TEST_NGINX_TLS_CERT;
120138
lua_ssl_verify_depth 2;
121139
content_by_lua_block {
122140
local we = require "resty.events.compat"
@@ -125,7 +143,7 @@ false
125143
local checker = healthcheck.new({
126144
name = "testing",
127145
shm_name = "test_shm",
128-
events_module = "resty.events",
146+
events_module = "resty.events",
129147
checks = {
130148
active = {
131149
type = "https",
@@ -142,9 +160,9 @@ events_module = "resty.events",
142160
},
143161
}
144162
})
145-
local ok, err = checker:add_target("104.154.89.105", 443, "wrong.host.badssl.com", false)
163+
local ok, err = checker:add_target("127.0.0.1", 2115, "wrong.host.test", false)
146164
ngx.sleep(8) -- wait for 4x the check interval
147-
ngx.say(checker:get_target_status("104.154.89.105", 443, "wrong.host.badssl.com")) -- true
165+
ngx.say(checker:get_target_status("127.0.0.1", 2115, "wrong.host.test")) -- true
148166
}
149167
}
150168
--- request
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
use Test::Nginx::Socket::Lua;
2+
use Cwd qw(cwd);
3+
4+
workers(1);
5+
6+
plan tests => repeat_each() * (blocks() * 2);
7+
8+
my $pwd = cwd();
9+
$ENV{TEST_NGINX_SERVROOT} = server_root();
10+
11+
our $HttpConfig = qq{
12+
lua_package_path "$pwd/lib/?.lua;;";
13+
lua_shared_dict test_shm 8m;
14+
15+
init_worker_by_lua_block {
16+
_G.__TESTING_HEALTHCHECKER = true
17+
local we = require "resty.events.compat"
18+
assert(we.configure({
19+
unique_timeout = 5,
20+
broker_id = 0,
21+
listening = "unix:$ENV{TEST_NGINX_SERVROOT}/worker_events.sock"
22+
}))
23+
assert(we.configured())
24+
}
25+
26+
server {
27+
server_name kong_worker_events;
28+
listen unix:$ENV{TEST_NGINX_SERVROOT}/worker_events.sock;
29+
access_log off;
30+
location / {
31+
content_by_lua_block {
32+
require("resty.events.compat").run()
33+
}
34+
}
35+
}
36+
};
37+
38+
run_tests();
39+
40+
__DATA__
41+
42+
=== TEST 1: stale-target cleanup runs for every checker, not just the first one
43+
--- http_config eval: $::HttpConfig
44+
--- config
45+
location = /t {
46+
content_by_lua_block {
47+
local healthcheck = require("resty.healthcheck")
48+
-- shorten the cleanup window so it fires within the test
49+
healthcheck._set_cleanup_interval(0.2)
50+
51+
local function new_checker(name)
52+
return assert(healthcheck.new({
53+
name = name,
54+
shm_name = "test_shm",
55+
events_module = "resty.events",
56+
checks = {
57+
active = {
58+
type = "tcp",
59+
healthy = { interval = 0.1 },
60+
unhealthy = { interval = 0.1 },
61+
}
62+
}
63+
}))
64+
end
65+
66+
-- two upstreams, each with health checks enabled
67+
local checker1 = new_checker("upstream-1")
68+
local checker2 = new_checker("upstream-2")
69+
70+
for i = 1, 3 do
71+
assert(checker1:add_target("127.0.0.1", 20000 + i, nil, true))
72+
assert(checker2:add_target("127.0.0.1", 30000 + i, nil, true))
73+
end
74+
75+
-- mark every target on both checkers for immediate delayed removal
76+
assert(checker1:delayed_clear(0))
77+
assert(checker2:delayed_clear(0))
78+
79+
-- wait long enough for several cleanup windows to elapse
80+
ngx.sleep(1.5)
81+
82+
local list1 = healthcheck.get_target_list("upstream-1", "test_shm")
83+
local list2 = healthcheck.get_target_list("upstream-2", "test_shm")
84+
85+
-- Before the fix only the first checker in `hcs` is cleaned each
86+
-- window, so checker2 keeps its purge-marked targets forever.
87+
ngx.say("checker1 remaining: ", #list1)
88+
ngx.say("checker2 remaining: ", #list2)
89+
}
90+
}
91+
--- request
92+
GET /t
93+
--- response_body
94+
checker1 remaining: 0
95+
checker2 remaining: 0
96+
--- timeout: 5

0 commit comments

Comments
 (0)