Wróć do bloga

GTP-C i GTP-U — parsowanie protokołów sieciowych na skali krajowej sieci telekomunikacyjnej

Jak zbudowałem system analizujący ruch GTP w czasie rzeczywistym — od przechwytywania ramek przez kernel, przez korelację stanową GTP-C, po skalowanie do 2M ramek/s.

Pracowałem przy systemie analizującym ruch sieciowy w czasie rzeczywistym w skali całej krajowej sieci telekomunikacyjnej. System parsował protokół GTP — protokół tunelowania używany w sieciach mobilnych do przesyłania danych abonentów. W tym artykule opisuję jak to wygląda od środka: jak ramki trafiają do aplikacji, czym się różni GTP-C od GTP-U, dlaczego korelacja sesji jest trudna i jak doszliśmy do rozwiązania obsługującego 2 miliony ramek UDP na sekundę.

Jak ramki trafiają do aplikacji — problem kopii danych

Pierwszym wyzwaniem jest samo przechwytywanie ruchu. Zanim parser może przetworzyć ramkę, musi ją skądś dostać — i ta kwestia ma ogromny wpływ na wydajność.

Standardowa ścieżka pakietu w Linuksie:

NIC → DMA → kernel socket buffer → copy → userspace

Przy milionach ramek na sekundę każda dodatkowa kopia między kernelem a przestrzenią użytkownika to realne wąskie gardło — czas CPU, presja na L2/L3 cache, zużycie pamięci.

Co rozważałem

Rozważałem kilka podejść:

  • DPDK — pełny kernel bypass. Sterownik w userspace, DMA prosto do pamięci aplikacji. Najszybsze podejście — ale wymaga wsparcia sterownika NIC. W naszym przypadku karta nie wspierała tego.
  • XDP — program BPF uruchamiany w kernelu na wczesnym etapie przetwarzania pakietu, przed stosem sieciowym. Szybki, ale to nadal kernel space, nie bypass. Również wymagał wsparcia sterownika, którego nie mieliśmy.
  • AF_PACKET z PACKET_MMAP — optymalizacja na poziomie kernela. Kernel tworzy ring buffer i mapuje go do przestrzeni użytkownika przez mmap(). Kernel kopiuje ramki z bufora sterownika do ring buffera, ale ten ring buffer jest mmapowany do userspace — więc aplikacja czyta je bez kolejnej kopii kernel→user.

Własna aplikacja C z JNA

Napisałem własną aplikację w C, która czytała ramki z AF_PACKET ring buffera i przekazywała je do parsera w Javie. Ścieżka pakietu:

NIC → DMA → AF_PACKET ring buffer (mmap) → aplikacja C → JNA → Java parser

Do komunikacji C↔Java użyłem JNA (Java Native Access) zamiast JNI. JNA jest dużo prostsze — nie wymaga pisania nagłówków ani ręcznego zarządzania referencjami JVM. Ceną jest overhead: każde wywołanie przechodzi przez warstwę refleksji. JNI byłoby szybsze, ale znacznie bardziej złożone.

Testy wypadły obiecująco — ale na produkcję trafiło inne rozwiązanie (opisuję je w sekcji o skalowaniu).

Stos protokołów — od ramki do danych abonenta

Każda przechwycona ramka to kilka warstw do odkodowania:

[ Ethernet frame ]
  └── [ IP header ]
        └── [ UDP header ]
              └── [ GTP header ]
                    └── [ Inner IP ] ← rzeczywiste dane abonenta
                          └── [ ICMP / TCP / UDP ]

Parsowanie zaczyna się od surowej ramki ethernetowej (warstwa 2), następnie nagłówek IP (warstwa 3), UDP (warstwa 4) — i dopiero tutaj zaczyna się GTP.

GTP (GPRS Tunnelling Protocol) to protokół tunelowania sieci mobilnych. Działa na porcie UDP 2152 (GTP-U) i 2123 (GTP-C).

Dwa typy GTP — i dlaczego oba są niezbędne

GTP-C (Control Plane)

GTP-C to sygnalizacja — zarządza sesjami. Gdy abonent łączy się z siecią, GTP-C tworzy sesję i przypisuje TEID (Tunnel Endpoint Identifier) — unikalny identyfikator tunelu. Kluczowe typy wiadomości:

  • Create Session Request / Response — otwarcie sesji
  • Delete Session Request / Response — zakończenie sesji
  • Modify Bearer Request / Response — modyfikacja parametrów

GTP-U (User Plane)

GTP-U przenosi faktyczne dane abonenta. Każdy pakiet zawiera:

  • TEID tunelu (łączy go z sesją z GTP-C)
  • Wewnętrzny pakiet IP z urządzenia abonenta

Kluczowy problem: pakiet GTP-U sam w sobie jest anonimowy — zawiera tylko numer TEID. Bez tablicy sesji zbudowanej z GTP-C nie wiesz do kogo należy.

Problem korelacji GTP-C

To jest serce całego wyzwania technicznego.

Sesja GTP-C składa się z par wiadomości. Create Session Request przychodzi od jednego węzła, Create Session Response wraca od drugiego — z zamienionymi adresami src/dst i uzupełnionymi TEID-ami. Dopiero razem dają kompletny obraz sesji.

Utrata stanu

Sesje GTP-C mogą trwać godzinami lub dniami. Jeśli system straci stan (restart, awaria węzła), nie da się poprosić sieci o ponowne wysłanie informacji o sesjach. Trzeba czekać aż abonent sam odnowi połączenie — a to może trwać bardzo długo.

Trwałość stanu i odporność na restarty to wymagania absolutnie kluczowe w tego typu systemach.

Delete Session

Każda zakończona sesja musi być zwolniona z tablicy korelacji. Delete Session Request/Response sygnalizuje koniec sesji — przy milionach aktywnych sesji brak zwalniania stanu to szybka droga do OOM.

Skalowanie i routing sesji

Korelacja GTP-C wymaga by oba kierunki tej samej sesji (request i response) trafiły do tego samego węzła przetwarzającego. Request i response mają zamienione src/dst — naiwne hashowanie po samym IP src skieruje je do różnych węzłów.

Rozwiązanie: hashowanie po parze IP niezależnie od kierunku — hash(min(src,dst), max(src,dst)). Oba kierunki zawsze trafiają do tej samej instancji.

Rozwiązanie produkcyjne: N instancji + reguły sieciowe

Produkcyjnym rozwiązaniem było uruchomienie N niezależnych instancji parsera i podział ruchu na poziomie systemu operacyjnego za pomocą reguł sieciowych. Reguły kierowały ruch do konkretnych instancji na podstawie pary IP src/dst. Każda instancja budowała własną tablicę korelacji GTP-C — bez dzielenia stanu między procesami.

To podejście rozwiązuje jednocześnie dwa problemy: wydajność (skalowanie poziome) i poprawność korelacji (ta sama para IP zawsze do tej samej instancji).

Wczesne odrzucanie śmieci

Nie każda ramka, która dociera, jest warta przetwarzania. Na sieci krajowej pojawia się ruch, który nie jest GTP — fragmenty, ramki z błędami. Przepuszczanie ich przez cały pipeline to zmarnowany CPU.

Aplikacja robiła fast-path rejection na samym wejściu — sprawdzenie portu UDP, wersji GTP i długości nagłówka zanim ramka wejdzie w kosztowne parsowanie. Co nie pasowało, leciało od razu do kosza.

Wyniki

System osiągał 2 miliony ramek UDP na sekundę przy N instancjach rozdzielonych regułami sieciowymi. Co miało największy wpływ:

  • Skalowanie poziome — N instancji bez dzielenia stanu
  • Podział ruchu regułami sieciowymi OS — bez dodatkowej warstwy proxy
  • Fast-path rejection — większość nieistotnych ramek eliminowana przed parserem
  • Brak dzielenia stanu między instancjami — minimalna synchronizacja