Files
2026-07-13 12:32:21 +08:00

632 lines
17 KiB
Lua

local _M = {}
local _MT = { __index = _M, }
local semaphore = require("ngx.semaphore")
local declarative = require("kong.db.declarative")
local clustering_utils = require("kong.clustering.utils")
local compat = require("kong.clustering.compat")
local constants = require("kong.constants")
local events = require("kong.clustering.events")
local calculate_config_hash = require("kong.clustering.config_helper").calculate_config_hash
local EMPTY = require("kong.tools.table").EMPTY
local string = string
local setmetatable = setmetatable
local type = type
local pcall = pcall
local pairs = pairs
local ngx = ngx
local ngx_log = ngx.log
local timer_at = ngx.timer.at
local json_decode = clustering_utils.json_decode
local json_encode = clustering_utils.json_encode
local kong = kong
local ngx_exit = ngx.exit
local exiting = ngx.worker.exiting
local worker_id = ngx.worker.id
local ngx_time = ngx.time
local ngx_now = ngx.now
local ngx_var = ngx.var
local table_insert = table.insert
local table_remove = table.remove
local sub = string.sub
local isempty = require("table.isempty")
local sleep = ngx.sleep
local now_updated = require("kong.tools.time").get_updated_now
local plugins_list_to_map = compat.plugins_list_to_map
local update_compatible_payload = compat.update_compatible_payload
local check_mixed_route_entities = compat.check_mixed_route_entities
local deflate_gzip = require("kong.tools.gzip").deflate_gzip
local yield = require("kong.tools.yield").yield
local connect_dp = clustering_utils.connect_dp
local kong_dict = ngx.shared.kong
local ngx_DEBUG = ngx.DEBUG
local ngx_NOTICE = ngx.NOTICE
local ngx_WARN = ngx.WARN
local ngx_ERR = ngx.ERR
local ngx_OK = ngx.OK
local ngx_ERROR = ngx.ERROR
local ngx_CLOSE = ngx.HTTP_CLOSE
local PING_WAIT = constants.CLUSTERING_PING_INTERVAL * 1.5
local CLUSTERING_SYNC_STATUS = constants.CLUSTERING_SYNC_STATUS
local DECLARATIVE_EMPTY_CONFIG_HASH = constants.DECLARATIVE_EMPTY_CONFIG_HASH
local PONG_TYPE = "PONG"
local RECONFIGURE_TYPE = "RECONFIGURE"
local _log_prefix = "[clustering] "
local no_connected_clients_logged
local function handle_export_deflated_reconfigure_payload(self)
ngx_log(ngx_DEBUG, _log_prefix, "exporting config")
local ok, p_err, err = pcall(self.export_deflated_reconfigure_payload, self)
return ok, p_err or err
end
local function is_timeout(err)
return err and sub(err, -7) == "timeout"
end
local function is_closed(err)
return err and sub(err, -6) == "closed"
end
local function extract_dp_cert(cert)
local expiry_timestamp = cert:get_not_after()
-- values in cert_details must be strings
local cert_details = {
expiry_timestamp = expiry_timestamp,
}
return cert_details
end
function _M.new(clustering)
assert(type(clustering) == "table",
"kong.clustering is not instantiated")
assert(type(clustering.conf) == "table",
"kong.clustering did not provide configuration")
local self = {
clients = setmetatable({}, { __mode = "k", }),
plugins_map = {},
conf = clustering.conf,
}
return setmetatable(self, _MT)
end
function _M:export_deflated_reconfigure_payload()
local config_table, err = declarative.export_config()
if not config_table then
return nil, err
end
-- update plugins map
self.plugins_configured = {}
if config_table.plugins then
for _, plugin in pairs(config_table.plugins) do
self.plugins_configured[plugin.name] = true
end
end
-- store serialized plugins map for troubleshooting purposes
local shm_key_name = "clustering:cp_plugins_configured:worker_" .. (worker_id() or -1)
kong_dict:set(shm_key_name, json_encode(self.plugins_configured))
kong.log.trace(_log_prefix, "plugin configuration map key: ", shm_key_name, " configuration: ", kong_dict:get(shm_key_name))
local config_hash, hashes = calculate_config_hash(config_table)
local payload = {
type = "reconfigure",
timestamp = ngx_now(),
config_table = config_table,
config_hash = config_hash,
hashes = hashes,
}
self.reconfigure_payload = payload
payload, err = json_encode(payload)
if not payload then
return nil, err
end
yield()
payload, err = deflate_gzip(payload)
if not payload then
return nil, err
end
yield()
self.current_hashes = hashes
self.current_config_hash = config_hash
self.deflated_reconfigure_payload = payload
return payload, nil, config_hash
end
function _M:push_config()
local start = ngx_now()
local payload, err = self:export_deflated_reconfigure_payload()
if not payload then
ngx_log(ngx_ERR, _log_prefix, "unable to export config from database: ", err)
return
end
local n = 0
for _, queue in pairs(self.clients) do
table_insert(queue, RECONFIGURE_TYPE)
queue.post()
n = n + 1
end
local duration = now_updated() - start
ngx_log(ngx_DEBUG, _log_prefix, "config pushed to ", n, " data-plane nodes in ", duration, " seconds")
end
_M.check_version_compatibility = compat.check_version_compatibility
_M.check_configuration_compatibility = compat.check_configuration_compatibility
function _M:handle_cp_websocket(cert)
local dp_id = ngx_var.arg_node_id
local dp_hostname = ngx_var.arg_node_hostname
local dp_ip = ngx_var.remote_addr
local dp_version = ngx_var.arg_node_version
local wb, log_suffix, ec = connect_dp(dp_id, dp_hostname, dp_ip, dp_version)
if not wb then
return ngx_exit(ec)
end
-- connection established
-- receive basic info
local data, typ, err
data, typ, err = wb:recv_frame()
if err then
err = "failed to receive websocket basic info frame: " .. err
elseif typ == "binary" then
if not data then
err = "failed to receive websocket basic info data"
else
data, err = json_decode(data)
if type(data) ~= "table" then
err = "failed to decode websocket basic info data" ..
(err and ": " .. err or "")
else
if data.type ~= "basic_info" then
err = "invalid basic info data type: " .. (data.type or "unknown")
else
if type(data.plugins) ~= "table" then
err = "missing plugins in basic info data"
end
end
end
end
end
if err then
ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
wb:send_close()
return ngx_exit(ngx_CLOSE)
end
local dp_cert_details = extract_dp_cert(cert)
local dp_plugins_map = plugins_list_to_map(data.plugins)
local config_hash = DECLARATIVE_EMPTY_CONFIG_HASH -- initial hash
local last_seen = ngx_time()
local sync_status = CLUSTERING_SYNC_STATUS.UNKNOWN
local purge_delay = self.conf.cluster_data_plane_purge_delay
local update_sync_status = function()
local pk = { id = dp_id }
local rpc_capabilities
if self.conf.cluster_rpc then
-- rpc framework should update rpc_capabilities when connecting
local res, err = kong.db.clustering_data_planes:select(pk)
if err then
ngx_log(ngx_ERR, "unable to update clustering data plane status, select(",
dp_id, ") failed: ", err, log_suffix)
return
end
-- do not overwrite rpc_capabilities field
rpc_capabilities = res and res.rpc_capabilities
end
local ok
ok, err = kong.db.clustering_data_planes:upsert(pk, {
last_seen = last_seen,
config_hash = config_hash ~= ""
and config_hash
or DECLARATIVE_EMPTY_CONFIG_HASH,
hostname = dp_hostname,
ip = dp_ip,
version = dp_version,
sync_status = sync_status, -- TODO: import may have been failed though
labels = data.labels,
cert_details = dp_cert_details,
rpc_capabilities = rpc_capabilities or EMPTY, -- should be a list or empty
}, { ttl = purge_delay, no_broadcast_crud_event = true, })
if not ok then
ngx_log(ngx_ERR, _log_prefix, "unable to update clustering data plane status: ", err, log_suffix)
end
end
local _
_, err, sync_status = self:check_version_compatibility({
dp_version = dp_version,
dp_plugins_map = dp_plugins_map,
log_suffix = log_suffix,
})
if err then
ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
wb:send_close()
update_sync_status()
return ngx_exit(ngx_CLOSE)
end
ngx_log(ngx_DEBUG, _log_prefix, "data plane connected", log_suffix)
local queue
do
local queue_semaphore = semaphore.new()
queue = {
wait = function(...)
return queue_semaphore:wait(...)
end,
post = function(...)
return queue_semaphore:post(...)
end
}
end
-- if clients table is empty, we might have skipped some config
-- push event in `push_config_loop`, which means the cached config
-- might be stale, so we always export the latest config again in this case
if isempty(self.clients) or not self.deflated_reconfigure_payload then
_, err = handle_export_deflated_reconfigure_payload(self)
end
self.clients[wb] = queue
if self.deflated_reconfigure_payload then
-- initial configuration compatibility for sync status variable
_, _, sync_status = self:check_configuration_compatibility({
dp_plugins_map = dp_plugins_map,
filters = data.filters,
})
table_insert(queue, RECONFIGURE_TYPE)
queue.post()
else
ngx_log(ngx_ERR, _log_prefix, "unable to send initial configuration to data plane: ", err, log_suffix)
end
-- how control plane connection management works:
-- two threads are spawned, when any of these threads exits,
-- it means a fatal error has occurred on the connection,
-- and the other thread is also killed
--
-- * read_thread: it is the only thread that receives websocket frames from the
-- data plane and records the current data plane status in the
-- database, and is also responsible for handling timeout detection
-- * write_thread: it is the only thread that sends websocket frames to the data plane
-- by grabbing any messages currently in the send queue and
-- send them to the data plane in a FIFO order. Notice that the
-- PONG frames are also sent by this thread after they are
-- queued by the read_thread
local read_thread = ngx.thread.spawn(function()
while not exiting() do
local data, typ, err = wb:recv_frame()
if exiting() then
return
end
if err then
if not is_timeout(err) then
return nil, err
end
local waited = ngx_time() - last_seen
if waited > PING_WAIT then
return nil, "did not receive ping frame from data plane within " ..
PING_WAIT .. " seconds"
end
-- timeout
goto continue
end
if typ == "close" then
ngx_log(ngx_DEBUG, _log_prefix, "received close frame from data plane", log_suffix)
return
end
if not data then
return nil, "did not receive ping frame from data plane"
elseif #data ~= 32 then
return nil, "received a ping frame from the data plane with an invalid"
.. " hash: '" .. tostring(data) .. "'"
end
-- dps only send pings
if typ ~= "ping" then
return nil, "invalid websocket frame received from data plane: " .. typ
end
kong.log.trace(_log_prefix, "received ping frame from data plane", log_suffix)
config_hash = data
last_seen = ngx_time()
update_sync_status()
-- queue PONG to avoid races
table_insert(queue, PONG_TYPE)
queue.post()
::continue::
end
end)
local write_thread = ngx.thread.spawn(function()
while not exiting() do
local ok, err = queue.wait(5)
if exiting() then
return
end
if not ok then
if err ~= "timeout" then
return nil, "semaphore wait error: " .. err
end
-- timeout
goto continue
end
local payload = table_remove(queue, 1)
if not payload then
return nil, "config queue can not be empty after semaphore returns"
end
if payload == PONG_TYPE then
local _, err = wb:send_pong()
if err then
if not is_timeout(err) then
return nil, "failed to send pong frame to data plane: " .. err
end
ngx_log(ngx_NOTICE, _log_prefix, "failed to send pong frame to data plane: ", err, log_suffix)
else
kong.log.trace(_log_prefix, "sent pong frame to data plane", log_suffix)
end
-- pong ok
goto continue
end
-- is reconfigure
assert(payload == RECONFIGURE_TYPE)
local previous_sync_status = sync_status
ok, err, sync_status = self:check_configuration_compatibility({
dp_plugins_map = dp_plugins_map,
filters = data.filters,
})
if not ok then
ngx_log(ngx_WARN, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)
if sync_status ~= previous_sync_status then
update_sync_status()
end
goto continue
end
ok, err = check_mixed_route_entities(self.reconfigure_payload, dp_version,
kong and kong.configuration and
kong.configuration.router_flavor)
if not ok then
ngx_log(ngx_WARN, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)
goto continue
end
local _, deflated_payload, err = update_compatible_payload(self.reconfigure_payload, dp_version, log_suffix)
if not deflated_payload then -- no modification or err, use the cached payload
deflated_payload = self.deflated_reconfigure_payload
end
if err then
ngx_log(ngx_WARN, "unable to update compatible payload: ", err, ", the unmodified config ",
"is returned", log_suffix)
end
-- config update
local _, err = wb:send_binary(deflated_payload)
if err then
if not is_timeout(err) then
return nil, "unable to send updated configuration to data plane: " .. err
end
ngx_log(ngx_NOTICE, _log_prefix, "unable to send updated configuration to data plane: ", err, log_suffix)
else
ngx_log(ngx_DEBUG, _log_prefix, "sent config update to data plane", log_suffix)
end
::continue::
end
end)
local ok, err, perr = ngx.thread.wait(write_thread, read_thread)
self.clients[wb] = nil
ngx.thread.kill(write_thread)
ngx.thread.kill(read_thread)
wb:send_close()
--TODO: should we update disconnect data plane status?
--sync_status = CLUSTERING_SYNC_STATUS.UNKNOWN
--update_sync_status()
if not ok then
ngx_log(ngx_ERR, _log_prefix, err, log_suffix)
return ngx_exit(ngx_ERROR)
end
if perr then
if is_closed(perr) then
ngx_log(ngx_DEBUG, _log_prefix, "data plane closed the connection", log_suffix)
else
ngx_log(ngx_ERR, _log_prefix, perr, log_suffix)
end
return ngx_exit(ngx_ERROR)
end
return ngx_exit(ngx_OK)
end
local function push_config_loop(premature, self, push_config_semaphore, delay)
if premature then
return
end
local ok, err = handle_export_deflated_reconfigure_payload(self)
if not ok then
ngx_log(ngx_ERR, _log_prefix, "unable to export initial config from database: ", err)
end
while not exiting() do
local ok, err = push_config_semaphore:wait(1)
if exiting() then
return
end
if not ok then
if err ~= "timeout" then
ngx_log(ngx_ERR, _log_prefix, "semaphore wait error: ", err)
end
goto continue
end
if isempty(self.clients) then
if not no_connected_clients_logged then
ngx_log(ngx_DEBUG, _log_prefix, "skipping config push (no connected clients)")
no_connected_clients_logged = true
end
sleep(1)
-- re-queue the task. wait until we have clients connected
if push_config_semaphore:count() <= 0 then
push_config_semaphore:post()
end
goto continue
end
no_connected_clients_logged = nil
ok, err = pcall(self.push_config, self)
if not ok then
ngx_log(ngx_ERR, _log_prefix, "export and pushing config failed: ", err)
goto continue
end
-- push_config ok, waiting for a while
local sleep_left = delay
while sleep_left > 0 do
if sleep_left <= 1 then
sleep(sleep_left)
break
end
sleep(1)
if exiting() then
return
end
sleep_left = sleep_left - 1
end
::continue::
end
end
function _M:init_worker(basic_info)
-- ROLE = "control_plane"
local plugins_list = basic_info.plugins
self.plugins_list = plugins_list
self.plugins_map = plugins_list_to_map(plugins_list)
self.deflated_reconfigure_payload = nil
self.reconfigure_payload = nil
self.plugins_configured = {}
self.plugin_versions = {}
for i = 1, #plugins_list do
local plugin = plugins_list[i]
self.plugin_versions[plugin.name] = plugin.version
end
self.filters = basic_info.filters
local push_config_semaphore = semaphore.new()
-- When "clustering", "push_config" worker event is received by a worker,
-- it loads and pushes the config to its the connected data planes
events.clustering_push_config(function(_)
if push_config_semaphore:count() <= 0 then
-- the following line always executes immediately after the `if` check
-- because `:count` will never yield, end result is that the semaphore
-- count is guaranteed to not exceed 1
push_config_semaphore:post()
end
end)
timer_at(0, push_config_loop, self, push_config_semaphore,
self.conf.db_update_frequency)
end
return _M