From 35bf3c73fc46745e3da74d9ae82293d8757be6d9 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Mon, 16 Mar 2020 23:21:58 +0800 Subject: [PATCH 01/16] add eureka --- .travis/linux_openresty_runner.sh | 8 ++ .travis/linux_tengine_runner.sh | 7 + .travis/osx_openresty_runner.sh | 6 +- Makefile | 3 + apisix/balancer.lua | 91 +++++++++---- apisix/core/table.lua | 1 + apisix/discovery/eureka.lua | 217 ++++++++++++++++++++++++++++++ apisix/discovery/init.lua | 33 +++++ apisix/http/service.lua | 46 ++++++- apisix/init.lua | 101 +++++++------- apisix/router.lua | 46 +++++-- apisix/schema_def.lua | 44 +++++- conf/config.yaml | 12 +- doc/discovery-cn.md | 195 +++++++++++++++++++++++++++ doc/discovery.md | 187 +++++++++++++++++++++++++ doc/images/discovery-cn.png | Bin 0 -> 42581 bytes doc/images/discovery.png | Bin 0 -> 46310 bytes t/admin/balancer.t | 2 + t/admin/upstream.t | 6 +- t/apisix.luacov | 1 + t/discovery/eureka.t | 66 +++++++++ t/node/upstream-array-nodes.t | 104 ++++++++++++++ 22 files changed, 1076 insertions(+), 100 deletions(-) create mode 100644 apisix/discovery/eureka.lua create mode 100644 apisix/discovery/init.lua create mode 100644 doc/discovery-cn.md create mode 100644 doc/discovery.md create mode 100644 doc/images/discovery-cn.png create mode 100644 doc/images/discovery.png create mode 100644 t/discovery/eureka.t create mode 100644 t/node/upstream-array-nodes.t diff --git a/.travis/linux_openresty_runner.sh b/.travis/linux_openresty_runner.sh index 384d10ec4a82..de1153c3c20a 100755 --- a/.travis/linux_openresty_runner.sh +++ b/.travis/linux_openresty_runner.sh @@ -117,6 +117,13 @@ do_install() { tar -xvf grpcurl-amd64.tar.gz mv grpcurl build-cache/ fi + + if [ ! -f "build-cache/eureka" ]; then + wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz + tar -xvf eureka.tar.gz + mv eureka.jar build-cache/ + fi + } script() { @@ -126,6 +133,7 @@ script() { sudo service etcd start ./build-cache/grpc_server_example & + java -jar ./build-cache/eureka.jar > ./build-cache/eureka.log 2>&1 & ./bin/apisix help ./bin/apisix init diff --git a/.travis/linux_tengine_runner.sh b/.travis/linux_tengine_runner.sh index 45a9ec448e29..85733d933c67 100755 --- a/.travis/linux_tengine_runner.sh +++ b/.travis/linux_tengine_runner.sh @@ -260,6 +260,12 @@ do_install() { tar -xvf grpc_server_example-amd64.tar.gz mv grpc_server_example build-cache/ fi + + if [ ! -f "build-cache/eureka" ]; then + wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz + tar -xvf eureka.tar.gz + mv eureka.jar build-cache/ + fi } script() { @@ -269,6 +275,7 @@ script() { sudo service etcd start ./build-cache/grpc_server_example & + java -jar ./build-cache/eureka.jar > ./build-cache/eureka.log 2>&1 & ./bin/apisix help ./bin/apisix init diff --git a/.travis/osx_openresty_runner.sh b/.travis/osx_openresty_runner.sh index 1cfce2728585..3fa4a9ed4920 100755 --- a/.travis/osx_openresty_runner.sh +++ b/.travis/osx_openresty_runner.sh @@ -43,11 +43,14 @@ do_install() { git clone https://github.com/iresty/test-nginx.git test-nginx wget -P utils https://raw.githubusercontent.com/openresty/openresty-devel-utils/master/lj-releng - chmod a+x utils/lj-releng + chmod a+x utils/lj-releng wget https://github.com/iresty/grpc_server_example/releases/download/20200314/grpc_server_example-darwin-amd64.tar.gz tar -xvf grpc_server_example-darwin-amd64.tar.gz + wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz + tar -xvf eureka.tar.gz + brew install grpcurl } @@ -59,6 +62,7 @@ script() { sleep 1 ./grpc_server_example & + java -jar ./eureka.jar > ./eureka.log 2>&1 & make help make init diff --git a/Makefile b/Makefile index 1ecc3074276a..66b44cc4d842 100644 --- a/Makefile +++ b/Makefile @@ -133,6 +133,9 @@ install: $(INSTALL) -d $(INST_LUADIR)/apisix/http/router $(INSTALL) apisix/http/router/*.lua $(INST_LUADIR)/apisix/http/router/ + $(INSTALL) -d $(INST_LUADIR)/apisix/discovery + $(INSTALL) apisix/discovery/*.lua $(INST_LUADIR)/apisix/discovery/ + $(INSTALL) -d $(INST_LUADIR)/apisix/plugins $(INSTALL) apisix/plugins/*.lua $(INST_LUADIR)/apisix/plugins/ diff --git a/apisix/balancer.lua b/apisix/balancer.lua index a5134bcbd928..6113305f3e07 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -16,6 +16,7 @@ -- local healthcheck = require("resty.healthcheck") local roundrobin = require("resty.roundrobin") +local discovery = require("apisix.discovery.init").discovery local resty_chash = require("resty.chash") local balancer = require("ngx.balancer") local core = require("apisix.core") @@ -23,6 +24,7 @@ local error = error local str_char = string.char local str_gsub = string.gsub local pairs = pairs +local ipairs = ipairs local tostring = tostring local set_more_tries = balancer.set_more_tries local get_last_failure = balancer.get_last_failure @@ -48,24 +50,31 @@ local _M = { local function fetch_health_nodes(upstream, checker) + local nodes = upstream.nodes if not checker then - return upstream.nodes + local new_nodes = core.table.new(0, #nodes) + for _, node in ipairs(nodes) do + -- TODO filter with metadata + new_nodes[core.table.concat({node.host, ":", node.port})] = node.weight + end + return new_nodes end local host = upstream.checks and upstream.checks.host - local up_nodes = core.table.new(0, core.table.nkeys(upstream.nodes)) - - for addr, weight in pairs(upstream.nodes) do - local ip, port = core.utils.parse_addr(addr) - local ok = checker:get_target_status(ip, port, host) + local up_nodes = core.table.new(0, #nodes) + for _, node in ipairs(nodes) do + local ok = checker:get_target_status(node.host, node.port, host) if ok then - up_nodes[addr] = weight + -- TODO filter with metadata + up_nodes[core.table.concat({node.host, ":", node.port})] = node.weight end end if core.table.nkeys(up_nodes) == 0 then core.log.warn("all upstream nodes is unhealth, use default") - up_nodes = upstream.nodes + for _, node in ipairs(nodes) do + up_nodes[core.table.concat({node.host, ":", node.port})] = node.weight + end end return up_nodes @@ -78,13 +87,11 @@ local function create_checker(upstream, healthcheck_parent) shm_name = "upstream-healthcheck", checks = upstream.checks, }) - - for addr, weight in pairs(upstream.nodes) do - local ip, port = core.utils.parse_addr(addr) - local ok, err = checker:add_target(ip, port, upstream.checks.host) + for _, node in ipairs(upstream.nodes) do + local ok, err = checker:add_target(node.host, node.port, upstream.checks.host) if not ok then - core.log.error("failed to add new health check target: ", addr, - " err: ", err) + core.log.error("failed to add new health check target: ", node.host, ":", node.port, + " err: ", err) end end @@ -230,7 +237,14 @@ local function pick_server(route, ctx) key = up_conf.type .. "#route_" .. route.value.id end - if core.table.nkeys(up_conf.nodes) == 0 then + if up_conf.service_name then + if not discovery then + return nil, nil, "discovery is uninitialized" + end + up_conf.nodes = discovery.nodes(up_conf.service_name) + end + + if not up_conf.nodes or #up_conf.nodes == 0 then return nil, nil, "no valid upstream node" end @@ -256,11 +270,10 @@ local function pick_server(route, ctx) if ctx.balancer_try_count == 1 then local retries = up_conf.retries - if retries and retries > 0 then - set_more_tries(retries) - else - set_more_tries(core.table.nkeys(up_conf.nodes)) + if not retries or retries <= 0 then + retries = #up_conf.nodes end + set_more_tries(retries) end if checker then @@ -291,9 +304,11 @@ local function pick_server(route, ctx) local ip, port, err = core.utils.parse_addr(server) ctx.balancer_ip = ip ctx.balancer_port = port - + core.log.info("proxy to ", ip, ":", port) return ip, port, err end + + -- for test _M.pick_server = pick_server @@ -323,21 +338,39 @@ function _M.init_worker() item_schema = core.schema.upstream, filter = function(upstream) upstream.has_domain = false - if not upstream.value then + if not upstream.value or not upstream.value.nodes then return end - for addr, _ in pairs(upstream.value.nodes or {}) do - local host = core.utils.parse_addr(addr) - if not core.utils.parse_ipv4(host) and - not core.utils.parse_ipv6(host) then - upstream.has_domain = true - break + local nodes = upstream.value.nodes + if core.table.isarray(nodes) then + for _, node in ipairs(nodes) do + local host = node.host + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + upstream.has_domain = true + break + end + end + else + local new_nodes = core.table.new(core.table.nkeys(nodes), 0) + for addr, weight in pairs(nodes) do + local host, port = core.utils.parse_addr(addr) + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + upstream.has_domain = true + end + local node = { + host = host, + port = port, + weight = weight, + } + core.table.insert(new_nodes, node) end + upstream.value.nodes = new_nodes end - core.log.info("filter upstream: ", - core.json.delay_encode(upstream)) + core.log.info("filter upstream: ", core.json.delay_encode(upstream)) end, }) if not upstreams_etcd then diff --git a/apisix/core/table.lua b/apisix/core/table.lua index 0fc64acc3444..da1a8f86f6d0 100644 --- a/apisix/core/table.lua +++ b/apisix/core/table.lua @@ -32,6 +32,7 @@ local _M = { insert = table.insert, concat = table.concat, clone = require("table.clone"), + isarray = require("table.isarray"), } diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua new file mode 100644 index 000000000000..d9e049a1bfb2 --- /dev/null +++ b/apisix/discovery/eureka.lua @@ -0,0 +1,217 @@ +-- +-- 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 local_conf = require("apisix.core.config_local").local_conf() +local http = require("resty.http") +local core = require("apisix.core") +local ipmatcher = require("resty.ipmatcher") +local ipairs = ipairs +local tostring = tostring +local type = type +local math_random = math.random +local error = error +local ngx = ngx +local ngx_timer_at = ngx.timer.at +local ngx_timer_every = ngx.timer.every +local string_sub = string.sub +local string_find = string.find +local log = core.log + +local default_weight +local applications +local useragent = 'ngx_lua-apisix/v' .. core.version.VERSION + + +local _M = { + version = 1.0, +} + + +local function service_info() + local host = local_conf.eureka and local_conf.eureka.host + if not host then + log.error("do not set eureka.host") + return + end + + local basic_auth + -- TODO Add health check to get healthy nodes. + local url = host[math_random(#host)] + local user_and_password_idx = string_find(url, "@", 1, true) + if user_and_password_idx then + local protocol_header_idx = string_find(url, "://", 1, true) + local protocol_header = string_sub(url, 1, protocol_header_idx + 2) + local user_and_password = string_sub(url, protocol_header_idx + 3, user_and_password_idx - 1) + local other = string_sub(url, user_and_password_idx + 1) + url = protocol_header .. other + basic_auth = "Basic " .. ngx.encode_base64(user_and_password) + end + if local_conf.eureka.prefix then + url = url .. local_conf.eureka.prefix + end + if string_sub(url, #url) ~= "/" then + url = url .. "/" + end + + return url, basic_auth +end + + +local function request(request_uri, basic_auth, method, path, query, body) + local url = request_uri .. path + local headers = core.table.new(0, 5) + headers['User-Agent'] = useragent + headers['Connection'] = 'Keep-Alive' + headers['Accept'] = 'application/json' + + if basic_auth then + headers['Authorization'] = basic_auth + end + + if body and 'table' == type(body) then + local err + body, err = core.json.encode(body) + if not body then + return nil, 'invalid body : ' .. err + end + -- log.warn(method, url, body) + headers['Content-Type'] = 'application/json' + end + + local httpc = http.new() + local timeout = local_conf.eureka.timeout + local connect_timeout = timeout and timeout.connect or 2000 + local send_timeout = timeout and timeout.send or 2000 + local read_timeout = timeout and timeout.read or 5000 + httpc:set_timeouts(connect_timeout, send_timeout, read_timeout) + return httpc:request_uri(url, { + version = 1.1, + method = method, + headers = headers, + query = query, + body = body, + ssl_verify = false, + }) +end + + +local function parse_instance(instance) + local status = instance.status + local overridden_status = instance.overriddenstatus or instance.overriddenStatus + if overridden_status and "UNKNOWN" ~= overridden_status then + status = overridden_status + end + + if status ~= "UP" then + return + end + local port + if tostring(instance.port["@enabled"]) == "true" and instance.port["$"] then + port = instance.port["$"] + -- secure = false + end + if tostring(instance.securePort["@enabled"]) == "true" and instance.securePort["$"] then + port = instance.securePort["$"] + -- secure = true + end + local ip = instance.ipAddr + if not ipmatcher.parse_ipv4(ip) and + not ipmatcher.parse_ipv6(ip) then + log.error("invalid ip:", ip) + return + end + return ip, port, instance.metadata +end + + +local function fetch_full_registry(premature) + if premature then + return + end + + local request_uri, basic_auth = service_info() + if not request_uri then + return + end + + local res, err = request(request_uri, basic_auth, "GET", "apps") + if not res then + log.error("failed to fetch registry", err) + return + end + + if not res.body or res.status ~= 200 then + log.error("failed to fetch registry, status = ", res.status) + return + end + + local json_str = res.body + local data, err = core.json.decode(json_str) + if not data then + log.error("invalid response body: ", json_str, " err: ", err) + return + end + local apps = data.applications.application + local up_apps = core.table.new(0, #apps) + for _, app in ipairs(apps) do + for _, instance in ipairs(app.instance) do + local ip, port, metadata = parse_instance(instance) + if ip and port then + local nodes = up_apps[app.name] + if not nodes then + nodes = core.table.new(#app.instance, 0) + up_apps[app.name] = nodes + end + core.table.insert(nodes, { + host = ip, + port = port, + weight = metadata and metadata.weight or default_weight, + metadata = metadata, + }) + if metadata then + -- remove useless data + metadata.weight = nil + end + end + end + end + applications = up_apps +end + + +function _M.nodes(service_name) + if not applications then + log.error("failed to fetch nodes for : ", service_name) + return + end + + return applications[service_name] +end + + +function _M.init_worker() + if not local_conf.eureka or not local_conf.eureka.host or #local_conf.eureka.host == 0 then + error("do not set eureka.host") + return + end + default_weight = local_conf.eureka.weight or 100 + ngx_timer_at(0, fetch_full_registry) + ngx_timer_every(30, fetch_full_registry) +end + + +return _M diff --git a/apisix/discovery/init.lua b/apisix/discovery/init.lua new file mode 100644 index 000000000000..db798dd4e1ab --- /dev/null +++ b/apisix/discovery/init.lua @@ -0,0 +1,33 @@ +-- +-- 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 log = require("apisix.core.log") +local local_conf = require("apisix.core.config_local").local_conf() + +local discovery_type = local_conf.apisix and local_conf.apisix.discovery +local discovery + +if discovery_type then + log.info("use discovery: ", discovery_type) + discovery = require("apisix.discovery." .. discovery_type) +end + + +return { + version = 1.0, + discovery = discovery +} diff --git a/apisix/http/service.lua b/apisix/http/service.lua index 42d31dd58b3c..0184a9315d69 100644 --- a/apisix/http/service.lua +++ b/apisix/http/service.lua @@ -14,7 +14,9 @@ -- See the License for the specific language governing permissions and -- limitations under the License. -- -local core = require("apisix.core") +local core = require("apisix.core") +local ipairs = ipairs +local pairs = pairs local services local error = error local pairs = pairs @@ -25,6 +27,48 @@ local _M = { } +local function filter(service) + service.has_domain = false + if not service.value then + return + end + + if not service.value.upstream or not service.value.upstream.nodes then + return + end + + local nodes = service.value.upstream.nodes + if core.table.isarray(nodes) then + for _, node in ipairs(nodes) do + local host = node.host + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + service.has_domain = true + break + end + end + else + local new_nodes = core.table.new(core.table.nkeys(nodes), 0) + for addr, weight in pairs(nodes) do + local host, port = core.utils.parse_addr(addr) + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + service.has_domain = true + end + local node = { + host = host, + port = port, + weight = weight, + } + core.table.insert(new_nodes, node) + end + service.value.upstream.nodes = new_nodes + end + + core.log.info("filter service: ", core.json.delay_encode(service)) +end + + function _M.get(service_id) return services:get(service_id) end diff --git a/apisix/init.lua b/apisix/init.lua index 48c5b8098bdf..6ae76783a43b 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -28,7 +28,7 @@ local get_method = ngx.req.get_method local ngx_exit = ngx.exit local math = math local error = error -local pairs = pairs +local ipairs = ipairs local tostring = tostring local load_balancer @@ -74,7 +74,10 @@ function _M.http_init_worker() if not ok then error("failed to init worker event: " .. err) end - + local discovery = require("apisix.discovery.init").discovery + if discovery and discovery.init_worker then + discovery.init_worker() + end require("apisix.balancer").init_worker() load_balancer = require("apisix.balancer").run require("apisix.admin.init").init_worker() @@ -179,71 +182,71 @@ function _M.http_ssl_phase() end -local function parse_domain_in_up(up, ver) - local new_nodes = core.table.new(0, 8) - for addr, weight in pairs(up.value.nodes) do - local host, port = core.utils.parse_addr(addr) +local function parse_domain(host) + local ip_info, err = core.utils.dns_parse(dns_resolver, host) + if not ip_info then + core.log.error("failed to parse domain for ", host, ", error:",err) + return nil, err + end + + core.log.info("parse addr: ", core.json.delay_encode(ip_info)) + core.log.info("resolver: ", core.json.delay_encode(dns_resolver)) + core.log.info("host: ", host) + if ip_info.address then + core.log.info("dns resolver domain: ", host, " to ", ip_info.address) + return ip_info.address + else + return nil, "failed to parse domain" + end +end + + +local function parse_domain_for_nodes(nodes) + local new_nodes = core.table.new(#nodes, 0) + for _, node in ipairs(nodes) do + local host = node.host if not ipmatcher.parse_ipv4(host) and - not ipmatcher.parse_ipv6(host) then - local ip_info, err = core.utils.dns_parse(dns_resolver, host) - if not ip_info then - return nil, err + not ipmatcher.parse_ipv6(host) then + local ip, err = parse_domain(host) + if ip then + local new_node = core.table.clone(node) + new_node.host = ip + core.table.insert(new_nodes, new_node) end - core.log.info("parse addr: ", core.json.delay_encode(ip_info)) - core.log.info("resolver: ", core.json.delay_encode(dns_resolver)) - core.log.info("host: ", host) - if ip_info.address then - new_nodes[ip_info.address .. ":" .. port] = weight - core.log.info("dns resolver domain: ", host, " to ", - ip_info.address) - else - return nil, "failed to parse domain in route" + if err then + return nil, err end else - new_nodes[addr] = weight + core.table.insert(new_nodes, node) end end + return new_nodes +end + +local function parse_domain_in_up(up, ver) + local nodes = up.value.nodes + local new_nodes, err = parse_domain_for_nodes(nodes) + if not new_nodes then + return nil, err + end up.dns_value = core.table.clone(up.value) up.dns_value.nodes = new_nodes - core.log.info("parse upstream which contain domain: ", - core.json.delay_encode(up)) + core.log.info("parse upstream which contain domain: ", core.json.delay_encode(up)) return up end local function parse_domain_in_route(route, ver) - local new_nodes = core.table.new(0, 8) - for addr, weight in pairs(route.value.upstream.nodes) do - local host, port = core.utils.parse_addr(addr) - if not ipmatcher.parse_ipv4(host) and - not ipmatcher.parse_ipv6(host) then - local ip_info, err = core.utils.dns_parse(dns_resolver, host) - if not ip_info then - return nil, err - end - - core.log.info("parse addr: ", core.json.delay_encode(ip_info)) - core.log.info("resolver: ", core.json.delay_encode(dns_resolver)) - core.log.info("host: ", host) - if ip_info and ip_info.address then - new_nodes[ip_info.address .. ":" .. port] = weight - core.log.info("dns resolver domain: ", host, " to ", - ip_info.address) - else - return nil, "failed to parse domain in route" - end - - else - new_nodes[addr] = weight - end + local nodes = route.value.upstream.nodes + local new_nodes, err = parse_domain_for_nodes(nodes) + if not new_nodes then + return nil, err end - route.dns_value = core.table.deepcopy(route.value) route.dns_value.upstream.nodes = new_nodes - core.log.info("parse route which contain domain: ", - core.json.delay_encode(route)) + core.log.info("parse route which contain domain: ", core.json.delay_encode(route)) return route end diff --git a/apisix/router.lua b/apisix/router.lua index d3b45941d995..456ff75f704c 100644 --- a/apisix/router.lua +++ b/apisix/router.lua @@ -15,9 +15,10 @@ -- limitations under the License. -- local require = require -local core = require("apisix.core") -local error = error -local pairs = pairs +local core = require("apisix.core") +local error = error +local pairs = pairs +local ipairs = ipairs local _M = {version = 0.2} @@ -29,17 +30,36 @@ local function filter(route) return end - if not route.value.upstream then + if not route.value.upstream or not route.value.upstream.nodes then return end - for addr, _ in pairs(route.value.upstream.nodes or {}) do - local host = core.utils.parse_addr(addr) - if not core.utils.parse_ipv4(host) and - not core.utils.parse_ipv6(host) then - route.has_domain = true - break + local nodes = route.value.upstream.nodes + if core.table.isarray(nodes) then + for _, node in ipairs(nodes) do + local host = node.host + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + route.has_domain = true + break + end end + else + local new_nodes = core.table.new(core.table.nkeys(nodes), 0) + for addr, weight in pairs(nodes) do + local host, port = core.utils.parse_addr(addr) + if not core.utils.parse_ipv4(host) and + not core.utils.parse_ipv6(host) then + route.has_domain = true + end + local node = { + host = host, + port = port, + weight = weight, + } + core.table.insert(new_nodes, node) + end + route.value.upstream.nodes = new_nodes end core.log.info("filter route: ", core.json.delay_encode(route)) @@ -78,7 +98,7 @@ end function _M.stream_init_worker() local router_stream = require("apisix.stream.router.ip_port") - router_stream.stream_init_worker() + router_stream.stream_init_worker(filter) _M.router_stream = router_stream end @@ -88,4 +108,8 @@ function _M.http_routes() end +-- for test +_M.filter = filter + + return _M diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index e261c98c1ec4..b1e53f63b0fb 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -225,11 +225,9 @@ local health_checker = { } -local upstream_schema = { - type = "object", - properties = { - nodes = { - description = "nodes of upstream", +local nodes_schema = { + anyOf = { + { type = "object", patternProperties = { [".*"] = { @@ -240,6 +238,40 @@ local upstream_schema = { }, minProperties = 1, }, + { + type = "array", + minItems = 1, + items = { + type = "object", + properties = { + host = host_def, + port = { + description = "port of node", + type = "integer", + minimum = 1, + }, + weight = { + description = "weight of node", + type = "integer", + minimum = 0, + maximum = 100, + }, + metadata = { + description = "metadata of node", + type = "object", + } + }, + required = {"host", "port", "weight"}, + }, + } + } +} + + +local upstream_schema = { + type = "object", + properties = { + nodes = nodes_schema, retries = { type = "integer", minimum = 1, @@ -297,11 +329,13 @@ local upstream_schema = { type = "boolean" }, desc = {type = "string", maxLength = 256}, + service_name = {type = "string", maxLength = 50}, id = id_schema }, anyOf = { {required = {"type", "nodes"}}, {required = {"type", "k8s_deployment_info"}}, + {required = {"type", "service_name"}}, }, additionalProperties = false, } diff --git a/conf/config.yaml b/conf/config.yaml index 3b16de12fded..193b9942aa97 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -91,7 +91,7 @@ apisix: listen_port: 9443 ssl_protocols: "TLSv1 TLSv1.1 TLSv1.2 TLSv1.3" ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES256-SHA256:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:AES256-SHA:DES-CBC3-SHA" - +# discovery: eureka # service discovery center nginx_config: # config for render the template to genarate nginx.conf error_log: "logs/error.log" error_log_level: "warn" # warn,error @@ -118,6 +118,16 @@ etcd: prefix: "/apisix" # apisix configurations prefix timeout: 3 # 3 seconds +eureka: + host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. + - "http://127.0.0.1:8761" + prefix: "/eureka/" + weight: 100 # default weight for node + timeout: + connect: 2000 + send: 2000 + read: 5000 + plugins: # plugin list - example-plugin - limit-req diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md new file mode 100644 index 000000000000..cf2b46120a0d --- /dev/null +++ b/doc/discovery-cn.md @@ -0,0 +1,195 @@ + + +# 集成服务发现注册中心 + +## 摘要 + +当业务量发生变化时,需要对下游服务进行扩缩容,或者因服务器硬件故障需要更换服务器。如果网关是通过配置来维护下游服务信息,在微服务架构模式下,其带来的维护成本可想而知。再者因不能及时更新这些信息,也会对业务带来一定的影响,还有人为误操作带来的影响也不可忽视,所以网关非常必要通过服务注册中心来获取最新的服务实例列表。架构图如下所示: + +![](./images/discovery-cn.png) + +1. 服务启动时将自身的一些信息,比如服务名、IP、端口等信息上报到注册中心;各个服务与注册中心使用一定机制(例如心跳)通信,如果注册中心与服务长时间无法通信,就会注销该实例;当服务下线时,会删除注册中心的实例信息; +2. 网关会准实时地从注册中心获取服务实例信息; +3. 当用户通过网关请求服务时,网关从注册中心获取的实例列表中选择一台进行代理; + +常见的注册中心:Eureka, Etcd, Consul, Zookeeper, Nacos等 + +## 开启服务发现 + +首先要在 `conf/config.yaml` 文件中增加如下配置,以选择注册中心的类型: + +```yaml +apisix: + discovery: eureka +``` + +现已支持注册中心有:Eureka 。 + +## 注册中心配置 + +### Eureka 的配置 + +在 `conf/config.yaml` 增加如下格式的配置: + +```yaml +eureka: + host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. + - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" + - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" + prefix: "/eureka/" + weight: 100 # default weight for node + timeout: + connect: 2000 + send: 2000 + read: 5000 +``` + +通过 `eureka.host ` 配置 eureka 的服务器地址。 + +如果 eureka 的地址是 `http://127.0.0.1:8761/` ,并且不需要用户名和密码验证的话,配置如下: + +```yaml +eureka: + host: + - "http://127.0.0.1:8761" + prefix: "/eureka/" +``` + +**Memo**: 如果能把这些配置移到配置中心管理,那就更好了。 + +## upstream 配置 + +APISIX是通过 `upstream.service_name` 与注册中心的服务名进行关联。下面是 uri 为 "/user/*" 的请求路由到注册中心名为 "USER-SERVICE" 的服务上例子: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/user/*", + "upstream": { + "service_name": "USER-SERVICE", + "type": "roundrobin" + } +}' + +HTTP/1.1 201 Created +Date: Sat, 31 Aug 2019 01:17:15 GMT +Content-Type: text/plain +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX web server + +{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} +``` + +**注意**:配置 `upstream.service_name` 后 `upstream.nodes` 将不再生效,而是使用从注册中心获取到的 `nodes` 来替换。 + +## 如何扩展注册中心? + +APISIX 要扩展注册中心其实是件非常容易的事情,我们还是以 Eureka 为例。 + +### 1. 实现 eureka.lua + +首先在 `lua/apisix/discovery/` 目录中添加 `eureka.lua`; + +然后在 `eureka.lua` 实现用于初始化的 `init_worker` 函数以及用于获取服务实例节点列表的 `nodes` 函数即可: + + ```lua + local _M = { + version = 1.0, + } + + + function _M.nodes(service_name) + ... ... + end + + + function _M.init_worker() + ... ... + end + + + return _M + ``` + +### 2. Eureka 与 APISIX 之间数据转换逻辑 + +APISIX是通过 `upstream.nodes` 来配置下游服务的,所以使用注册中心后,通过注册中心获取服务的所有 node 后,赋值给 `upstream.nodes` 来达到相同的效果。那么 APISIX 是怎么将 Eureka 的数据转成 node 的呢? 假如从 Eureka 获取如下数据: + +```json +{ + "applications": { + "application": [ + { + "name": "USER-SERVICE", # 服务名称 + "instance": [ + { + "instanceId": "192.168.1.100:8761", + "hostName": "192.168.1.100", + "app": "USER-SERVICE", # 服务名称 + "ipAddr": "192.168.1.100", # 实例 IP 地址 + "status": "UP", # 状态 + "overriddenStatus": "UNKNOWN", # 覆盖状态 + "port": { + "$": 8761, # 端口 + "@enabled": "true" # 开始端口 + }, + "securePort": { + "$": 443, + "@enabled": "false" + }, + "metadata": { + "management.port": "8761", + "weight": 100 # 权重,需要通过 spring boot 应用的 eureka.instance.metadata-map.weight 进行配置 + }, + "homePageUrl": "http://192.168.1.100:8761/", + "statusPageUrl": "http://192.168.1.100:8761/actuator/info", + "healthCheckUrl": "http://192.168.1.100:8761/actuator/health", + ... ... + } + ] + } + ] + } +} +``` + +解析 instance 数据步骤: + +1. 首先要选择状态为 “UP” 的实例: overriddenStatus 值不为 "UNKNOWN" 以 overriddenStatus 为准,否则以 status 的值为准; +2. IP 地址:以 ipAddr 的值为 IP; 并且是 IPv4 或 IPv6 格式的; +3. 端口:端口取值规则是,如果 port["@enabled"] == "true" 那么使用 port["\$"] 的值;如果 securePort["@enabled"] == "true" 那么使用 securePort["$"] 的值; +4. 权重:权重取值顺序是,先判断 metadata.weight 是否有值,如果没有,则取配置中的 eureka.weight 的值, 如果还没有,则取默认值100; + +这个例子转成 APISIX nodes 的结果如下: + +```json +[ + { + "host" : "192.168.1.100", + "port" : 8761, + "weight" : 100, + "metadata" : { + "management.port": "8761", + "weight": 100 + } + } +] +``` diff --git a/doc/discovery.md b/doc/discovery.md new file mode 100644 index 000000000000..54b2246addc4 --- /dev/null +++ b/doc/discovery.md @@ -0,0 +1,187 @@ + + +# Integration service discovery registry + +## Summary + +When system traffic changes, the number of servers of the downstream service also increases or decreases, or the server needs to be replaced due to its hardware failure. If the gateway maintains downstream service information through configuration, the maintenance costs in the microservices architecture pattern are unpredictable. Furthermore, due to the untimely update of these information, will also bring a certain impact for the business, and the impact of human error operation can not be ignored. So it is very necessary for the gateway to automatically get the latest list of service instances through the service registry。As shown in the figure below: + +![](./images/discovery.png) + +1. When the service starts, it will report some of its information, such as the service name, IP, port and other information to the registry. The services communicate with the registry using a mechanism such as a heartbeat, and if the registry and the service are unable to communicate for a long time, the instance will be cancel.When the service goes offline, the registry will delete the instance information. +2. The gateway gets service instance information from the registry in near-real time. +3. When the user requests the service through the gateway, the gateway selects one instance from the registry for proxy. + +Common registries: Eureka, Etcd, Consul, Zookeeper, Nacos etc. + +## Enabled discovery client + +Add the following configuration to `conf/config.yaml` file and select one discovery client type which you want: + +```yaml +apisix: + discovery: eureka +``` + +The supported discovery client: Eureka. + +## Configuration for discovery client + +Once the registry is selected, it needs to be configured. + +### Configuration for Eureka + +Add following configuration in `conf/config.yaml` : + +```yaml +eureka: + host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. + - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" + - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" + prefix: "/eureka/" + weight: 100 # default weight for node + timeout: + connect: 2000 + send: 2000 + read: 5000 +``` + + +**Tip**: It would be even better if these configurations could be moved to the configuration center for management. + +## Upstream setting + +Here is an example of routing a request with a uri of "/user/*" to a service which named "user-service" in the registry : + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/user/*", + "upstream": { + "service_name": "USER-SERVICE", + "type": "roundrobin" + } +}' + +HTTP/1.1 201 Created +Date: Sat, 31 Aug 2019 01:17:15 GMT +Content-Type: text/plain +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX web server + +{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} +``` + +*Notice**:When configuring `upstream.service_name`, `upstream.nodes` will no longer take effect, but will be replaced by 'nodes' obtained from the registry. + +## How do I extend the discovery client? + +It is very easy for APISIX to extend the discovery client. Let's take Eureka as an example. + +### 1. the code structure of discovery client + +First, add 'eureka.lua' in the 'lua/apisix/discovery/' directory; + +Then implement the 'init_worker' function for initialization and the 'nodes' function for obtaining the list of service instance nodes in' eureka.lua': + + ```lua + local _M = { + version = 1.0, + } + + + function _M.nodes(service_name) + ... ... + end + + + function _M.init_worker() + ... ... + end + + + return _M + ``` + +### 2. How convert Eureka's instance data to APISIX's node? + +Here's an example of Eureka's data: + +```json +{ + "applications": { + "application": [ + { + "name": "USER-SERVICE", # service name + "instance": [ + { + "instanceId": "192.168.1.100:8761", + "hostName": "192.168.1.100", + "app": "USER-SERVICE", # service name + "ipAddr": "192.168.1.100", # IP address + "status": "UP", + "overriddenStatus": "UNKNOWN", + "port": { + "$": 8761, + "@enabled": "true" + }, + "securePort": { + "$": 443, + "@enabled": "false" + }, + "metadata": { + "management.port": "8761", + "weight": 100 # Setting by 'eureka.instance.metadata-map.weight' of the spring boot application + }, + "homePageUrl": "http://192.168.1.100:8761/", + "statusPageUrl": "http://192.168.1.100:8761/actuator/info", + "healthCheckUrl": "http://192.168.1.100:8761/actuator/health", + ... ... + } + ] + } + ] + } +} +``` + +Deal with the Eureka's instance data need the following steps : + +1. select the UP instance. When the value of `overriddenStatus` is "UP" or the value of `overriddenStatus` is "UNKNOWN" and the value of `status` is "UP". +2. Host. The `ipAddr` is the IP address of instance; and must be IPv4 or IPv6. +3. Port. If the value of `port["@enabled"]` is equal to "true", using the value of `port["\$"]`, If the value of `securePort["@enabled"]` is equal to "true", using the value of `securePort["\$"]`. +4. Weight. `local weight = metadata.weight or local_conf.eureka.weight or 100` + +The result of this example is as follows: + +```json +[ + { + "host" : "192.168.1.100", + "port" : 8761, + "weight" : 100, + "metadata" : { + "management.port": "8761", + "weight": 100 + } + } +] +``` diff --git a/doc/images/discovery-cn.png b/doc/images/discovery-cn.png new file mode 100644 index 0000000000000000000000000000000000000000..7b448c2ca1e4e318cc965a5efe910a47951187b3 GIT binary patch literal 42581 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

Q9}phMfn?2WlI=L%KQ?wjUH7lF9Q2uxK`? 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 weWy}7{GWSZbL`K6R }; + close $in; + $cert; +} + +our $yaml_config = read_file("conf/config.yaml"); +$yaml_config =~ s/node_listen: 9080/node_listen: 1984/; +$yaml_config =~ s/enable_heartbeat: true/enable_heartbeat: false/; +$yaml_config =~ s/config_center: etcd/config_center: yaml/; +$yaml_config =~ s/enable_admin: true/enable_admin: false/; +$yaml_config =~ s/enable_admin: true/enable_admin: false/; +$yaml_config =~ s/ discovery:/ discovery: eureka #/; +$yaml_config =~ s/# discovery:/ discovery: eureka #/; +run_tests(); + +__DATA__ + + +=== TEST 1: get APISIX-EUREKA info from EUREKA +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /eureka/* + upstream: + service_name: APISIX-EUREKA + type: roundrobin + +#END +--- request +GET /eureka/apps/APISIX-EUREKA +--- response_body_like +.*APISIX-EUREKA.* +--- error_log +use config_center: yaml +--- no_error_log +[error] + + diff --git a/t/node/upstream-array-nodes.t b/t/node/upstream-array-nodes.t new file mode 100644 index 000000000000..6044cc1bb7ac --- /dev/null +++ b/t/node/upstream-array-nodes.t @@ -0,0 +1,104 @@ +# +# 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); +log_level('info'); +worker_connections(256); +no_root_location(); +no_shuffle(); + +run_tests(); + +__DATA__ + +=== TEST 1: set upstream(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams/1', + ngx.HTTP_PUT, + [[{ + "nodes": [{ + "host": "127.0.0.1", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + +=== TEST 2: set route(id: 1) +--- 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": "/hello", + "upstream_id": "1" + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + +=== TEST 3: /not_found +--- request +GET /not_found +--- error_code: 404 +--- response_body +{"error_msg":"failed to match any routes"} +--- no_error_log +[error] + + +=== TEST 4: hit routes +--- request +GET /hello +--- response_body +hello world +--- no_error_log +[error] From 26962cc1dfba877d27cb64bfa298ad027586bf89 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sat, 11 Apr 2020 22:11:05 +0800 Subject: [PATCH 02/16] test --- apisix/balancer.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apisix/balancer.lua b/apisix/balancer.lua index 6113305f3e07..1c99078a19b8 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -294,8 +294,7 @@ local function pick_server(route, ctx) if up_conf.timeout then local timeout = up_conf.timeout - local ok, err = set_timeouts(timeout.connect, timeout.send, - timeout.read) + local ok, err = set_timeouts(timeout.connect, timeout.send, timeout.read) if not ok then core.log.error("could not set upstream timeouts: ", err) end From 8872b9103c11680f007660ea66af688febfdbc4c Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 07:20:59 +0800 Subject: [PATCH 03/16] fix --- doc/discovery-cn.md | 1 - doc/discovery.md | 1 - 2 files changed, 2 deletions(-) diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index cf2b46120a0d..d63f7664b9f4 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -188,7 +188,6 @@ APISIX是通过 `upstream.nodes` 来配置下游服务的,所以使用注册 "weight" : 100, "metadata" : { "management.port": "8761", - "weight": 100 } } ] diff --git a/doc/discovery.md b/doc/discovery.md index 54b2246addc4..ed05bb0334fc 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -180,7 +180,6 @@ The result of this example is as follows: "weight" : 100, "metadata" : { "management.port": "8761", - "weight": 100 } } ] From 96d9e060b9cb3329c23454baae6e33a3a6ed1d46 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 09:33:38 +0800 Subject: [PATCH 04/16] update version --- apisix/balancer.lua | 2 +- apisix/core/table.lua | 2 +- apisix/discovery/eureka.lua | 2 +- apisix/discovery/init.lua | 2 +- apisix/init.lua | 2 +- apisix/router.lua | 2 +- apisix/schema_def.lua | 2 +- conf/config.yaml | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apisix/balancer.lua b/apisix/balancer.lua index 1c99078a19b8..c6e5b028271b 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -44,7 +44,7 @@ local lrucache_checker = core.lrucache.new({ local _M = { - version = 0.1, + version = 0.2, name = module_name, } diff --git a/apisix/core/table.lua b/apisix/core/table.lua index da1a8f86f6d0..14e0165a1abd 100644 --- a/apisix/core/table.lua +++ b/apisix/core/table.lua @@ -25,7 +25,7 @@ local type = type local _M = { - version = 0.1, + version = 0.2, new = new_tab, clear = require("table.clear"), nkeys = nkeys, diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index d9e049a1bfb2..9efe12b8fe6e 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -37,7 +37,7 @@ local useragent = 'ngx_lua-apisix/v' .. core.version.VERSION local _M = { - version = 1.0, + version = 0.1, } diff --git a/apisix/discovery/init.lua b/apisix/discovery/init.lua index db798dd4e1ab..16aafe62c50d 100644 --- a/apisix/discovery/init.lua +++ b/apisix/discovery/init.lua @@ -28,6 +28,6 @@ end return { - version = 1.0, + version = 0.1, discovery = discovery } diff --git a/apisix/init.lua b/apisix/init.lua index 6ae76783a43b..f3d3684577bd 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -42,7 +42,7 @@ local function parse_args(args) end -local _M = {version = 0.3} +local _M = {version = 0.4} function _M.http_init(args) diff --git a/apisix/router.lua b/apisix/router.lua index 456ff75f704c..0e6645fbe86a 100644 --- a/apisix/router.lua +++ b/apisix/router.lua @@ -21,7 +21,7 @@ local pairs = pairs local ipairs = ipairs -local _M = {version = 0.2} +local _M = {version = 0.3} local function filter(route) diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index b1e53f63b0fb..bfa16ff75349 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -18,7 +18,7 @@ local schema = require('apisix.core.schema') local setmetatable = setmetatable local error = error -local _M = {version = 0.4} +local _M = {version = 0.5} local plugins_schema = { diff --git a/conf/config.yaml b/conf/config.yaml index 193b9942aa97..47a5557c28ce 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -153,7 +153,7 @@ plugins: # plugin list - proxy-cache - tcp-logger - proxy-mirror - - kafka-logger + #- kafka-logger - cors - batch-requests stream_plugins: From 28c1b0430ba250f132246270dd0ad1c64a909946 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 10:16:33 +0800 Subject: [PATCH 05/16] remove filter --- apisix/http/service.lua | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/apisix/http/service.lua b/apisix/http/service.lua index 0184a9315d69..552095b02748 100644 --- a/apisix/http/service.lua +++ b/apisix/http/service.lua @@ -83,29 +83,6 @@ function _M.services() end -local function filter(service) - service.has_domain = false - if not service.value then - return - end - - if not service.value.upstream then - return - end - - for addr, _ in pairs(service.value.upstream.nodes or {}) do - local host = core.utils.parse_addr(addr) - if not core.utils.parse_ipv4(host) and - not core.utils.parse_ipv6(host) then - service.has_domain = true - break - end - end - - core.log.info("filter service: ", core.json.delay_encode(service)) -end - - function _M.init_worker() local err services, err = core.config.new("/services", { From 061e8f83087a278893f4c0b62937272b2c19b65e Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 10:51:36 +0800 Subject: [PATCH 06/16] fix --- apisix/http/service.lua | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/apisix/http/service.lua b/apisix/http/service.lua index 552095b02748..161d82fe2358 100644 --- a/apisix/http/service.lua +++ b/apisix/http/service.lua @@ -16,7 +16,6 @@ -- local core = require("apisix.core") local ipairs = ipairs -local pairs = pairs local services local error = error local pairs = pairs @@ -27,6 +26,20 @@ local _M = { } +function _M.get(service_id) + return services:get(service_id) +end + + +function _M.services() + if not services then + return nil, nil + end + + return services.values, services.conf_version +end + + local function filter(service) service.has_domain = false if not service.value then @@ -69,20 +82,6 @@ local function filter(service) end -function _M.get(service_id) - return services:get(service_id) -end - - -function _M.services() - if not services then - return nil, nil - end - - return services.values, services.conf_version -end - - function _M.init_worker() local err services, err = core.config.new("/services", { From f34197c696107cba6bb8698c53b36ac20a553d9a Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 10:59:05 +0800 Subject: [PATCH 07/16] fix --- conf/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conf/config.yaml b/conf/config.yaml index 47a5557c28ce..193b9942aa97 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -153,7 +153,7 @@ plugins: # plugin list - proxy-cache - tcp-logger - proxy-mirror - #- kafka-logger + - kafka-logger - cors - batch-requests stream_plugins: From af5757673dc477e4036f25d4c7f9fe342d7c5ff5 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Sun, 12 Apr 2020 22:47:21 +0800 Subject: [PATCH 08/16] add schema --- apisix/discovery/eureka.lua | 30 ++++++++++++++++++++++++++++++ apisix/init.lua | 6 ++++++ conf/config.yaml | 18 +++++++++--------- doc/discovery-cn.md | 2 +- doc/discovery.md | 1 + t/discovery/eureka.t | 13 +++++++++++++ 6 files changed, 60 insertions(+), 10 deletions(-) diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index 9efe12b8fe6e..61365da4e65f 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -35,6 +35,30 @@ local default_weight local applications local useragent = 'ngx_lua-apisix/v' .. core.version.VERSION +local schema = { + type = "object", + properties = { + host = { + type = "array", + minItems = 1, + items = { + type = "string", + }, + }, + prefix = {type = "string"}, + weight = {type = "integer", minimum = 0, maximum = 100}, + timeout = { + type = "object", + properties = { + connect = {type = "integer", minimum = 1, default = 1000}, + send = {type = "integer", minimum = 1, default = 1000}, + read = {type = "integer", minimum = 1, default = 1000}, + } + }, + }, + required = {"host"} +} + local _M = { version = 0.1, @@ -208,6 +232,12 @@ function _M.init_worker() error("do not set eureka.host") return end + + local ok, err = core.schema.check(schema, local_conf.eureka) + if not ok then + error("invalid eureka configuration: " .. err) + return + end default_weight = local_conf.eureka.weight or 100 ngx_timer_at(0, fetch_full_registry) ngx_timer_every(30, fetch_full_registry) diff --git a/apisix/init.lua b/apisix/init.lua index f3d3684577bd..be1afacef593 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -445,6 +445,7 @@ function _M.grpc_access_phase() run_plugin("access", plugins, api_ctx) end + local function common_phase(plugin_name) local api_ctx = ngx.ctx.api_ctx if not api_ctx then @@ -467,14 +468,17 @@ local function common_phase(plugin_name) return api_ctx end + function _M.http_header_filter_phase() common_phase("header_filter") end + function _M.http_body_filter_phase() common_phase("body_filter") end + function _M.http_log_phase() local api_ctx = common_phase("log") @@ -491,6 +495,7 @@ function _M.http_log_phase() core.tablepool.release("api_ctx", api_ctx) end + function _M.http_balancer_phase() local api_ctx = ngx.ctx.api_ctx if not api_ctx then @@ -514,6 +519,7 @@ function _M.http_balancer_phase() load_balancer(api_ctx.matched_route, api_ctx) end + local function cors_admin() local local_conf = core.config.local_conf() if local_conf.apisix and not local_conf.apisix.enable_admin_cors then diff --git a/conf/config.yaml b/conf/config.yaml index 193b9942aa97..c75b242b0fbf 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -118,15 +118,15 @@ etcd: prefix: "/apisix" # apisix configurations prefix timeout: 3 # 3 seconds -eureka: - host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. - - "http://127.0.0.1:8761" - prefix: "/eureka/" - weight: 100 # default weight for node - timeout: - connect: 2000 - send: 2000 - read: 5000 +#eureka: +# host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. +# - "http://127.0.0.1:8761" +# prefix: "/eureka/" +# weight: 100 # default weight for node +# timeout: +# connect: 2000 +# send: 2000 +# read: 5000 plugins: # plugin list - example-plugin diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index d63f7664b9f4..35af682f5deb 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -16,7 +16,7 @@ # limitations under the License. # --> - +[English](discovery.md) # 集成服务发现注册中心 ## 摘要 diff --git a/doc/discovery.md b/doc/discovery.md index ed05bb0334fc..48e0384e7b51 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -16,6 +16,7 @@ # limitations under the License. # --> +[Chinese](discovery-cn.md) # Integration service discovery registry diff --git a/t/discovery/eureka.t b/t/discovery/eureka.t index e3183940a564..8da6aaad42b7 100644 --- a/t/discovery/eureka.t +++ b/t/discovery/eureka.t @@ -38,6 +38,19 @@ $yaml_config =~ s/enable_admin: true/enable_admin: false/; $yaml_config =~ s/enable_admin: true/enable_admin: false/; $yaml_config =~ s/ discovery:/ discovery: eureka #/; $yaml_config =~ s/# discovery:/ discovery: eureka #/; + +$yaml_config .= <<_EOC_; +eureka: + host: + - "http://127.0.0.1:8761" + prefix: "/eureka/" + weight: 100 + timeout: + connect: 2000 + send: 2000 + read: 5000 +_EOC_ + run_tests(); __DATA__ From 8e5de2a411973998fb03d6bc633be3807a443783 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Thu, 16 Apr 2020 08:21:58 +0800 Subject: [PATCH 09/16] add fetch_interval and use docker --- .travis/linux_openresty_runner.sh | 10 ++-------- .travis/linux_tengine_runner.sh | 9 ++------- .travis/osx_openresty_runner.sh | 4 ---- apisix/discovery/eureka.lua | 6 +++--- conf/config.yaml | 1 + 5 files changed, 8 insertions(+), 22 deletions(-) diff --git a/.travis/linux_openresty_runner.sh b/.travis/linux_openresty_runner.sh index de1153c3c20a..b4409348a165 100755 --- a/.travis/linux_openresty_runner.sh +++ b/.travis/linux_openresty_runner.sh @@ -43,6 +43,8 @@ before_install() { docker network create kafka-net --driver bridge docker run --name zookeeper-server -d -p 2181:2181 --network kafka-net -e ALLOW_ANONYMOUS_LOGIN=yes bitnami/zookeeper:3.6.0 docker run --name kafka-server1 -d --network kafka-net -e ALLOW_PLAINTEXT_LISTENER=yes -e KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-server:2181 -e KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 -p 9092:9092 -e KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true bitnami/kafka:latest + docker pull bitinit/eureka + docker run --name eureka -d -p 8761:8761 --env ENVIRONMENT=apisix --env spring.application.name=apisix-eureka --env server.port=8761 --env eureka.instance.ip-address=127.0.0.1 --env eureka.client.registerWithEureka=true --env eureka.client.fetchRegistry=false --env eureka.client.serviceUrl.defaultZone=http://127.0.0.1:8761/eureka/ bitinit/eureka sleep 5 docker exec -it kafka-server1 /opt/bitnami/kafka/bin/kafka-topics.sh --create --zookeeper zookeeper-server:2181 --replication-factor 1 --partitions 1 --topic test2 } @@ -117,13 +119,6 @@ do_install() { tar -xvf grpcurl-amd64.tar.gz mv grpcurl build-cache/ fi - - if [ ! -f "build-cache/eureka" ]; then - wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz - tar -xvf eureka.tar.gz - mv eureka.jar build-cache/ - fi - } script() { @@ -133,7 +128,6 @@ script() { sudo service etcd start ./build-cache/grpc_server_example & - java -jar ./build-cache/eureka.jar > ./build-cache/eureka.log 2>&1 & ./bin/apisix help ./bin/apisix init diff --git a/.travis/linux_tengine_runner.sh b/.travis/linux_tengine_runner.sh index 85733d933c67..26f1a3ae4f79 100755 --- a/.travis/linux_tengine_runner.sh +++ b/.travis/linux_tengine_runner.sh @@ -44,6 +44,8 @@ before_install() { docker network create kafka-net --driver bridge docker run --name zookeeper-server -d -p 2181:2181 --network kafka-net -e ALLOW_ANONYMOUS_LOGIN=yes bitnami/zookeeper:3.6.0 docker run --name kafka-server1 -d --network kafka-net -e ALLOW_PLAINTEXT_LISTENER=yes -e KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper-server:2181 -e KAFKA_CFG_ADVERTISED_LISTENERS=PLAINTEXT://127.0.0.1:9092 -p 9092:9092 -e KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE=true bitnami/kafka:latest + docker pull bitinit/eureka + docker run --name eureka -d -p 8761:8761 --env ENVIRONMENT=apisix --env spring.application.name=apisix-eureka --env server.port=8761 --env eureka.instance.ip-address=127.0.0.1 --env eureka.client.registerWithEureka=true --env eureka.client.fetchRegistry=false --env eureka.client.serviceUrl.defaultZone=http://127.0.0.1:8761/eureka/ bitinit/eureka sleep 5 docker exec -it kafka-server1 /opt/bitnami/kafka/bin/kafka-topics.sh --create --zookeeper zookeeper-server:2181 --replication-factor 1 --partitions 1 --topic test2 } @@ -260,12 +262,6 @@ do_install() { tar -xvf grpc_server_example-amd64.tar.gz mv grpc_server_example build-cache/ fi - - if [ ! -f "build-cache/eureka" ]; then - wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz - tar -xvf eureka.tar.gz - mv eureka.jar build-cache/ - fi } script() { @@ -275,7 +271,6 @@ script() { sudo service etcd start ./build-cache/grpc_server_example & - java -jar ./build-cache/eureka.jar > ./build-cache/eureka.log 2>&1 & ./bin/apisix help ./bin/apisix init diff --git a/.travis/osx_openresty_runner.sh b/.travis/osx_openresty_runner.sh index 3fa4a9ed4920..0f60eb987b40 100755 --- a/.travis/osx_openresty_runner.sh +++ b/.travis/osx_openresty_runner.sh @@ -48,9 +48,6 @@ do_install() { wget https://github.com/iresty/grpc_server_example/releases/download/20200314/grpc_server_example-darwin-amd64.tar.gz tar -xvf grpc_server_example-darwin-amd64.tar.gz - wget https://github.com/api7/eureka-for-test/releases/download/v1.9.8/eureka.tar.gz - tar -xvf eureka.tar.gz - brew install grpcurl } @@ -62,7 +59,6 @@ script() { sleep 1 ./grpc_server_example & - java -jar ./eureka.jar > ./eureka.log 2>&1 & make help make init diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index 61365da4e65f..d86752dd6769 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -33,7 +33,6 @@ local log = core.log local default_weight local applications -local useragent = 'ngx_lua-apisix/v' .. core.version.VERSION local schema = { type = "object", @@ -45,6 +44,7 @@ local schema = { type = "string", }, }, + fetch_interval = {type = "integer", minimum = 1, default = 30}, prefix = {type = "string"}, weight = {type = "integer", minimum = 0, maximum = 100}, timeout = { @@ -98,7 +98,6 @@ end local function request(request_uri, basic_auth, method, path, query, body) local url = request_uri .. path local headers = core.table.new(0, 5) - headers['User-Agent'] = useragent headers['Connection'] = 'Keep-Alive' headers['Accept'] = 'application/json' @@ -239,8 +238,9 @@ function _M.init_worker() return end default_weight = local_conf.eureka.weight or 100 + local fetch_interval = local_conf.eureka.fetch_interval or 30 ngx_timer_at(0, fetch_full_registry) - ngx_timer_every(30, fetch_full_registry) + ngx_timer_every(fetch_interval, fetch_full_registry) end diff --git a/conf/config.yaml b/conf/config.yaml index c75b242b0fbf..4fd21270e38e 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -122,6 +122,7 @@ etcd: # host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. # - "http://127.0.0.1:8761" # prefix: "/eureka/" +# fetch_interval: 30 # default 30s # weight: 100 # default weight for node # timeout: # connect: 2000 From 0c801ba08ae8d1ac712e9bc1d2e3dcad2bb832c6 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Mon, 20 Apr 2020 23:11:50 +0800 Subject: [PATCH 10/16] add directory --- doc/discovery-cn.md | 180 +++++++++++++++++++++++++------------------- doc/discovery.md | 147 +++++++++++++++++++++--------------- 2 files changed, 187 insertions(+), 140 deletions(-) diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index 35af682f5deb..2799121801a7 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -17,102 +17,54 @@ # --> [English](discovery.md) + # 集成服务发现注册中心 +* [**摘要**](#摘要) +* [**如何扩展注册中心**](#如何扩展注册中心) + * [**基本步骤**](#基本步骤) + * [**以 Eureka 举例**](#以-Eureka-举例) + * [**实现 eureka.lua**](#实现-eureka.lua) + * [**Eureka 与 APISIX 之间数据转换逻辑**](#Eureka-与-APISIX-之间数据转换逻辑) +* [**注册中心配置**](#注册中心配置) + * [**选择注册中心**](#选择注册中心) + * [**Eureka 注册中心配置**](#Eureka-注册中心配置) +* [**upstream 配置**](#upstream-配置) + ## 摘要 -当业务量发生变化时,需要对下游服务进行扩缩容,或者因服务器硬件故障需要更换服务器。如果网关是通过配置来维护下游服务信息,在微服务架构模式下,其带来的维护成本可想而知。再者因不能及时更新这些信息,也会对业务带来一定的影响,还有人为误操作带来的影响也不可忽视,所以网关非常必要通过服务注册中心来获取最新的服务实例列表。架构图如下所示: +当业务量发生变化时,需要对上游服务进行扩缩容,或者因服务器硬件故障需要更换服务器。如果网关是通过配置来维护上游服务信息,在微服务架构模式下,其带来的维护成本可想而知。再者因不能及时更新这些信息,也会对业务带来一定的影响,还有人为误操作带来的影响也不可忽视,所以网关非常必要通过服务注册中心动态获取最新的服务实例信息。架构图如下所示: ![](./images/discovery-cn.png) 1. 服务启动时将自身的一些信息,比如服务名、IP、端口等信息上报到注册中心;各个服务与注册中心使用一定机制(例如心跳)通信,如果注册中心与服务长时间无法通信,就会注销该实例;当服务下线时,会删除注册中心的实例信息; 2. 网关会准实时地从注册中心获取服务实例信息; -3. 当用户通过网关请求服务时,网关从注册中心获取的实例列表中选择一台进行代理; - -常见的注册中心:Eureka, Etcd, Consul, Zookeeper, Nacos等 - -## 开启服务发现 - -首先要在 `conf/config.yaml` 文件中增加如下配置,以选择注册中心的类型: - -```yaml -apisix: - discovery: eureka -``` - -现已支持注册中心有:Eureka 。 - -## 注册中心配置 - -### Eureka 的配置 - -在 `conf/config.yaml` 增加如下格式的配置: - -```yaml -eureka: - host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. - - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" - - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" - prefix: "/eureka/" - weight: 100 # default weight for node - timeout: - connect: 2000 - send: 2000 - read: 5000 -``` +3. 当用户通过网关请求服务时,网关从注册中心获取的实例列表中选择一个进行代理; -通过 `eureka.host ` 配置 eureka 的服务器地址。 +常见的注册中心:Eureka, Etcd, Consul, Nacos, Zookeeper等 -如果 eureka 的地址是 `http://127.0.0.1:8761/` ,并且不需要用户名和密码验证的话,配置如下: -```yaml -eureka: - host: - - "http://127.0.0.1:8761" - prefix: "/eureka/" -``` - -**Memo**: 如果能把这些配置移到配置中心管理,那就更好了。 - -## upstream 配置 - -APISIX是通过 `upstream.service_name` 与注册中心的服务名进行关联。下面是 uri 为 "/user/*" 的请求路由到注册中心名为 "USER-SERVICE" 的服务上例子: - -```shell -$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' -{ - "uri": "/user/*", - "upstream": { - "service_name": "USER-SERVICE", - "type": "roundrobin" - } -}' - -HTTP/1.1 201 Created -Date: Sat, 31 Aug 2019 01:17:15 GMT -Content-Type: text/plain -Transfer-Encoding: chunked -Connection: keep-alive -Server: APISIX web server +## 如何扩展注册中心? -{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} -``` +### 基本步骤 -**注意**:配置 `upstream.service_name` 后 `upstream.nodes` 将不再生效,而是使用从注册中心获取到的 `nodes` 来替换。 +APISIX 要扩展注册中心其实是件非常容易的事情,其基本步骤如下: -## 如何扩展注册中心? +1. 在 `apisix/discovery/` 目录中添加注册中心客户端的实现; +2. 实现用于初始化的 `_M.init_worker()` 函数以及用于获取服务实例节点列表的 `_M.nodes(service_name)` 函数; +3. 将注册中心数据转换为 APISIX 格式的数据; -APISIX 要扩展注册中心其实是件非常容易的事情,我们还是以 Eureka 为例。 +### 以 Eureka 举例 -### 1. 实现 eureka.lua +#### 实现 eureka.lua -首先在 `lua/apisix/discovery/` 目录中添加 `eureka.lua`; +首先在 `apisix/discovery/` 目录中添加 [`eureka.lua`](../apisix/discovery/eureka.lua); 然后在 `eureka.lua` 实现用于初始化的 `init_worker` 函数以及用于获取服务实例节点列表的 `nodes` 函数即可: ```lua local _M = { - version = 1.0, + version = 0.1, } @@ -129,9 +81,9 @@ APISIX 要扩展注册中心其实是件非常容易的事情,我们还是以 return _M ``` -### 2. Eureka 与 APISIX 之间数据转换逻辑 +#### Eureka 与 APISIX 之间数据转换逻辑 -APISIX是通过 `upstream.nodes` 来配置下游服务的,所以使用注册中心后,通过注册中心获取服务的所有 node 后,赋值给 `upstream.nodes` 来达到相同的效果。那么 APISIX 是怎么将 Eureka 的数据转成 node 的呢? 假如从 Eureka 获取如下数据: +APISIX是通过 `upstream.nodes` 来配置上游服务的,所以使用注册中心后,通过注册中心获取服务的所有 node 后,赋值给 `upstream.nodes` 来达到相同的效果。那么 APISIX 是怎么将 Eureka 的数据转成 node 的呢? 假如从 Eureka 获取如下数据: ```json { @@ -174,9 +126,9 @@ APISIX是通过 `upstream.nodes` 来配置下游服务的,所以使用注册 解析 instance 数据步骤: 1. 首先要选择状态为 “UP” 的实例: overriddenStatus 值不为 "UNKNOWN" 以 overriddenStatus 为准,否则以 status 的值为准; -2. IP 地址:以 ipAddr 的值为 IP; 并且是 IPv4 或 IPv6 格式的; -3. 端口:端口取值规则是,如果 port["@enabled"] == "true" 那么使用 port["\$"] 的值;如果 securePort["@enabled"] == "true" 那么使用 securePort["$"] 的值; -4. 权重:权重取值顺序是,先判断 metadata.weight 是否有值,如果没有,则取配置中的 eureka.weight 的值, 如果还没有,则取默认值100; +2. IP 地址:以 ipAddr 的值为 IP; 并且必须是 IPv4 或 IPv6 格式的; +3. 端口:端口取值规则是,如果 port["@enabled"] 等于 "true" 那么使用 port["\$"] 的值;如果 securePort["@enabled"] 等于 "true" 那么使用 securePort["$"] 的值; +4. 权重:权重取值顺序是,先判断 `metadata.weight` 是否有值,如果没有,则取配置中的 `eureka.weight` 的值, 如果还没有,则取默认值`100`; 这个例子转成 APISIX nodes 的结果如下: @@ -192,3 +144,75 @@ APISIX是通过 `upstream.nodes` 来配置下游服务的,所以使用注册 } ] ``` + +## 注册中心配置 + +### 选择注册中心 + +首先要在 `conf/config.yaml` 文件中增加如下配置,以选择注册中心的类型: + +```yaml +apisix: + discovery: eureka +``` + +此名称要与 `apisix/discovery/` 目录中实现对应注册中心的文件名保持一致。 + +现已支持注册中心有:Eureka 。 + +### Eureka 的配置 + +在 `conf/config.yaml` 增加如下格式的配置: + +```yaml +eureka: + host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. + - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" + - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" + prefix: "/eureka/" + fetch_interval: 30 # 从 eureka 中拉取数据的时间间隔,默认30秒 + weight: 100 # default weight for node + timeout: + connect: 2000 # 连接 eureka 的超时时间 + send: 2000 # 向 eureka 发送数据的超时时间 + read: 5000 # 从 eureka 读数据的超时时间 +``` + +通过 `eureka.host ` 配置 eureka 的服务器地址。 + +如果 eureka 的地址是 `http://127.0.0.1:8761/` ,并且不需要用户名和密码验证的话,配置如下: + +```yaml +eureka: + host: + - "http://127.0.0.1:8761" + prefix: "/eureka/" +``` + +## upstream 配置 + +APISIX是通过 `upstream.service_name` 与注册中心的服务名进行关联。下面是将 uri 为 "/user/*" 的请求路由到注册中心名为 "USER-SERVICE" 的服务上例子: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/user/*", + "upstream": { + "service_name": "USER-SERVICE", + "type": "roundrobin" + } +}' + +HTTP/1.1 201 Created +Date: Sat, 31 Aug 2019 01:17:15 GMT +Content-Type: text/plain +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX web server + +{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} +``` + +**注意**:配置 `upstream.service_name` 后 `upstream.nodes` 将不再生效,而是使用从注册中心的数据来替换,即使注册中心的数据是空的。 + + diff --git a/doc/discovery.md b/doc/discovery.md index 48e0384e7b51..4948cc44c881 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -20,9 +20,20 @@ # Integration service discovery registry +* [** Summary**](#Summary) +* [**How extend the discovery client?**](#How-extend-the-discovery-client?) + * [**Basic steps**](#Basic-steps) + * [**the example of Eureka**](#the-example-of-Eureka) + * [**Implementation of eureka.lua**](#Implementation-of-eureka.lua) + * [**How convert Eureka's instance data to APISIX's node?**](#How-convert-Eureka's-instance-data-to-APISIX's-node?) +* [**Configuration for discovery client**](#Configuratio-for-discovery-client) + * [**Select discovery client**](#Select-discovery-client) + * [**Configuration for Eureka**](#Configuration-for-Eureka) +* [**Upstream setting**](#Upstream-setting) + ## Summary -When system traffic changes, the number of servers of the downstream service also increases or decreases, or the server needs to be replaced due to its hardware failure. If the gateway maintains downstream service information through configuration, the maintenance costs in the microservices architecture pattern are unpredictable. Furthermore, due to the untimely update of these information, will also bring a certain impact for the business, and the impact of human error operation can not be ignored. So it is very necessary for the gateway to automatically get the latest list of service instances through the service registry。As shown in the figure below: +When system traffic changes, the number of servers of the upstream service also increases or decreases, or the server needs to be replaced due to its hardware failure. If the gateway maintains upstream service information through configuration, the maintenance costs in the microservices architecture pattern are unpredictable. Furthermore, due to the untimely update of these information, will also bring a certain impact for the business, and the impact of human error operation can not be ignored. So it is very necessary for the gateway to automatically get the latest list of service instances through the service registry。As shown in the figure below: ![](./images/discovery.png) @@ -32,76 +43,26 @@ When system traffic changes, the number of servers of the downstream service als Common registries: Eureka, Etcd, Consul, Zookeeper, Nacos etc. -## Enabled discovery client - -Add the following configuration to `conf/config.yaml` file and select one discovery client type which you want: - -```yaml -apisix: - discovery: eureka -``` - -The supported discovery client: Eureka. - -## Configuration for discovery client - -Once the registry is selected, it needs to be configured. - -### Configuration for Eureka - -Add following configuration in `conf/config.yaml` : - -```yaml -eureka: - host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. - - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" - - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" - prefix: "/eureka/" - weight: 100 # default weight for node - timeout: - connect: 2000 - send: 2000 - read: 5000 -``` - +## How extend the discovery client? -**Tip**: It would be even better if these configurations could be moved to the configuration center for management. +### Basic steps -## Upstream setting +It is very easy for APISIX to extend the discovery client. , the basic steps are as follows -Here is an example of routing a request with a uri of "/user/*" to a service which named "user-service" in the registry : +1. Add the implementation of registry client in the 'apisix/discovery/' directory; -```shell -$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' -{ - "uri": "/user/*", - "upstream": { - "service_name": "USER-SERVICE", - "type": "roundrobin" - } -}' +2. Implement the `_M. init_worker()` function for initialization and the `_M. nodes(service_name)` function for obtaining the list of service instance nodes; -HTTP/1.1 201 Created -Date: Sat, 31 Aug 2019 01:17:15 GMT -Content-Type: text/plain -Transfer-Encoding: chunked -Connection: keep-alive -Server: APISIX web server +3. Convert the registry data into data in APISIX; -{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} -``` - -*Notice**:When configuring `upstream.service_name`, `upstream.nodes` will no longer take effect, but will be replaced by 'nodes' obtained from the registry. - -## How do I extend the discovery client? -It is very easy for APISIX to extend the discovery client. Let's take Eureka as an example. +### the example of Eureka -### 1. the code structure of discovery client +#### Implementation of eureka.lua -First, add 'eureka.lua' in the 'lua/apisix/discovery/' directory; +First, add [`eureka.lua`](../apisix/discovery/eureka.lua) in the `apisix/discovery/` directory; -Then implement the 'init_worker' function for initialization and the 'nodes' function for obtaining the list of service instance nodes in' eureka.lua': +Then implement the `_M.init_worker()` function for initialization and the `_M.nodes(service_name)` function for obtaining the list of service instance nodes in ` eureka.lua`: ```lua local _M = { @@ -122,7 +83,7 @@ Then implement the 'init_worker' function for initialization and the 'nodes' fun return _M ``` -### 2. How convert Eureka's instance data to APISIX's node? +#### How convert Eureka's instance data to APISIX's node? Here's an example of Eureka's data: @@ -185,3 +146,65 @@ The result of this example is as follows: } ] ``` + +## Configuration for discovery client + +### Select discovery client + +Add the following configuration to `conf/config.yaml` and select one discovery client type which you want: + +```yaml +apisix: + discovery: eureka +``` + +This name should be consistent with the file name of the implementation registry in the `apisix/discovery/` directory. + +The supported discovery client: Eureka. + +### Configuration for Eureka + +Add following configuration in `conf/config.yaml` : + +```yaml +eureka: + host: # it's possible to define multiple eureka hosts addresses of the same eureka cluster. + - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" + - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" + prefix: "/eureka/" + fetch_interval: 30 + weight: 100 # default weight for node + timeout: + connect: 2000 + send: 2000 + read: 5000 +``` + + +## Upstream setting + +Here is an example of routing a request with a uri of "/user/*" to a service which named "user-service" in the registry : + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/user/*", + "upstream": { + "service_name": "USER-SERVICE", + "type": "roundrobin" + } +}' + +HTTP/1.1 201 Created +Date: Sat, 31 Aug 2019 01:17:15 GMT +Content-Type: text/plain +Transfer-Encoding: chunked +Connection: keep-alive +Server: APISIX web server + +{"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} +``` + +**Notice**:When configuring `upstream.service_name`, `upstream.nodes` will no longer take effect, but will be replaced by 'nodes' obtained from the registry. + + From 4f625776294f30b9847d62514b82ddaf1eddd27f Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Mon, 20 Apr 2020 23:21:19 +0800 Subject: [PATCH 11/16] update directory --- doc/discovery-cn.md | 4 ++-- doc/discovery.md | 20 ++++++++++---------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index 2799121801a7..cd431267e803 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -24,11 +24,11 @@ * [**如何扩展注册中心**](#如何扩展注册中心) * [**基本步骤**](#基本步骤) * [**以 Eureka 举例**](#以-Eureka-举例) - * [**实现 eureka.lua**](#实现-eureka.lua) + * [**实现 eureka.lua**](#实现-eurekalua) * [**Eureka 与 APISIX 之间数据转换逻辑**](#Eureka-与-APISIX-之间数据转换逻辑) * [**注册中心配置**](#注册中心配置) * [**选择注册中心**](#选择注册中心) - * [**Eureka 注册中心配置**](#Eureka-注册中心配置) + * [**Eureka 的配置**](#Eureka-的配置) * [**upstream 配置**](#upstream-配置) ## 摘要 diff --git a/doc/discovery.md b/doc/discovery.md index 4948cc44c881..f91fb9d95ad2 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -20,16 +20,16 @@ # Integration service discovery registry -* [** Summary**](#Summary) -* [**How extend the discovery client?**](#How-extend-the-discovery-client?) - * [**Basic steps**](#Basic-steps) - * [**the example of Eureka**](#the-example-of-Eureka) - * [**Implementation of eureka.lua**](#Implementation-of-eureka.lua) - * [**How convert Eureka's instance data to APISIX's node?**](#How-convert-Eureka's-instance-data-to-APISIX's-node?) -* [**Configuration for discovery client**](#Configuratio-for-discovery-client) - * [**Select discovery client**](#Select-discovery-client) - * [**Configuration for Eureka**](#Configuration-for-Eureka) -* [**Upstream setting**](#Upstream-setting) +* [**Summary**](#Summary) +* [**How extend the discovery client?**](#how-extend-the-discovery-client) + * [**Basic steps**](#basic-steps) + * [**the example of Eureka**](#the-example-of-eureka) + * [**Implementation of eureka.lua**](#implementation-of-eurekalua) + * [**How convert Eureka's instance data to APISIX's node?**](#how-convert-eurekas-instance-data-to-apisixs-node) +* [**Configuration for discovery client**](#configuration-for-discovery-client) + * [**Select discovery client**](#select-discovery-client) + * [**Configuration for Eureka**](#configuration-for-eureka) +* [**Upstream setting**](#upstream-setting) ## Summary From 5bbd619d6ce64310d72bd0b7ddb5a303f3748606 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Mon, 27 Apr 2020 23:01:42 +0800 Subject: [PATCH 12/16] add test case --- apisix/discovery/eureka.lua | 6 +- apisix/schema_def.lua | 1 - t/admin/routes-array-nodes.t | 126 ++++++++++ t/admin/services-array-nodes.t | 116 ++++++++++ t/admin/upstream-array-nodes.t | 409 +++++++++++++++++++++++++++++++++ t/discovery/eureka.t | 32 ++- t/node/upstream-array-nodes.t | 120 +++++++++- 7 files changed, 799 insertions(+), 11 deletions(-) create mode 100644 t/admin/routes-array-nodes.t create mode 100644 t/admin/services-array-nodes.t create mode 100644 t/admin/upstream-array-nodes.t diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index d86752dd6769..ec880d43bce6 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -46,7 +46,7 @@ local schema = { }, fetch_interval = {type = "integer", minimum = 1, default = 30}, prefix = {type = "string"}, - weight = {type = "integer", minimum = 0, maximum = 100}, + weight = {type = "integer", minimum = 0}, timeout = { type = "object", properties = { @@ -96,6 +96,7 @@ end local function request(request_uri, basic_auth, method, path, query, body) + log.info("eureka uri:", request_uri, ".") local url = request_uri .. path local headers = core.table.new(0, 5) headers['Connection'] = 'Keep-Alive' @@ -120,6 +121,7 @@ local function request(request_uri, basic_auth, method, path, query, body) local connect_timeout = timeout and timeout.connect or 2000 local send_timeout = timeout and timeout.send or 2000 local read_timeout = timeout and timeout.read or 5000 + log.info("connect_timeout:", connect_timeout, ", send_timeout:", send_timeout, ", read_timeout:", read_timeout, ".") httpc:set_timeouts(connect_timeout, send_timeout, read_timeout) return httpc:request_uri(url, { version = 1.1, @@ -238,7 +240,9 @@ function _M.init_worker() return end default_weight = local_conf.eureka.weight or 100 + log.info("default_weight:", default_weight, ".") local fetch_interval = local_conf.eureka.fetch_interval or 30 + log.info("fetch_interval:", fetch_interval, ".") ngx_timer_at(0, fetch_full_registry) ngx_timer_every(fetch_interval, fetch_full_registry) end diff --git a/apisix/schema_def.lua b/apisix/schema_def.lua index bfa16ff75349..7bc05ceed633 100644 --- a/apisix/schema_def.lua +++ b/apisix/schema_def.lua @@ -254,7 +254,6 @@ local nodes_schema = { description = "weight of node", type = "integer", minimum = 0, - maximum = 100, }, metadata = { description = "metadata of node", diff --git a/t/admin/routes-array-nodes.t b/t/admin/routes-array-nodes.t new file mode 100644 index 000000000000..3d6cafacca98 --- /dev/null +++ b/t/admin/routes-array-nodes.t @@ -0,0 +1,126 @@ +# +# 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(); +log_level("info"); + +run_tests; + +__DATA__ + +=== TEST 1: set route(id: 1) +--- 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, + [[{ + "methods": ["GET"], + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + }, + "desc": "new route", + "uri": "/index.html" + }]], + [[{ + "node": { + "value": { + "methods": [ + "GET" + ], + "uri": "/index.html", + "desc": "new route", + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + } + }, + "key": "/apisix/routes/1" + }, + "action": "set" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 2: get route(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/routes/1', + ngx.HTTP_GET, + nil, + [[{ + "node": { + "value": { + "methods": [ + "GET" + ], + "uri": "/index.html", + "desc": "new route", + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + } + }, + "key": "/apisix/routes/1" + }, + "action": "get" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + diff --git a/t/admin/services-array-nodes.t b/t/admin/services-array-nodes.t new file mode 100644 index 000000000000..a8a6ca1b8e09 --- /dev/null +++ b/t/admin/services-array-nodes.t @@ -0,0 +1,116 @@ +# +# 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(); +log_level("info"); + +run_tests; + +__DATA__ + +=== TEST 1: set service(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/services/1', + ngx.HTTP_PUT, + [[{ + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + }, + "desc": "new service" + }]], + [[{ + "node": { + "value": { + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + }, + "desc": "new service" + }, + "key": "/apisix/services/1" + }, + "action": "set" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 2: get service(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/services/1', + ngx.HTTP_GET, + nil, + [[{ + "node": { + "value": { + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + }, + "desc": "new service" + }, + "key": "/apisix/services/1" + }, + "action": "get" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + diff --git a/t/admin/upstream-array-nodes.t b/t/admin/upstream-array-nodes.t new file mode 100644 index 000000000000..9f0c5b8d9978 --- /dev/null +++ b/t/admin/upstream-array-nodes.t @@ -0,0 +1,409 @@ +# +# 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(); +log_level("info"); + +run_tests; + +__DATA__ + +=== TEST 1: set upstream(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams/1', + ngx.HTTP_PUT, + [[{ + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + }]], + [[{ + "node": { + "value": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + }, + "key": "/apisix/upstreams/1" + }, + "action": "set" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 2: get upstream(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams/1', + ngx.HTTP_GET, + nil, + [[{ + "node": { + "value": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + }, + "key": "/apisix/upstreams/1" + }, + "action": "get" + }]] + ) + + ngx.status = code + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 3: delete upstream(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, message = t('/apisix/admin/upstreams/1', + ngx.HTTP_DELETE, + nil, + [[{ + "action": "delete" + }]] + ) + ngx.say("[delete] code: ", code, " message: ", message) + } + } +--- request +GET /t +--- response_body +[delete] code: 200 message: passed +--- no_error_log +[error] + + + +=== TEST 4: delete upstream(id: not_found) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code = t('/apisix/admin/upstreams/not_found', + ngx.HTTP_DELETE, + nil, + [[{ + "action": "delete" + }]] + ) + + ngx.say("[delete] code: ", code) + } + } +--- request +GET /t +--- response_body +[delete] code: 404 +--- no_error_log +[error] + + + +=== TEST 5: push upstream + delete +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, message, res = t('/apisix/admin/upstreams', + ngx.HTTP_POST, + [[{ + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + }]], + [[{ + "node": { + "value": { + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin" + } + }, + "action": "create" + }]] + ) + + if code ~= 200 then + ngx.status = code + ngx.say(message) + return + end + + ngx.say("[push] code: ", code, " message: ", message) + + local id = string.sub(res.node.key, #"/apisix/upstreams/" + 1) + code, message = t('/apisix/admin/upstreams/' .. id, + ngx.HTTP_DELETE, + nil, + [[{ + "action": "delete" + }]] + ) + ngx.say("[delete] code: ", code, " message: ", message) + } + } +--- request +GET /t +--- response_body +[push] code: 200 message: passed +[delete] code: 200 message: passed +--- no_error_log +[error] + + + +=== TEST 6: empty nodes +--- config + location /t { + content_by_lua_block { + local core = require("apisix.core") + local t = require("lib.test_admin").test + local code, message, res = t('/apisix/admin/upstreams/1', + ngx.HTTP_PUT, + [[{ + "nodes": [], + "type": "roundrobin" + }]] + ) + + if code ~= 200 then + ngx.status = code + ngx.print(message) + return + end + + ngx.say("[push] code: ", code, " message: ", message) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"nodes\" validation failed: object matches none of the requireds"} + + + +=== TEST 7: no additional properties is valid +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams', + ngx.HTTP_PUT, + [[{ + "id": 1, + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": 1 + }], + "type": "roundrobin", + "invalid_property": "/index.html" + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: additional properties forbidden, found invalid_property"} +--- no_error_log +[error] + + + +=== TEST 8: invalid weight of node +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams', + ngx.HTTP_PUT, + [[{ + "id": 1, + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": "1" + }], + "type": "chash" + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"nodes\" validation failed: object matches none of the requireds"} +--- no_error_log +[error] + + + +=== TEST 9: invalid weight of node +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams', + ngx.HTTP_PUT, + [[{ + "id": 1, + "nodes": [{ + "host": "127.0.0.1", + "port": 8080, + "weight": -100 + }], + "type": "chash" + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"nodes\" validation failed: object matches none of the requireds"} +--- no_error_log +[error] + + + +=== TEST 10: invalid port of node +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams', + ngx.HTTP_PUT, + [[{ + "id": 1, + "nodes": [{ + "host": "127.0.0.1", + "port": 0, + "weight": 1 + }], + "type": "chash" + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"nodes\" validation failed: object matches none of the requireds"} +--- no_error_log +[error] + + + +=== TEST 11: invalid host of node +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/upstreams', + ngx.HTTP_PUT, + [[{ + "id": 1, + "nodes": [{ + "host": "127.#.%.1", + "port": 8080, + "weight": 1 + }], + "type": "chash" + }]] + ) + + ngx.status = code + ngx.print(body) + } + } +--- request +GET /t +--- error_code: 400 +--- response_body +{"error_msg":"invalid configuration: property \"nodes\" validation failed: object matches none of the requireds"} +--- no_error_log +[error] diff --git a/t/discovery/eureka.t b/t/discovery/eureka.t index 8da6aaad42b7..879efa31b58f 100644 --- a/t/discovery/eureka.t +++ b/t/discovery/eureka.t @@ -38,17 +38,20 @@ $yaml_config =~ s/enable_admin: true/enable_admin: false/; $yaml_config =~ s/enable_admin: true/enable_admin: false/; $yaml_config =~ s/ discovery:/ discovery: eureka #/; $yaml_config =~ s/# discovery:/ discovery: eureka #/; +$yaml_config =~ s/error_log_level: "warn"/error_log_level: "info"/; + $yaml_config .= <<_EOC_; eureka: host: - "http://127.0.0.1:8761" prefix: "/eureka/" - weight: 100 + fetch_interval: 10 + weight: 80 timeout: - connect: 2000 - send: 2000 - read: 5000 + connect: 1500 + send: 1500 + read: 1500 _EOC_ run_tests(); @@ -73,7 +76,28 @@ GET /eureka/apps/APISIX-EUREKA .*APISIX-EUREKA.* --- error_log use config_center: yaml +default_weight:80. +fetch_interval:10. +eureka uri:http://127.0.0.1:8761/eureka/. +connect_timeout:1500, send_timeout:1500, read_timeout:1500. --- no_error_log [error] +=== TEST 2: error service_name name +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /eureka/* + upstream: + service_name: APISIX-EUREKA-DEMO + type: roundrobin + +#END +--- request +GET /eureka/apps/APISIX-EUREKA +--- error_code: 502 +--- error_log eval +qr/.*failed to pick server: no valid upstream node.*/ + diff --git a/t/node/upstream-array-nodes.t b/t/node/upstream-array-nodes.t index 6044cc1bb7ac..94ad68b75bbd 100644 --- a/t/node/upstream-array-nodes.t +++ b/t/node/upstream-array-nodes.t @@ -58,6 +58,7 @@ passed [error] + === TEST 2: set route(id: 1) --- config location /t { @@ -85,17 +86,126 @@ passed [error] -=== TEST 3: /not_found + +=== TEST 3: hit routes --- request -GET /not_found ---- error_code: 404 +GET /hello --- response_body -{"error_msg":"failed to match any routes"} +hello world --- no_error_log [error] -=== TEST 4: hit routes + +=== TEST 4: set route(id: 1) +--- 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": "/hello", + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 5: hit routes +--- request +GET /hello +--- response_body +hello world +--- no_error_log +[error] + + + +=== TEST 6: set services(id: 1) +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + local code, body = t('/apisix/admin/services/1', + ngx.HTTP_PUT, + [[{ + "upstream": { + "nodes": [{ + "host": "127.0.0.1", + "port": 1980, + "weight": 1 + }], + "type": "roundrobin", + "desc": "new upstream" + } + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + +=== TEST 7: set route(id: 1) +--- 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": "/hello", + "service_id": 1 + }]] + ) + + if code >= 300 then + ngx.status = code + end + ngx.say(body) + } + } +--- request +GET /t +--- response_body +passed +--- no_error_log +[error] + + + +=== TEST 8: hit routes --- request GET /hello --- response_body From b8971a3cae9f7adcaebeaf5d89596e63e00ac2b9 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Thu, 30 Apr 2020 14:42:19 +0800 Subject: [PATCH 13/16] add test case with proxy-rewrite plugin --- doc/discovery-cn.md | 35 +++++++++++++++++++++++++++++++++++ doc/discovery.md | 34 ++++++++++++++++++++++++++++++++++ t/discovery/eureka.t | 28 ++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index cd431267e803..ce95a67950fd 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -213,6 +213,41 @@ Server: APISIX web server {"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} ``` +因为上游的接口 URL 可能会有冲突,通常会在网关通过前缀来进行区分: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/a/*", + "plugins": { + "proxy-rewrite" : { + regex_uri: ["^/a/(.*)", "/${1}"] + } + } + "upstream": { + "service_name": "A-SERVICE", + "type": "roundrobin" + } +}' + +$ curl http://127.0.0.1:9080/apisix/admin/routes/2 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/b/*", + "plugins": { + "proxy-rewrite" : { + regex_uri: ["^/b/(.*)", "/${1}"] + } + } + "upstream": { + "service_name": "B-SERVICE", + "type": "roundrobin" + } +}' +``` + +假如 A-SERVICE 和 B-SERVICE 都提供了一个 `/test` 的接口,通过上面的配置,可以通过 `/a/test` 访问 A-SERVICE 的 `/test` 接口,通过 `/b/test` 访问 B-SERVICE 的 `/test` 接口。 + + **注意**:配置 `upstream.service_name` 后 `upstream.nodes` 将不再生效,而是使用从注册中心的数据来替换,即使注册中心的数据是空的。 diff --git a/doc/discovery.md b/doc/discovery.md index f91fb9d95ad2..a7fa0e54ebe4 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -205,6 +205,40 @@ Server: APISIX web server {"node":{"value":{"uri":"\/user\/*","upstream": {"service_name": "USER-SERVICE", "type": "roundrobin"}},"createdIndex":61925,"key":"\/apisix\/routes\/1","modifiedIndex":61925},"action":"create"} ``` +Because the upstream interface URL may have conflict, usually in the gateway by prefix to distinguish: + +```shell +$ curl http://127.0.0.1:9080/apisix/admin/routes/1 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/a/*", + "plugins": { + "proxy-rewrite" : { + regex_uri: ["^/a/(.*)", "/${1}"] + } + } + "upstream": { + "service_name": "A-SERVICE", + "type": "roundrobin" + } +}' + +$ curl http://127.0.0.1:9080/apisix/admin/routes/2 -H 'X-API-KEY: edd1c9f034335f136f87ad84b625c8f1' -X PUT -i -d ' +{ + "uri": "/b/*", + "plugins": { + "proxy-rewrite" : { + regex_uri: ["^/b/(.*)", "/${1}"] + } + } + "upstream": { + "service_name": "B-SERVICE", + "type": "roundrobin" + } +}' +``` + +Suppose both A-SERVICE and B-SERVICE provide a `/test` API. The above configuration allows access to A-SERVICE's `/test` API through `/a/test` and B-SERVICE's `/test` API through `/b/test`. + **Notice**:When configuring `upstream.service_name`, `upstream.nodes` will no longer take effect, but will be replaced by 'nodes' obtained from the registry. diff --git a/t/discovery/eureka.t b/t/discovery/eureka.t index 879efa31b58f..5ae26bfeb6ed 100644 --- a/t/discovery/eureka.t +++ b/t/discovery/eureka.t @@ -101,3 +101,31 @@ GET /eureka/apps/APISIX-EUREKA --- error_log eval qr/.*failed to pick server: no valid upstream node.*/ + +=== TEST 3: with proxy-rewrite +--- yaml_config eval: $::yaml_config +--- apisix_yaml +routes: + - + uri: /eureka-test/* + plugins: + proxy-rewrite: + regex_uri: ["^/eureka-test/(.*)", "/${1}"] + upstream: + service_name: APISIX-EUREKA + type: roundrobin + +#END +--- request +GET /eureka-test/eureka/apps/APISIX-EUREKA +--- response_body_like +.*APISIX-EUREKA.* +--- error_log +use config_center: yaml +default_weight:80. +fetch_interval:10. +eureka uri:http://127.0.0.1:8761/eureka/. +connect_timeout:1500, send_timeout:1500, read_timeout:1500. +--- no_error_log +[error] + From 095053358327f89ef35de3736cc59aeb4f9a7fa3 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Thu, 30 Apr 2020 15:39:22 +0800 Subject: [PATCH 14/16] line is to long --- apisix/discovery/eureka.lua | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index ec880d43bce6..f3e7b11fbc6d 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -75,13 +75,13 @@ local function service_info() local basic_auth -- TODO Add health check to get healthy nodes. local url = host[math_random(#host)] - local user_and_password_idx = string_find(url, "@", 1, true) - if user_and_password_idx then - local protocol_header_idx = string_find(url, "://", 1, true) - local protocol_header = string_sub(url, 1, protocol_header_idx + 2) - local user_and_password = string_sub(url, protocol_header_idx + 3, user_and_password_idx - 1) - local other = string_sub(url, user_and_password_idx + 1) - url = protocol_header .. other + local auth_idx = string_find(url, "@", 1, true) + if auth_idx then + local protocol_idx = string_find(url, "://", 1, true) + local protocol = string_sub(url, 1, protocol_idx + 2) + local user_and_password = string_sub(url, protocol_idx + 3, auth_idx - 1) + local other = string_sub(url, auth_idx + 1) + url = protocol .. other basic_auth = "Basic " .. ngx.encode_base64(user_and_password) end if local_conf.eureka.prefix then @@ -121,7 +121,8 @@ local function request(request_uri, basic_auth, method, path, query, body) local connect_timeout = timeout and timeout.connect or 2000 local send_timeout = timeout and timeout.send or 2000 local read_timeout = timeout and timeout.read or 5000 - log.info("connect_timeout:", connect_timeout, ", send_timeout:", send_timeout, ", read_timeout:", read_timeout, ".") + log.info("connect_timeout:", connect_timeout, ", send_timeout:", send_timeout, + ", read_timeout:", read_timeout, ".") httpc:set_timeouts(connect_timeout, send_timeout, read_timeout) return httpc:request_uri(url, { version = 1.1, From 66bfab73ef82a211113f9dcf3fd2a1cd1c989c40 Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Mon, 11 May 2020 23:59:25 +0800 Subject: [PATCH 15/16] code style --- apisix/balancer.lua | 6 +++--- apisix/discovery/eureka.lua | 10 +++++----- apisix/router.lua | 2 +- conf/config.yaml | 6 +++--- doc/discovery-cn.md | 6 +++--- doc/discovery.md | 8 ++++---- t/admin/balancer.t | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/apisix/balancer.lua b/apisix/balancer.lua index c6e5b028271b..db43af536352 100644 --- a/apisix/balancer.lua +++ b/apisix/balancer.lua @@ -55,7 +55,7 @@ local function fetch_health_nodes(upstream, checker) local new_nodes = core.table.new(0, #nodes) for _, node in ipairs(nodes) do -- TODO filter with metadata - new_nodes[core.table.concat({node.host, ":", node.port})] = node.weight + new_nodes[node.host .. ":" .. node.port] = node.weight end return new_nodes end @@ -66,14 +66,14 @@ local function fetch_health_nodes(upstream, checker) local ok = checker:get_target_status(node.host, node.port, host) if ok then -- TODO filter with metadata - up_nodes[core.table.concat({node.host, ":", node.port})] = node.weight + up_nodes[node.host .. ":" .. node.port] = node.weight end end if core.table.nkeys(up_nodes) == 0 then core.log.warn("all upstream nodes is unhealth, use default") for _, node in ipairs(nodes) do - up_nodes[core.table.concat({node.host, ":", node.port})] = node.weight + up_nodes[node.host .. ":" .. node.port] = node.weight end end diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index f3e7b11fbc6d..d923c3cbba42 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -50,9 +50,9 @@ local schema = { timeout = { type = "object", properties = { - connect = {type = "integer", minimum = 1, default = 1000}, - send = {type = "integer", minimum = 1, default = 1000}, - read = {type = "integer", minimum = 1, default = 1000}, + connect = {type = "integer", minimum = 1, default = 2000}, + send = {type = "integer", minimum = 1, default = 2000}, + read = {type = "integer", minimum = 1, default = 5000}, } }, }, @@ -138,7 +138,7 @@ end local function parse_instance(instance) local status = instance.status local overridden_status = instance.overriddenstatus or instance.overriddenStatus - if overridden_status and "UNKNOWN" ~= overridden_status then + if overridden_status and overridden_status ~= "UNKNOWN" then status = overridden_status end @@ -157,7 +157,7 @@ local function parse_instance(instance) local ip = instance.ipAddr if not ipmatcher.parse_ipv4(ip) and not ipmatcher.parse_ipv6(ip) then - log.error("invalid ip:", ip) + log.error(instance.app, " service ", instance.hostName, " node IP ", ip, " is invalid(must be IPv4 or IPv6).") return end return ip, port, instance.metadata diff --git a/apisix/router.lua b/apisix/router.lua index 0e6645fbe86a..4ba870993717 100644 --- a/apisix/router.lua +++ b/apisix/router.lua @@ -109,7 +109,7 @@ end -- for test -_M.filter = filter +_M.filter_test = filter return _M diff --git a/conf/config.yaml b/conf/config.yaml index 4fd21270e38e..990fb20da7f0 100644 --- a/conf/config.yaml +++ b/conf/config.yaml @@ -125,9 +125,9 @@ etcd: # fetch_interval: 30 # default 30s # weight: 100 # default weight for node # timeout: -# connect: 2000 -# send: 2000 -# read: 5000 +# connect: 2000 # default 2000ms +# send: 2000 # default 2000ms +# read: 5000 # default 5000ms plugins: # plugin list - example-plugin diff --git a/doc/discovery-cn.md b/doc/discovery-cn.md index ce95a67950fd..b748921468d8 100644 --- a/doc/discovery-cn.md +++ b/doc/discovery-cn.md @@ -173,9 +173,9 @@ eureka: fetch_interval: 30 # 从 eureka 中拉取数据的时间间隔,默认30秒 weight: 100 # default weight for node timeout: - connect: 2000 # 连接 eureka 的超时时间 - send: 2000 # 向 eureka 发送数据的超时时间 - read: 5000 # 从 eureka 读数据的超时时间 + connect: 2000 # 连接 eureka 的超时时间,默认2000ms + send: 2000 # 向 eureka 发送数据的超时时间,默认2000ms + read: 5000 # 从 eureka 读数据的超时时间,默认5000ms ``` 通过 `eureka.host ` 配置 eureka 的服务器地址。 diff --git a/doc/discovery.md b/doc/discovery.md index a7fa0e54ebe4..f84f29473c4b 100644 --- a/doc/discovery.md +++ b/doc/discovery.md @@ -172,12 +172,12 @@ eureka: - "http://${usename}:${passowrd}@${eureka_host1}:${eureka_port1}" - "http://${usename}:${passowrd}@${eureka_host2}:${eureka_port2}" prefix: "/eureka/" - fetch_interval: 30 + fetch_interval: 30 # 30s weight: 100 # default weight for node timeout: - connect: 2000 - send: 2000 - read: 5000 + connect: 2000 # 2000ms + send: 2000 # 2000ms + read: 5000 # 5000ms ``` diff --git a/t/admin/balancer.t b/t/admin/balancer.t index b5f64de1b994..7054d22769d5 100644 --- a/t/admin/balancer.t +++ b/t/admin/balancer.t @@ -31,7 +31,7 @@ add_block_preprocessor(sub { function test(route, ctx, count) local balancer = require("apisix.balancer") local router = require("apisix.router") - router.filter(route) + router.filter_test(route) local res = {} for i = 1, count or 12 do local host, port, err = balancer.pick_server(route, ctx) From 1507f435b7f77fc122dcf27a0294616fd13a39ed Mon Sep 17 00:00:00 2001 From: qiujiayu <153163285@qq.com> Date: Tue, 12 May 2020 13:07:48 +0800 Subject: [PATCH 16/16] line is too long --- apisix/discovery/eureka.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apisix/discovery/eureka.lua b/apisix/discovery/eureka.lua index d923c3cbba42..d4b436853617 100644 --- a/apisix/discovery/eureka.lua +++ b/apisix/discovery/eureka.lua @@ -157,7 +157,8 @@ local function parse_instance(instance) local ip = instance.ipAddr if not ipmatcher.parse_ipv4(ip) and not ipmatcher.parse_ipv6(ip) then - log.error(instance.app, " service ", instance.hostName, " node IP ", ip, " is invalid(must be IPv4 or IPv6).") + log.error(instance.app, " service ", instance.hostName, " node IP ", ip, + " is invalid(must be IPv4 or IPv6).") return end return ip, port, instance.metadata