Cal.com Cold Start Resolution

Tło
Przez pewien czas aplikacja Cal.com mogła mieć długie czasy ładowania przy cold startach — od 7 sekund do 30 sekund w skrajnych przypadkach. To zachowanie nie występowało zawsze, bo takie są już cold starty, ale pojawiało się na tyle często, że stało się problemem, którym musieliśmy zająć się jak najszybciej.
Dla tych, którzy nie są zaznajomieni z cold startami, jest mnóstwo materiałów opisujących ten problem ogólnie, więc nie będziemy się w to zagłębiać. Chcemy w tym wpisie wyjaśnić dokładną przyczynę źródłową, która wpływała konkretnie na nasz system, a którą wywoływały cold starty w środowisku serverless.
Ustalenia i eksperymenty
Na początek spójrzmy na poniższy log z Vercel:

Dla funkcji /api/trpc/[trpc] widzimy Cold Boot Duration na poziomie 298 ms, ale Execution Duration wynoszący 7,57 s. Dziwne, prawda? Sam cold boot nie jest problemem, tylko to, co ładuje się podczas cold boota, powoduje, że Execution Duration mocno rośnie.
Więc od czego zaczynamy, żeby znaleźć przyczynę źródłową?
Hipoteza 1: Rozmiar bundle’a funkcji
Po zobaczeniu Function Size na poziomie 33,1 MB nasz zespół, społeczność oraz ludzie z Vercel i Prisma od razu zabrali się do pracy i zaczęli analizę bundle’a oraz logowanie kompilacji, żeby znaleźć pakiety obciążające nasze funkcje. Mimo że ten Function Size mieścił się poniżej limitu 50MB, wierzyliśmy, że mógł być przynajmniej częściowo winny.
Zidentyfikowaliśmy kilka dużych zależności ładowanych w ramach pobierania naszej sesji, które udało się odchudzić (PR), a zależności naszego app store zaczęliśmy ładować leniwie (PR).
Wydajność poprawiła się przy każdym żądaniu po tych zmianach, ale nie był to spadek o kilka sekund, na który liczyliśmy. Co jeszcze mogło to powodować? Czy Prisma wolno łączy się przy cold starcie w naszym setupie serverless?
Hipoteza 2: Prisma
W produkcji mieliśmy już uruchomione Prisma Data Proxy, żeby obsługiwać pooling połączeń, więc byliśmy sceptyczni, że to jest problem, ale mimo to chcieliśmy to dokładnie sprawdzić i upewnić się.
W eksperymentalnej gałęzi uruchomiliśmy testowy endpoint odseparowany od naszych tras API i stron w osobnej trasie app (z użyciem nowego App Routera z Next.js), który zwracał tylko liczbę użytkowników w systemie. Wydajność tej trasy przy cold startach mogła sięgać 1 sekundy, co pokazało nam, że Prisma może mieć niewielkie opóźnienie przy cold startach, ale znowu — nie były to 7–15 sekund, które widzieliśmy średnio.
Hipoteza 3: _app i _document
Z analizy bundle’a, którą przeprowadziliśmy, było jasne, że nasza aplikacja ładuje bardzo dużo zależności. To podsunęło nam pomysł, żeby ograniczyć to, co ładuje się przy każdym żądaniu do API. Wiedząc, że trasy API w Next.js ładują plik _app.tsx, zdecydowaliśmy się wdrożyć PageWrapper zawierający wszystkie zależności potrzebne dla tras innych niż API (PR).
Znowu — to fajna poprawa pod kątem optymalizacji zależności, ale nadal mieliśmy długie czasy oczekiwania.
Przyczyna źródłowa
Po wypuszczeniu mnóstwa PR-ów skupionych na wydajności, które pomogły w organizacji kodu, szybkości i rozmiarze bundle’a, nadal nie znaleźliśmy złotego strzału. Nadszedł moment, żeby zatoczyć koło i pomyśleć w kategoriach wąskich gardeł, bo wiele problemów z wydajnością wiąże się właśnie z wąskimi gardłami, prawda?
Kierując się tym tokiem myślenia, uruchomiliśmy test polegający na wydzieleniu najprostszego routera tRPC do własnej trasy API. W naszym przypadku był to publiczny router. Po wdrożeniu endpoint /api/trpc/viewer.public.i18n stał się teraz /api/trpc/public/i18n. Zobaczyliśmy spadek czasu cold startu dla tej trasy z 15 sekund do 2 sekund. To wyglądało na potwierdzenie, że nasz pojedynczy router tRPC rzeczywiście był wąskim gardłem.
Mówiąc prosto, mieliśmy zbyt wiele routerów tRPC wrzuconych do głównego routera viewer wystawionego pod trasą /api/trpc/[trpc]. To oznaczało, że już pierwsze wywołanie tej funkcji — niezależnie od tego, czy chodziło o wczytanie terminów i18n, czy pobranie harmonogramu użytkownika — musiało zaimportować wszystkie 20 routerów i wszystkie ich zależności.
Jak widać tutaj w PR-ze, który ostatecznie rozwiązał większość naszych problemów z długimi cold startami, pojawiło się podejrzenie, że router slots jest zbyt duży i stanowi źródło problemów. Niestety po wydzieleniu routera slots do własnej trasy API Next.js nadal widzieliśmy duże rozmiary bundle’ów i długie czasy cold startu. Z tych powodów ten PR został na razie odłożony na bok, a my poszliśmy dalej tropem innych hipotez.
Po odkryciu, że trasa i18n ładuje się przy cold starcie w 2 sekundy, wspomniany PR został rozwinięty tak, aby wszystkie routery tRPC miały własną trasę API. Po podziale Vercel tworzy osobne funkcje dla każdej trasy.
Więc dlaczego podzielenie routerów tRPC na własne trasy API Next.js naprawia problem? Prosta odpowiedź w naszym przypadku jest taka, że nawet jeśli strona wywołuje 5 różnych tras tRPC, żeby wczytać dane, będą one działać równolegle, więc efekt ładowania zależności dla wszystkich routerów nie kumuluje się. Nadal możemy teraz widzieć czasy cold startu rzędu 2–3 sekund dla dowolnej pojedynczej trasy tRPC, ale ponieważ są one wywoływane równolegle przez przeglądarkę, łączny czas oczekiwania może wynieść 3 sekundy, a nie 7–15 sekund (albo 30 sekund w skrajnych przypadkach).
Wiemy, że wciąż jest jeszcze więcej pracy do wykonania, żeby jeszcze bardziej skrócić te czasy, ale dotychczasowe redukcje były znaczące.
Oto issue promujące potencjalne zmiany dla tRPC w środowiskach serverless.
Najważniejsze wyniki

Podziękowania
Ogromne podziękowania i wielki ukłon dla wszystkich, którzy przyczynili się do rozwiązania tego problemu. Od pisania własnych skryptów, które pomagały ustalić, jakie zależności spowalniają nasz system, po otwieranie PR-ów — naprawdę doceniamy wsparcie i wysiłek włożony w rozwiązanie tych problemów. Mamy nadzieję, że ten wpis i PR-y dadzą trochę wglądu w eksperymenty, które przeprowadziliśmy, oraz w przyczynę źródłową, którą ostatecznie znaleźliśmy.
Specjalne podziękowania dla Juliusa Marminge z tRPC.io, Guillermo Raucha, Malte Ubl i Jimmy'ego Lai z Vercel oraz Lucasa Smitha. 🙏
Co dalej
Choć to duże osiągnięcie, nadal ciężko pracujemy zarówno nad dalszym skracaniem cold startów, jak i nad poprawą doświadczenia self-hostingu bez architektury serverless. Bądźcie z nami!
