Implementacja specyfikacji klucza idempotencji w Apache APISIX

W zeszłym tygodniu napisałem artykuł analizę analizy Specyfikacja IETF Idempotency-Key. Specyfikacja ma na celu uniknięcie zduplikowanych żądań. Krótko mówiąc, chodzi o to, aby klient wysyłał unikalny klucz wraz z żądaniem:

  • Jeśli serwer nie zna klucza, postępuje jak zwykle, a następnie przechowuje odpowiedź
  • Jeśli serwer zna klucz, skraca dalsze przetwarzanie i natychmiast zwraca zapisaną odpowiedź.

Ten post pokazuje, jak to zaimplementować za pomocą Apache APISIX.

Przygotowanie do tworzenia wtyczek Apache APISIX

Przed rozpoczęciem kodowania musimy zdefiniować kilka rzeczy.

  1. Apache APISIX oferuje architekturę opartą na wtyczkach. W związku z tym zakodujemy powyższą logikę we wtyczce.
  2. Apache APISIX opiera się na OpenResty, który opiera się na nginx. Każdy komponent definiuje fazy, które w mniejszym lub większym stopniu mapują poszczególne komponenty. Aby uzyskać więcej informacji na temat faz, proszę zobaczyć ten poprzedni post.
  3. Na koniec zdecydujemy o priorytecie. Priorytet określa kolejność, w jakiej APISIX uruchamia wtyczki wewnątrz fazy. Zdecydowałem się na 1500, ponieważ wszystkie wtyczki uwierzytelniające mają priorytet w 2000 i więcej, ale chcę zwrócić buforowaną odpowiedź JAK NAJSZYBCIEJ.
  4. Specyfikacja wymaga od nas przechowywania danych. APISIX oferuje wiele abstrakcji, ale przechowywanie nie jest jedną z nich. Potrzebujemy dostępu poprzez klucz idempotencji, więc wygląda to jak magazyn klucz-wartość.
    • Arbitralnie wybrałem Redis, ponieważ jest on dość rozpowszechniony i klient jest już częścią dystrybucji APISIX. Proszę zauważyć, że zwykły Redis nie oferuje przechowywania JSON; dlatego używam rozszerzenia redis-stack Docker image.

Lokalna infrastruktura jest następująca:

services:
  apisix:
    image: apache/apisix:3.9.0-debian
    volumes:
      - ./apisix/config.yml:/usr/local/apisix/conf/config.yaml:ro
      - ./apisix/apisix.yml:/usr/local/apisix/conf/apisix.yaml:ro #1
      - ./plugin/src:/opt/apisix/plugins:ro                  #2
    ports:
      - "9080:9080"
  redis:
    image: redis/redis-stack:7.2.0-v9
    ports:
      - "8001:8001"                                          #3

  1. Konfiguracja trasy statycznej
  2. Ścieżka do naszej przyszłej wtyczki
  3. Port Redis Insights (GUI). Nie jest to konieczne per se, ale bardzo przydatne podczas programowania do debugowania

Konfiguracja APISIX jest następująca:

deployment:
  role: data_plane
  role_data_plane:
    config_provider: yaml                                    #1

apisix:
  extra_lua_path: /opt/?.lua                                 #2

plugins:
  - idempotency                    # priority: 1500          #3

plugin_attr:                                                 #4
  idempotency:
    host: redis                                              #5

  1. Konfiguracja APISIX dla konfiguracji tras statycznych
  2. Proszę skonfigurować lokalizację naszej wtyczki
  3. Wtyczki niestandardowe muszą być wyraźnie zadeklarowane. Komentarz dotyczący priorytetu nie jest wymagany, ale jest dobrą praktyką i poprawia łatwość konserwacji
  4. Wspólna konfiguracja wtyczek dla wszystkich tras
  5. Proszę zobaczyć poniżej

Na koniec deklarujemy naszą pojedynczą trasę:

routes:
  - uri: /*
    plugins:
      idempotency: ~                                         #1
    upstream:
      nodes:
        "httpbin.org:80": 1                                  #2
#END                                                         #3

  1. Proszę zadeklarować wtyczkę, którą zamierzamy utworzyć
  2. httpbin jest przydatny, ponieważ możemy wypróbować różne URI i metody.
  3. Obowiązkowe dla konfiguracji tras statycznych!

Mając tę infrastrukturę na miejscu, możemy rozpocząć implementację.

Układ wtyczki

Podstawy wtyczki Apache APISIX są dość podstawowe:

local plugin_name = "idempotency"

local _M = {
    version = 1.0,
    priority = 1500,
    schema = {},
    name = plugin_name,
}

return _M

Następnym krokiem jest konfiguracja, np. Host i port Redis. Na początek będziemy oferować pojedynczą konfigurację Redis na wszystkich trasach. Taka jest idea stojąca za plugin_attr w sekcji config.yaml plik: wspólna konfiguracja. Rozwińmy naszą wtyczkę:

local core = require("apisix.core")
local plugin = require("apisix.plugin")

local attr_schema = {                                       --1
    type = "object",
    properties = {
        host = {
            type = "string",
            description = "Redis host",
            default = "localhost",
        },
        port = {
            type = "integer",
            description = "Redis port",
            default = 6379,
        },
    },
}

function _M.init()
    local attr = plugin.plugin_attr(plugin_name) or {}
    local ok, err = core.schema.check(attr_schema, attr)    --2
    if not ok then
        core.log.error("Failed to check the plugin_attr[", plugin_name, "]", ": ", err)
        return false, err
    end
end

  1. Proszę zdefiniować kształt konfiguracji
  2. Proszę sprawdzić poprawność konfiguracji

Ponieważ zdefiniowałem wartości domyślne we wtyczce, mogę nadpisać tylko wartości host do redis aby działać w mojej infrastrukturze Docker Compose i używać domyślnego portu.

Następnie muszę utworzyć klienta Redis. Proszę zauważyć, że platforma uniemożliwia mi łączenie się w dowolnej fazie po sekcji przepisywania/dostępu. Dlatego utworzę go w sekcji init() i zachowam ją do końca.

local redis_new = require("resty.redis").new                --1

function _M.init()

    -- ...

    redis = redis_new()                                     --2
    redis:set_timeout(1000)
    local ok, err = redis:connect(attr.host, attr.port)
    if not ok then
        core.log.error("Failed to connect to Redis: ", err)
        return false, err
    end
end

  1. Proszę odnieść się do new funkcji modułu OpenResty Redis
  2. Proszę ją wywołać, aby uzyskać instancję

Klient Redis jest teraz dostępny w redis przez resztę cyklu wykonywania wtyczki.

Implementacja ścieżki nominalnej

W moim poprzednim życiu inżyniera oprogramowania zwykle najpierw wdrażałem ścieżkę nominalną. Następnie uczyniłem kod bardziej niezawodnym, zarządzając przypadkami błędów indywidualnie. W ten sposób, gdybym musiał zwolnić w dowolnym momencie, nadal dostarczałbym wartości biznesowe – z ostrzeżeniami. W ten sam sposób podejdę do tego mini-projektu.

Pseudoalgorytm na ścieżce nominalnej wygląda następująco:

DO extract idempotency key from request
DO look up value from Redis
IF value doesn't exist
  DO set key in Redis with empty value
ELSE
  RETURN cached response
DO forward to upstream
DO store response in Redis
RETURN response

Musimy zmapować logikę do fazy, o której wspomniałem powyżej. Przed upstreamem dostępne są dwie fazy, przepisanie oraz dostęp; trzy po, header_filter, body_filter oraz log. The dostęp faza wydawała się oczywista dla wcześniejszej pracy, ale musiałem ustalić między trzema innymi. Losowo wybrałem body_filter, ale jestem bardziej niż chętny do wysłuchania sensownych argumentów za innymi fazami.

Proszę zauważyć, że usunąłem logi, aby kod był bardziej czytelny. Dzienniki błędów i informacyjne są niezbędne, aby ułatwić debugowanie problemów produkcyjnych.

function _M.access(conf, ctx)
    local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1
    local redis_key = "idempotency#" .. idempotency_key     --2
    local resp, err = redis:hgetall(redis_key)              --3
    if not resp then
        return
    end
    if next(resp) == nil then                               --4
        local resp, err = redis:hset(redis_key, "request", true ) --4
        if not resp then
            return
        end
    else
        local data = normalize_hgetall_result(resp)         --5
        local response = core.json.decode(data["response"]) --6
        local body = response["body"]                       --7
        local status_code = response["status"]              --7
        local headers = response["headers"]
        for k, v in pairs(headers) do                       --7
            core.response.set_header(k, v)
        end
        return core.response.exit(status_code, body)        --8
    end
end

  1. Proszę wyodrębnić klucz idempotencji z żądania
  2. Proszę poprzedzić klucz prefiksem, aby uniknąć potencjalnych kolizji.
  3. Proszę pobrać zestaw danych przechowywany w Redis pod kluczem idempotency
  4. Jeśli klucz nie zostanie znaleziony, proszę zapisać go ze znakiem logicznym
  5. Przekształcenie danych w tabeli Lua za pomocą niestandardowej funkcji narzędziowej
  6. Odpowiedź jest przechowywana w formacie JSON, aby uwzględnić nagłówki
  7. Proszę zrekonstruować odpowiedź
  8. Proszę zwrócić zrekonstruowaną odpowiedź do klienta. Proszę zwrócić uwagę na return statement: APISIX pomija późniejsze fazy cyklu życia
function _M.body_filter(conf, ctx)
    local idempotency_key = core.request.header(ctx, "Idempotency-Key") --1
    local redis_key = "idempotency#" .. idempotency_key
    if core.response then
        local response = {                                  --2
            status = ngx.status,
            body = core.response.hold_body_chunk(ctx, true),
            headers = ngx.resp.get_headers()
        }
        local redis_key = "idempotency#" .. redis_key
        local resp, err = red:set(redis_key, "response", core.json.encode(response)) --3
        if not resp then
            return
        end
    end
end

  1. wyodrębnienie klucza idempotencji z żądania
  2. Uporządkowanie różnych elementów odpowiedzi w tabeli Lua
  3. Przechowywanie odpowiedzi zakodowanej w JSON w zestawie Redis

Testy wykazały, że działa zgodnie z oczekiwaniami.
Proszę spróbować:

curl -i -X POST -H 'Idempotency-Key: A' localhost:9080/response-headers\?freeform=hello
curl -i -H 'Idempotency-Key: B' localhost:9080/status/250
curl -i -H 'Idempotency-Key: C' -H 'foo: bar'  localhost:9080/status/250

Proszę również spróbować ponownie użyć niedopasowanego klucza idempotency, np., Adla trzeciego żądania. Ponieważ nie zaimplementowaliśmy jeszcze żadnego zarządzania błędami, otrzymają Państwo buforowaną odpowiedź na kolejne żądanie. Nadszedł czas, aby poprawić naszą grę.

Wdrażanie ścieżek błędów

Specyfikacja definiuje kilka ścieżek błędów:

  • Brak klucza Idempotency-Key
  • Idempotency-Key jest już używany
  • Żądanie jest zaległe dla tego klucza Idempotency-Key

Zaimplementujmy je jeden po drugim. Najpierw sprawdźmy, czy żądanie ma klucz idempotency. Proszę zauważyć, że możemy skonfigurować wtyczkę na podstawie trasy, więc jeśli trasa zawiera wtyczkę, możemy stwierdzić, że jest ona obowiązkowa.

function _M.access(conf, ctx)
    local idempotency_key = core.request.header(ctx, "Idempotency-Key")
    if not idempotency_key then
        return core.response.exit(400, "This operation is idempotent and it requires correct usage of Idempotency Key")
    end
    -- ...

Wystarczy zwrócić odpowiednie 400, jeśli brakuje klucza. To było łatwe.

Sprawdzanie ponownego użycia istniejącego klucza dla innego żądania jest nieco bardziej skomplikowane. Najpierw musimy przechowywać żądanie, a dokładniej odcisk palca tego, co stanowi żądanie. Dwa żądania są takie same, jeśli mają: tę samą metodę, tę samą ścieżkę, to samo ciało i te same nagłówki. W zależności od sytuacji, domena (i port) mogą, ale nie muszą być ich częścią. W mojej prostej implementacji pominę to.

Jest kilka problemów do rozwiązania. Po pierwsze, nie znalazłem istniejącego API do haszowania wartości core.request tak jak w innych językach, które znam, np., Java’s Object.hash(). Postanowiłem zakodować obiekt w JSON i skrócić ciąg znaków. Jednak istniejący core.request ma podelementy, których nie można przekonwertować na JSON. Musiałem wyodrębnić wspomniane części i przekonwertować tabelę.

local function hash_request(request, ctx)
    local request = {                                       --1
        method = core.request.get_method(),
        uri = ctx.var.request_uri,
        headers = core.request.headers(),
        body = core.request.get_body()
    }
    local json = core.json.stably_encode(request)           --2
    return ngx.encode_base64(json)                          --3
end

  1. Proszę utworzyć tabelę zawierającą tylko odpowiednie części
  2. The cjson tworzy JSON, którego elementy mogą być posortowane inaczej w kilku wywołaniach. W związku z tym skutkuje to różnymi hashami. The core.json.stably_encode rozwiązuje ten problem.
  3. Hash it

Następnie, zamiast przechowywać wartość logiczną po otrzymaniu żądania, przechowujemy wynikowy hash.

local hash = hash_request(core.request, ctx)
if next(resp) == nil then
    core.log.warn("No key found in Redis for Idempotency-Key, set it: ", redis_key)
    local resp, err = redis:hset(redis_key, "request", hash)
    if not resp then
        core.log.error("Failed to set data in Redis: ", err)
        return
    end
then -- ...

Odczytujemy hash przechowywany pod kluczem idempotency w drugiej gałęzi. Jeśli się nie zgadzają, wychodzimy z odpowiednim kodem błędu:

local data = normalize_hgetall_result(resp)
local stored_hash = data["request"]
if hash ~= stored_hash then
    return core.response.exit(422, "This operation is idempotent and it requires correct usage of Idempotency Key. Idempotency Key MUST not be reused across different payloads of this operation.")
end

Ostateczne zarządzanie błędami następuje zaraz potem. Proszę wyobrazić sobie następujący scenariusz:

  1. Przychodzi żądanie z kluczem idempotencji X
  2. Wtyczka pobiera odciski palców i przechowuje hash w Redis
  3. APISIX przekazuje żądanie do serwera upstream
  4. Zduplikowane żądanie ma ten sam klucz idempotencji, X
  5. Wtyczka odczytuje dane z Redis i nie znajduje buforowanej odpowiedzi

Upstream nie zakończył przetwarzania żądania; w związku z tym pierwsze żądanie nie dotarło jeszcze do pliku body_filter .

Do powyższego fragmentu dołączamy następujący kod:

if not data["response"] then
    return core.response.exit(409, " request with the same Idempotency-Key for the same operation is being processed or is outstanding.")
end

To wszystko.

Wnioski

W tym poście pokazałem prostą implementację funkcji Idempotency-Key na Apache APISIX za pomocą wtyczki. Na tym etapie można go ulepszyć: zautomatyzowane testy, możliwość konfigurowania Redis na podstawie trasy, konfigurowanie domeny / ścieżki jako części żądania, konfigurowanie klastra Redis zamiast pojedynczej instancji, korzystanie z innego magazynu K / V itp.

Mimo to implementuje specyfikację i ma potencjał, aby ewoluować w bardziej produkcyjną implementację.

Pełny kod źródłowy tego wpisu można znaleźć na stronie GitHub.