Часть 3: Как создать API-шлюз для микросервисов с использованием OpenResty

API7.ai

February 3, 2023

OpenResty (NGINX + Lua)

В этой статье мы завершаем создание API-шлюза для микросервисов. Давайте используем минимальный пример, чтобы объединить ранее выбранные компоненты и запустить их в соответствии с разработанным планом!

Конфигурация и инициализация NGINX

Мы знаем, что API-шлюз используется для обработки входящего трафика, поэтому сначала нам нужно сделать простую конфигурацию в nginx.conf, чтобы весь трафик обрабатывался через Lua-код шлюза.

server { listen 9080; init_worker_by_lua_block { apisix.http_init_worker() } location / { access_by_lua_block { apisix.http_access_phase() } header_filter_by_lua_block { apisix.http_header_filter_phase() } body_filter_by_lua_block { apisix.http_body_filter_phase() } log_by_lua_block { apisix.http_log_phase() } } }

Здесь мы используем открытый API-шлюз Apache APISIX в качестве примера, поэтому в приведенном выше примере кода присутствует ключевое слово apisix. В этом примере мы слушаем порт 9080 и перехватываем все запросы к этому порту через location /, обрабатывая их через фазы access, rewrite, header filter, body filter и log, вызывая соответствующие функции плагинов на каждой фазе. Фаза rewrite объединена в функции apisix.http_access_phase.

Инициализация системы выполняется на фазе init_worker, которая включает чтение параметров конфигурации, предварительную настройку каталога в etcd, получение списка плагинов из etcd и сортировку плагинов по приоритету и т.д. Я перечислил и объяснил ключевые части кода здесь, а более полную функцию инициализации можно увидеть на GitHub.

function _M.http_init_worker() -- Инициализация маршрутизации, сервисов и плагинов - три наиболее важные части router.init_worker() require("apisix.http.service").init_worker() require("apisix.plugins.ext-plugin.init").init_worker() end

Как видно из этого кода, инициализация маршрутизатора и плагинов немного сложнее, в основном она включает чтение параметров конфигурации и принятие некоторых решений в зависимости от них. Поскольку это связано с чтением данных из etcd, мы используем ngx.timer, чтобы обойти ограничение "нельзя использовать cosocket на фазе init_worker". Если вас интересует эта часть, мы рекомендуем прочитать исходный код, чтобы лучше понять её.

Сопоставление маршрутов

В начале фазы access нам сначала нужно сопоставить маршрут на основе запроса, содержащего uri, host, args, cookies и т.д., с установленными правилами маршрутизации.

router.router_http.match(api_ctx)

Единственный код, доступный публично, — это строка выше, где api_ctx хранит информацию о uri, host, args и cookie запроса. Конкретная реализация функции сопоставления использует lua-resty-radixtree, о котором мы упоминали ранее. Если маршрут не найден, запрос не имеет соответствующего upstream, и он вернет 404.

local router = require("resty.radixtree") local match_opts = {} function _M.match(api_ctx) -- Получаем параметры запроса из ctx и используем их как условие для маршрута match_opts.method = api_ctx.var.method match_opts.host = api_ctx.var.host match_opts.remote_addr = api_ctx.var.remote_addr match_opts.vars = api_ctx.var -- Вызываем функцию проверки маршрута local ok = uri_router:dispatch(api_ctx.var.uri, match_opts, api_ctx) -- Если маршрут не найден, возвращаем 404 if not ok then core.log.info("not find any matched route") return core.response.exit(404) end return true end

Загрузка плагинов

Конечно, если маршрут найден, мы переходим к этапу фильтрации и загрузки плагинов, что является ядром API-шлюза. Начнем с следующего кода.

local plugins = core.tablepool.fetch("plugins", 32, 0) -- Список плагинов в etcd и список плагинов в локальном конфигурационном файле пересекаются api_ctx.plugins = plugin.filter(route, plugins) -- Запускаем функции, подключенные плагинами на фазах rewrite и access, по порядку run_plugin("rewrite", plugins, api_ctx) run_plugin("access", plugins, api_ctx)

В этом коде мы сначала запрашиваем таблицу длиной 32 через пул таблиц, что является техникой оптимизации производительности, которую мы представили ранее. Затем идет функция фильтрации плагинов. Вы можете задаться вопросом, зачем это нужно. На фазе init worker плагина разве мы уже не получили список плагинов из etcd и отсортировали их?

Фильтрация здесь выполняется в сравнении с локальной конфигурацией по следующим двум причинам:

  1. Во-первых, новый разработанный плагин нужно выпустить в канареечном режиме. В этот момент новый плагин существует в списке etcd, но находится в открытом состоянии только на некоторых узлах шлюза. Поэтому нам нужно выполнить дополнительную операцию пересечения.
  2. Для поддержки режима отладки. Какие плагины обрабатывают запрос клиента? В каком порядке загружаются эти плагины? Эта информация будет полезна при отладке, поэтому функция фильтрации также определит, находится ли она в режиме отладки, и запишет эту информацию в заголовок ответа.

Таким образом, в конце фазы access мы берем эти отфильтрованные плагины и запускаем их один за другим в порядке приоритета, как показано в следующем коде.

local function run_plugin(phase, plugins, api_ctx) for i = 1, #plugins, 2 do local phase_fun = plugins[i][phase] if phase_fun then -- Основной код вызова phase_fun(plugins[i + 1], api_ctx) end end return api_ctx end

При итерации по плагинам видно, что мы делаем это с интервалом 2. Это связано с тем, что каждый плагин будет иметь два компонента: объект плагина и параметры конфигурации плагина. Теперь давайте посмотрим на основную строку кода в приведенном выше примере.

phase_fun(plugins[i + 1], api_ctx)

Если эта строка кода кажется немного абстрактной, давайте заменим её конкретным плагином limit_count, что сделает её намного понятнее.

limit_count_plugin_rewrite_function(conf_of_plugin, api_ctx)

На этом этапе мы практически завершили общий поток API-шлюза. Весь этот код находится в одном файле, который содержит более 400 строк кода, но ядро кода — это несколько десятков строк, которые мы описали выше.

Написание плагинов

Теперь осталось сделать одну вещь, чтобы можно было запустить полный демо, — это написать плагин. Давайте возьмем плагин limit-count в качестве примера. Полная реализация занимает чуть более 60 строк кода, которые можно увидеть, перейдя по ссылке. Здесь я подробно объясню ключевые строки кода:

Сначала мы представим lua-resty-limit-traffic как базовую библиотеку для ограничения количества запросов.

local limit_count_new = require("resty.limit.count").new

Затем, используя json schema в rapidjson, определим, какие параметры имеет этот плагин:

local schema = { type = "object", properties = { count = {type = "integer", minimum = 0}, time_window = {type = "integer", minimum = 0}, key = {type = "string", enum = {"remote_addr", "server_addr"}, }, rejected_code = {type = "integer", minimum = 200, maximum = 600}, }, additionalProperties = false, required = {"count", "time_window", "key", "rejected_code"}, }

Эти параметры плагина соответствуют большинству параметров resty.limit.count, которые содержат ключ ограничения, размер временного окна и количество запросов, которые нужно ограничить. Кроме того, плагин добавляет параметр: rejected_code, который возвращает указанный код состояния, когда запрос ограничен.

На последнем этапе мы подключаем функцию обработчика плагина к фазе rewrite:

function _M.rewrite(conf, ctx) -- Получаем объект ограничения из кэша, если его нет, используем функцию `create_limit_obj` для создания нового объекта и кэшируем его local lim, err = core.lrucache.plugin_ctx(plugin_name, ctx, create_limit_obj, conf) -- Получаем значение ключа из `ctx.var` и составляем новый ключ вместе с типом конфигурации и номером версии конфигурации local key = (ctx.var[conf.key] or "") .. ctx.conf_type .. ctx.conf_version -- Функция для определения, превышен ли лимит local delay, remaining = lim:incoming(key, true) if not delay then local err = remaining -- Если пороговое значение превышено, возвращаем указанный код состояния if err == "rejected" then return conf.rejected_code end core.log.error("failed to limit req: ", err) return 500 end -- Если порог не превышен, пропускаем запрос и устанавливаем соответствующий заголовок ответа core.response.set_header("X-RateLimit-Limit", conf.count, "X-RateLimit-Remaining", remaining) end

В коде выше только одна строка логики делает проверку ограничения, остальное — это подготовительная работа и установка заголовков ответа. Если порог не превышен, он продолжит выполнение следующего плагина в соответствии с приоритетом.

Заключение

Наконец, я оставлю вам вопрос для размышления. Мы знаем, что API-шлюзы могут обрабатывать не только трафик уровня 7, но и трафик уровня 4. Основываясь на этом, можете ли вы придумать несколько сценариев использования? Пожалуйста, оставьте свои комментарии и поделитесь этой статьей, чтобы учиться и общаться с большим количеством людей.

Предыдущие части: Часть 1: Как построить API-шлюз для микросервисов с использованием OpenResty Часть 2: Как построить API-шлюз для микросервисов с использованием OpenResty