服務發現是一個古老的話題,當應用開始脫離單機運行和訪問時,服務發現就誕生了。目前的網絡架構是每個主機都有一個獨立的 IP 地址,那麼服務發現基本上都是通過某種方式獲取到服務所部署的 IP 地址。DNS 協議是最早將一個網絡名稱翻譯為網絡 IP 的協議,在最初的架構選型中,DNS+LVS+Nginx 基本可以滿足所有的 RESTful 服務的發現,此時服務的 IP 列表通常配置在 nginx或者 LVS。後來出現了 RPC 服務,服務的上下線更加頻繁,人們開始尋求一種能夠支持動態上下線並且推送 IP 列表變化的註冊中心產品。
----《Nacos架構&原理》
我對於「服務發現」實際運作方式,是從《Nacos架構&原理》一書才有比較清楚的了解。服務發現運作機制上有三個成員:
(參考《Nacos架構&原理》內容後重劃)
DNS伺服器可以視作一個最早、最廣泛使用的服務註冊中心。透過nslookup
工具查看tw.yahoo.com.
服務,這個動作就像是在服務查詢一樣。可能會得到以下結果:
> nslookup tw.yahoo.com
Server: 127.0.0.53
Address: 127.0.0.53#53
Non-authoritative answer:
tw.yahoo.com canonical name = fp-ycpi.g03.yahoodns.net.
Name: fp-ycpi.g03.yahoodns.net
Address: 180.222.106.11
Name: fp-ycpi.g03.yahoodns.net
Address: 180.222.109.252
Name: fp-ycpi.g03.yahoodns.net
Address: 180.222.106.12
Name: fp-ycpi.g03.yahoodns.net
Address: 180.222.109.251
Name: fp-ycpi.g03.yahoodns.net
Address: 2406:2000:a0:807::1
Name: fp-ycpi.g03.yahoodns.net
Address: 2406:2000:9c:800::11
Name: fp-ycpi.g03.yahoodns.net
Address: 2406:2000:9c:800::12
Name: fp-ycpi.g03.yahoodns.net
Address: 2406:2000:a0:807::2
如果服務提供者使用的是固定IP,或許做這麼一次手動設定就好。但通常固定IP使用費用比動態IP昂貴,因此有可能用上動態DNS(DDNS)的機制:
動態DNS(DDNS)的提供者,可能會提供一個程式或腳本,這個程式會定期的向DDNS伺服器回報最新的IP位置。其實這個動作,映射到服務發現的概念,就是「服務註冊」。
Apache APISIX在這個結構中,也可以作爲「消費服務者」。為了知道上游服務代的節點,除了直接以Domain方式設定上游以外,APISIX也支援多種「服務發現」的機制:除了前述提到的DNS外,還有Nacos、Eureka、Consul。
ETCD也是常見於用來提供服務發現的工具之一,但APISIX並不能直接使用,需要多做一些工作才行。
除此之外,也可以自行為APISIX擴充服務發現的能力。譬如近期接觸到的一組軟體架構,其自行實現了服務發現中心,並將資訊儲存與SQL Server之中。前端會透過服務發現知道後端API節點,並實現客戶端的負載平衡。
其服務發現回傳的結構類似下面JSON結果:
[
{
"Id": "02198043-1888-41e4-b055-493fae277f4a",
"Name": "新竹巨城",
"Description": "新竹巨城開始營業",
"Host": "127.0.0.1",
"Port": 8081,
"IsEnabled": true,
"Weight": 10,
"CreatedOn": "2025-01-16T11:02:55.943+08:00",
"CreatedBy": "System",
"ModifiedOn": "2025-08-12T14:54:42.52+08:00",
"ModifiedBy": "System",
"LastServiceHistoryId": "02198043-1888-41e4-b055-493fae277f4a"
},
{
"Id": "5ef3f698-51dd-4a4e-b25c-bd81e2f96e24",
"Name": "新竹SOGO",
"Description": "新竹巨城開始營業",
"Host": "127.0.0.1",
"Port": 8082,
"IsEnabled": true,
"Weight": 10,
"CreatedOn": "2025-01-16T11:02:55.943+08:00",
"CreatedBy": "System",
"ModifiedOn": "2025-08-12T14:54:42.52+08:00",
"ModifiedBy": "System",
"LastServiceHistoryId": "5ef3f698-51dd-4a4e-b25c-bd81e2f96e24"
}
]
可以將其格式轉換成APISIX使用的格式:
[
{
"host": "127.0.0.1",
"port": 8081,
"weight": 100
},
{
"host": "127.0.0.1",
"port": 8082,
"weight": 100
},
]
(圖片來自APISIX文檔)
要擴充APISIX服務發現能力,可以建立discovery/customer01
建立資料夾與相關檔案。當然,因爲使用docker建立服務,也需要將其掛載進容器位置/apisix/discovery/customer01/
:
services:
apisix:
container_name: apisix
volumes:
- ./apisix_config/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./discovery/customer01/:/usr/local/apisix/apisix/discovery/customer01/:ro
在discovery/customer01/init.lua
中的nodes(servcie_name)
函式,其結果需要回傳前述APISIX需求格式:
function _M.nodes(service_name)
local mock = {
{
host = "127.0.0.1",
port = 8081,
weight = 100,
metadata = {
["management.port"] = "8081"
}
}
}
return mock
end
並且可以在init_worker()
建立模組初始化的邏輯:
local _M = {
version = 0.1,
}
function _M.init_worker()
...
end
function _M.dump_data()
return {services = services_dict or {}}
end
return _M
這次設計的邏輯,會在init_worker()
,透過ngx_timer_at()
和ngx_timer_every()
註冊週期性檢查機制:
function _M.init_worker()
default_weight = local_conf.discovery.customer01.weight
services_dict = {}
log.info('default_weight:', default_weight)
local fetch_interval = local_conf.discovery.customer01.fetch_interval
log.info('fetch_interval:', fetch_interval)
ngx_timer_at(0, fetch_full_registry)
ngx_timer_every(fetch_interval, fetch_full_registry)
end
services_dict
是個模組內部變數,其保存了服務發現的結果,譬如:
{
"新竹百貨公司": [
{
"host": "127.0.0.1",
"port": 8081,
"weight": 100
},
{
"host": "127.0.0.1",
"port": 8082,
"weight": 100
},
]
}
並且會透過fetch_full_registry()
更新服務發現的結果:
local function fetch_full_registry(premature)
if premature then
return
end
local up_app = {}
for service_name in pairs(services_dict) do
up_app[service_name] = get_service_nodes(service_name)
end
services_dict = up_app
return up_app
end
get_service_nodes()
是實際取得服務資訊的方法:
local function get_service_nodes(service_name)
local url = get_base_url()
url = url .. ngx.escape_uri(service_name .. '.json')
local res, err = request_get(url) -- 透過「GET /新竹百貨公司.json」方式取得資訊
if err then
log.error(string.format('get servcie nodes failed, %q', err))
return
end
if not res.body or res.status ~= 200 then
log.error(string.format('response is empty, status %d', res.status))
return
end
local json_str = res.body
local data, err = core.json.decode(json_str) -- 將結果從JSON字串轉換成內部物件
if err then
log.error('parse data failed, %q', err)
return
end
----------- section: 將結果轉換成APISIX需求格式 -------------
local targets = {}
services_dict[service_name] = targets
for _i, node in ipairs(data) do
if node.IsEnabled then -- 僅保留啓用的節點
table.insert(targets, {
host = node.Host,
port = node.Port,
weight = node.Weight or default_weight,
-- metadata = {
-- ["management.port"] = node.Port
-- }
})
end
log.info(string.format('[%s]%s({%d}): %s : %s -> %s', node.IsEnabled, service_name, _i, node.Name, node.Description, node.Endpoint))
end
----------- endsection: 將結果轉換成APISIX需求格式 -------------
return targets
end
設計上,會透過GET /<service_name>.json
取得服務資訊,譬如GET /新竹百貨公司.json
。取得的資訊會再轉換成APISIX的需求格式,而這個結果會儲存在services_dict[service_name]
中。
現在可以調整nodes(service_name)
的內容。調整的邏輯很簡單,如果services_dict[servicd_name]
已經有結果了,直接回傳結果;否則註冊服務,並嘗試取得服務資訊:
function _M.nodes(service_name)
if services_dict[servicd_name] then
return services_dict[service_name]
else
return get_service_nodes(service_name)
end
end
最後要提供schema.lua
申明設定格式。其結果是一個符合JSON Type Definition的內容:
local host_pattern = [[^http(s)?:\/\/([a-zA-Z0-9-_.]+:.+\@)?[a-zA-Z0-9-_.:]+$]]
local prefix_pattern = [[^[\/a-zA-Z0-9-_.]+$]]
return {
type = 'object',
properties = {
host = {
type = 'string',
pattern = host_pattern,
maxLength = 500,
},
prefix = {
type = 'string',
pattern = prefix_pattern,
maxLength = 100,
default = '/'
},
fetch_interval = {type = 'integer', minimum = 1, default = 30},
weight = {type = 'integer', minimum = 1, default = 100},
timeout = {
type = 'object',
properties = {
connect = {type = 'integer', minimum = 1, default = 2000},
send = {type = 'integer', minimum = 1, default = 2000},
read = {type = 'integer', minimum = 1, default = 5000},
},
default = {
connect = 2000,
send = 2000,
read = 5000,
}
}
},
required = {'host'}
}
要使用擴充的服務發現機制,需要調整apisix_config/config.yaml
內容:
discovery:
customer01:
host: http://127.0.0.1:8090
prefix: '/'
fetch_interval: 30
weight: 100
timeout:
connect: 2000
send: 2000
read: 5000
discovery.customer01
所設定的屬性,就是在discovery/customer01/schema.lua
所申明的結構。
然後調整Upstream設定:
{
"timeout": {
"connect": 6,
"send": 6,
"read": 6
},
"type": "roundrobin",
"scheme": "http",
"discovery_type": "customer01",
"pass_host": "pass",
"name": "新竹百貨公司",
"service_name": "新竹百貨公司",
"keepalive_pool": {
"idle_timeout": 60,
"requests": 1000,
"size": 320
}
}
特別留意discovery_type
和service_name
。分別申明了使用的服務發現機制和服務名稱:
discovery_type: customer01
service_name: 新竹百貨公司
最後要實現服務發現中心:同樣透過http-server ./ --port 8090
簡單實現,並提供新竹百貨公司.json
檔案為服務資訊。現在同樣瀏覽「新竹百貨公司」頁面,會發現沒什麼變化。但是僅透過調整新竹百貨公司.json
檔案內容,就可以增加或減少上游的節點。
當然,正常的服務發現中心,一般不會簡單使用檔案儲存資訊XD。
完整程式碼可以在GitHub Gist找到。