Strona główna / Artykuły / Zapytanie GraphQL, które wyczerpało zasoby bazy danych

Zapytanie GraphQL, które wyczerpało zasoby bazy danych

Jeden zagnieżdżony dokument GraphQL spowodował awarię bazy danych produkcyjnej. Dlaczego zawiodły limity przepustowości, timeouty HTTP oraz DataLoader — i jakie cztery zasady ostatecznie to opanowały.

1617 słów

Jeden zapytanie GraphQL POST wyłączyło API na prawie godzinę — i nie chodziło o zły úmysł

Zgiełk rozpoczął się tuż po ósmej w sobotni wieczór.

CPU serwera Postgres pracowało na pełnej mocy. Nie pozostały żadne wolne połączenia w puli. Wszystkie klienty napotykały problemy z czasem oczekiwania na odpowiedź. Publiczne API było całkowicie niedostępne w niedzielny wieczór, który to czas odpowiada prawie szczytowemu poziomowi wykorzystania tego produktu.

Ilość ruchu była zwyczajna — nawet nieco niska jak na ten dzień — więc nagły wzrost nie był prawdopodobny.

Od połowy tygodnia nic nie zostało wypuszczone, więc błąd podczas implementacji również był mało prawdopodobny.

Po około kwadransie dochodzenia udało się znaleźć przyczynę, która na pierwszy rzut oka wydawała się absurdalna.

Dokładnie jedna wywołanie HTTP. Jedno POST /graphql spędziło już około półtorej minuty na przetwarzaniu danych w bazie i nadal funkcjonowało. Ta pojedyncza operacja zużyła już więcej czasu bazy danych niż całe poprzednie kilka godzin normalnego ruchu.

Następna część opisuje strukturę dokumentu, dlaczego istniejące mechanizmy kontroli jej nie wykryły oraz cztery granice, które zostały ustalone kilka dni później. Na końcu znajduje się najgorszy scenariusz: jak mało wysiłku potrzebowałby zły aktor, by wykorzystać tę samą lukę.

Zapytanie

Dokument, choć skrócony, był strukturalnie dokładny i przypominał:

query {
  organizations {
    members {
      user {
        organizations {
          members {
            user {
              organizations {
                members {
                  user { id, email }
                }
              }
            }
          }
        }
      }
    }
  }
}

Nestowanie trwało siedem poziomów wewnątrz cyklu: organizacje przechowują informacje o członkostwie, członkostwa wskazują na osoby, a osoby z kolei należą do organizacji.

Te krawędzie są rzeczywiste i dwukierunkowe, więc modelowanie ich w ten sposób jest właściwe. Rozwiązujące narzędzia funkcjonowały prawidłowo. Każde oddzielne wywołanie SQL działało dobrze i szybko.

Szkoda wynikła z kombinatorycznego wzrostu.

Zwykłe konta znajdują się mniej więcej w trzech organizacjach. Zwykłe organizacje liczą około pięćdziesięciu osób. Rozszerzenie tego drzewa daje wyniki na poziomie:

  • głębokość 1 → ~3 organizacje
  • głębokość 2 → ~150 członkostw
  • głębokość 3 → ~150 użytkowników
  • głębokość 4 → ~450 organizacji
  • głębokość 5 → ~22,5 tys. członkostw
  • głębokość 6 → ~22,5 tys. użytkowników
  • głębokość 7 → ~67,5 tys. organizacji

Na dole znajduje się prawie siedemdziesiąt tysięcy elementów, z których każdy powoduje dodatkowe żądania o dane członkostwa. Rozszerzanie wciąż trwało, gdy operatorzy zatrzymali proces.

Krótki dokument tekstowy. Brak luk w zabezpieczeniach. Brak możliwości wstrzyknięcia ciągu znaków. Nic, co skaner mógłby zauważyć. Schemat po prostu odpowiadał grafowi, który publikował.

Przypadkowe, a nie celowe

Pochodzenie ma znaczenie dla tej lekcji.

Zadanie zostało wykonane przy użyciu autoryzowanej sesji pracownika firmy. Inżynier mobilny badał schemat w Apollo Studio, opracowując wymagania dotyczące danych interfejsu użytkownika, otwierając nawiasowane pola, aby sprawdzić, co tam znajduje się.

Nacisnęli przycisk „wykonać”, zauważyli, że interfejs się zawiesił, obwinili sieć i zamknęli kartę przeglądarki.

Zamknięcie karty nie wstrzymuje procesów na stronie serwera. Sokiet zniknął, ale wykonywanie dalsze trwało; baza danych kontynuowała przetwarzanie dziesiątek tysięcy ścieżek, a nikt nie czekał na odpowiedź.

Dowiedzieli się o awarii dopiero z tematu dotyczącego incydentu z poniedziałku. Nie doszło do niczego wrogiego: użyli narzędzia dostarczonego im przez firmę, zgodnie ze schematem, który również został im dostarczony.

Dlaczego istniejące zabezpieczenia nie zadziałały

Istniały mechanizmy kontroli. Żaden z nich nie odpowiadał temu trybowi awarii — i właśnie to jest sednem problemu.

Ograniczenia liczby żądań na IP. Ograniczenia te odnoszą się do liczby wywołań HTTP na minutę. Jedno wywołanie to jedno wywołanie – mechanizm ograniczający poprawnie je przepuścił.

Czasowe ograniczenia w Edge HTTP. Wybuchł timeout balansera trwający trzydzieści sekund; klient otrzymał błąd 504. Serwer SQL nadal działał, ponieważ zamknięcie połączenia nie anuluje zadań wykonywanych w tle. Klienci zostali oszukani, a serwer nadal zużywał zasoby.

DataLoader. Zespoły często traktują grupowanie zapytań jako środek bezpieczeństwa.

W ciągu ułamka sekundy DataLoader łączy duplikatyczne żądania do obiektów i naprawia klasyczny problem N+1. Na najniższym poziomie bazy danych tysiące zapytań jest łączonych w kilka zdań typu WHERE id IN (...).

Zagnieżdżenie ponad dwudziestu tysięcy identyfikatorów w jednym zapytaniu nie uwalnia tych wierszy. Liczba ruchów się zmniejsza, ale kardynalność pozostaje bez zmian; nadal istnieją głębsze poziomy. Grupowanie to tylko sposób na poprawę wydajności, a nie jej górna granica. Wydajność była błędnie traktowana jako ograniczenie.

Logowanie i tożsamość. Osoba wykonująca zapytanie była zalogowana. Tożsamość odpowiada na pytanie kto, a nie jak drogie to jest.

Prawa dostępu do poszczególnych pól. Każde wybrane pole było dostępne dla tego użytkownika. Autoryzacja zakończyła się sukcesem. Błąd wynikał z dużej liczby legalnych operacji przeszukiwania grafu, a nie z zakazanych danych.

Jak brzydko wygląda ta sama luka pod atakiem

Po przywróceniu systemu spędzono popołudnie na modelowaniu wrogiego wykorzystania tej samej luki. To właśnie to modelowanie jest powodem istnienia tego tekstu.

Przydomki umożliwiają jednemu dokumencie powtarzanie pola z różnymi argumentami:

mutation {
  a1: login(email: "target@company.com", password: "000001") { token }
  a2: login(email: "target@company.com", password: "000002") { token }
  a3: login(email: "target@company.com", password: "000003") { token }
  # ... two thousand more
}

Nadal tylko jedna prośba HTTP. Ograniczenia szybkości dotyczą właśnie tej jednej prośby. Licznik blokady po pięciu nieudanych próbach logowania znajdował się wewnątrz mechanizmu rozwiązywania adresów i poprawnie liczył tysiące prób.

Dzięki temu atak typu „password spray” został zatrzymany przypadkowo — licznik znajdował się akurat w miejscu, gdzie faktycznie odbywały się operacje.

Ogólny schemat pozostał otwarty. Każdy kosztowny mechanizm rozwiązywania adresów mógł być używany setki razy w ramach jednej prośby, co ignorują ograniczenia szybkości: wyszukiwanie, generowanie raportów, połączenia z podmiotami trzecimi. Lata stosowania mechanizmów ograniczania przepustowości w stylu REST napotkały API, które nie zachowuje się jak REST.

W środowisku produkcyjnym również pozostawała włączona funkcja introspekcji. Każdy mógł pobrać pełny graf typów — włączając wszystkie łącza — i stworzyć dokumenty o maksymalnym koszcie bez konieczności domysłów.

Nikt tego nie robił. Szczęście nie stanowi elementu kontroli.

Cztery ograniczenia wprowadzone później

Kilka dni pracy inżynierów doprowadziło do dodania następujących rozwiązań, uszeregowanych według wpływu.

1. Ograniczenie głębokości

Najprostsze rozwiązanie to odrzucenie dokumentów znajdujących się poza ustalonym limitem głębokości.

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  schema,
  validationRules: [depthLimit(7)]
});

Zbadano rzeczywisty ruch klientów. Najgłębsze, prawidłowe operacje zatrzymywały się na poziomie pięciu poziomów. Limit został podniesiony do siedmiu – to zapewnia przestrzeń na rozwój i pozwala odrzucić przypadki patologiczne przed uruchomieniem mechanizmów rozwiązywania.

Zasady walidacji sprawdzają skonstruowany dokument przed wykonaniem, więc odrzucenie jest praktycznie bezkosztowe.

2. Analiza kosztów zapytań

Głębokość sama w sobie nie wystarcza. Nawet niewielki dokument żądający dziesięciu tysięcy elementów listy stanowi ogromny obciążenie.

Kosztorys uwzględnia wagi poszczególnych pól, mnoży je przez argumenty listy i odrzuca wyniki przekraczające ustalony budżet.

const server = new ApolloServer({
  schema,
  plugins: [
    createComplexityPlugin({
      maximumComplexity: 1000,
      estimators: [
        fieldExtensionsEstimator(),
        simpleEstimator({ defaultComplexity: 1 })
      ]
    })
  ]
});

Podłączenie elementów jest proste; trudność polega na doborze odpowiednich wag. Wartość skalarowa kosztuje jednostkę, lista kosztuje first razy tyle, ile kosztuje jej element. Mechanizmy rozwiązywania korzystające z usług stron trzecich otrzymują ręcznie ustalone wagi, na przykład pięćdziesiąt.

Dwa dni dostrojewania na podstawie logów produkcyjnych dały przybliżone poprawne wartości. Przybliżenie wystarczyło.

3. Limity aliasów i liczby węzłów

Ustal ograniczenia liczby aliasów na operację oraz łącznej liczby węzłów AST.

Pięćdziesiąt aliasów w połączeniu z górną granicą liczby węzłów wystarczało do obsłużenia rzeczywistości; żaden uczciwy klient nie przekraczał tych wartości. Użycie tysięcy aliasów powoduje błędy walidacji zamiast skomplikowanych problemów z rozwiązywaniem zadań.

Pakietowe rozwiązania pomocne są. GraphQL Armor łączy w sobie kontrolę głębokości, kosztu, aliasów, dyrektyw oraz możliwości introspekcji. Zespoły tworzące nowe systemy powinny go zainstalować i dostroić, zanim będą projektować każdą część osobno.

4. Czas oczekiwania na zapytanie w bazie danych

Ostatnia linia obrony — i najszybsze rozwiązanie:

ALTER ROLE api_user SET statement_timeout = '10s';

Oświadczenia w ramach roli aplikacji wygasają po dziesięciu sekundach. Nie chodzi o sokiet HTTP – o sam SQL. Już to samo skróciłoby czas przerwy z około 94 sekund pracy bazy danych do dziesięciu, bez żadnej wiedzy na temat GraphQL.

Tę samą tydzień wyłączono introspekcję w środowisku produkcyjnym za pomocą konfiguracji – była to procedura higieniczna, której unikano od samego początku.

Lekcje dla wcześniejszej wersji tego samego zespołu

Trzy przypomnienia.

Kontroluj koszty, a nie tylko liczbę żądań. Liczenie żądań na minutę to nawyk charakterystyczny dla REST. GraphQL może ukryć dowolną ilość pracy w jednym POST-ze. Jeśli licznik widzi tylko żądania, to nie ma rzeczywistego licznika. Należy kontrolować koszty.

Terminy muszą zatrzymać pracę. Timeout HTTP na trzydzieści sekund wydawał się ochronny, ale jedynie ukrywał szkody przed użytkownikami, podczas gdy serwery nadal pracowały. Ustawiaj timeouty tam, gdzie odbywa się praca – dla Postgresa to statement_timeout w roli.

DataLoader nie ogranicza rozmiaru. Grupowanie zapytań eliminuje efekt N+1 i sprawia, że drogie zapytania są tańsze przy każdej transmisji. Nie sprawia to jednak, że stają się one mniejsze. Efektywność i górne granice to odrębne problemy; należy je oba uwzględnić.

Listwa kontrolna dla GraphQL w produkcji

Należy je szybko sprawdzić. Większość z nich zajmuje kilka minut.

  1. Introspekcja wyłączona w produkcji? W przeciwnym razie cała struktura schematu jest dostępna publicznie.
  2. Ustalony limit głębokości? Zmierz najgłębsze zapytania; ustaw wartość nieco powyżej tych wyników.
  3. Ustalony budżet kosztów? Sam limit głębokości nie uwzględnia długich list.
  4. Ustalony limit aliasów? Często pomijany; zapobiega chaotycznemu generowaniu zapytań.
  5. Rola w bazie danych statement_timeout skonfigurowana? Określa czas trwania zapytań; obejmuje całą tę klasę przypadków.
  • Ograniczenia szybkości działania opierają się na koszcie, a nie tylko na liczbie żądań? Ograniczenia oparte wyłącznie na liczbie żądań to bezużyteczność.
  • To zespół zaczął od jednego z sześciu rozwiązań. Obecnie używają wszystkich sześciu; cztery trafiły tu jeszcze tego samego popołudnia.

    Zasada

    Należy pamiętać o tej różnicy:

    Końce punktowe REST są od początku projektowane tak, by ograniczać pracę. GraphQL umożliwia klientom to samo. Chyba że serwer przywróci wyraźne ograniczenie, to takie ograniczenie nie zostało przeniesione — zostało usunięte.

    Wcześniejsze metody obrony zakładały, że to serwery decydują o tym, jak drogie może być żądanie. GraphQL przekazuje tę kontrolę osobie, która pisze dokument — co jest jego mocną stroną i częstym powodem do przyjęcia. Odpowiedzialność musi zostać przeniesiona z powrotem do kodu; framework tego nie zrobi.

    Prawie godzina przerwy w pracy rozpoczęła się, gdy jeden z kolegów nacisnął przycisk „run” w studiu. To wersja przyjazna. Wersja agresywna wymagała jedynie konta i krótkiej myśli; nigdy się to nie wydarzyło tylko dlatego, że nikt tego nie spróbował.

    Najpierw sprawdź ustawienia introspekcji. Zajmuje to kilka sekund, a wiele zespołów już zna odpowiedź.