kim-bu.uz — telefon raqami qaysi tashkilotga tegishli ekanini ko'rsatadigan sayt. Stack oddiy: Next.js 15 (App Router) + PostgreSQL + Prisma, hammasi bitta VPS'da Docker Compose bilan ishlaydi, oldida Cloudflare turadi.
Bir kuni o'zim sezib qoldim: ba'zi sahifalarni ochganda uzoq kutib qolaman. Doim emas, lekin tez-tez.
Bu maqolada shu muammo qanday o'lchangani, qaysi taxminlar noto'g'ri chiqqani va oxirida nima qilinganini yozaman.
Eng muhim dars: taxmin qilma — o'lcha. Men bu yo'lda kamida ikki marta noto'g'ri gipoteza qildim.
1. Birinchi qadam: o'lchash
Brauzerda "sekin" deyish hech narsa bermaydi. Menga aniq raqamlar kerak edi: qaysi sahifa, qancha vaqt, cache'dan keldimi yoki yo'q.
Buning uchun curlning -w opsiyasi juda qulay:
curl -sS -o /dev/null -D headers.txt \
-w "ttfb=%{time_starttransfer} total=%{time_total}\n" \
https://kim-bu.uz/ru
grep -iE 'x-nextjs-cache|cache-control|cf-cache-status' headers.txtBu yerda eng muhim uchta header:
| Header | Nimani ko'rsatadi |
|---|---|
x-nextjs-cache | Next.js sahifani cache'dan berdimi (HIT), eskirgan cache'dan (STALE) yoki qaytadan render qildimi (MISS) |
cache-control | Sahifa qancha vaqt cache'da yashashi mumkin |
cf-cache-status | Cloudflare javobni o'zidan berdimi (HIT) yoki origin'ga bordimi (DYNAMIC, MISS) |
Birinchi natijalar:
| Holat | TTFB |
|---|---|
Next.js cache HIT | 0.4–1 s |
Cache MISS / STALE (render) | 6–12 s |
| Qidiruv sahifasi (doim render) | 5–11 s |
/api/search/suggest (o'sha qidiruv, lekin render'siz) | 0.4 s |
/api/health (DB so'rovi bilan) | 0.4 s |
Va har bir HTML javobda:
cf-cache-status: DYNAMICYa'ni Cloudflare bitta ham HTML sahifani cache qilmayotgan edi — har bir so'rov serverga borardi.
2. Sabab qayerda? Imkoniyatlarni bittalab yopish
Jadvalga qarasak, qiziq narsa bor: qidiruv API'si 0.4 soniyada javob beradi, lekin xuddi shu qidiruvni ko'rsatadigan sahifa 10 soniya oladi.
Demak:
- DB sekin emas — API o'sha so'rovni tez bajaryapti.
- Muammo render jarayonida.
Keyingi savol: kod sekinmi?
Buni tekshirish uchun lokal kompyuterda production build qildim va o'sha sahifalarni o'lchadim:
npm run build
npx next start -p 3100| Sahifa | Lokal (prod build) | Production server |
|---|---|---|
| Raqam sahifasi | 20–40 ms | 6–12 s |
| Qidiruv | 16–40 ms | 5–11 s |
250 barobar farq. Bitta kod — ikki xil natija. Demak kodning o'zi emas, muhit aybdor.
Build log'da yana bir qiziq narsa ko'rdim:
● /[locale]/raqam/[number] 5.38 kB 261 kB 5m 1ySahifada export const revalidate = 3600 yozilgan, lekin Next.js uni 5 daqiqa deb hisoblayapti. Bu birinchi aniq muammo edi.
3. Muammo №1: bitta unstable_cache butun saytning ISR'ini qisqartirgan
Next.js App Router'da sahifaning haqiqiy revalidate vaqti — u render paytida ishlatgan barcha cache'lar ichidagi eng kichik qiymat.
Layout'da sayt sozlamalari (reklama, analitika ID'lari) shunday olinardi:
export const getSiteSettings = unstable_cache(
fetchSiteSettings,
["site-settings"],
{ revalidate: 300, tags: ["site-settings"] },
);Layout esa har bir sahifada render bo'ladi. Natijada 300 soniya hamma sahifaga "yuqtirilgan": raqam sahifasi ham, tashkilot sahifasi ham, bosh sahifa ham har 5 daqiqada eskirib, qaytadan render bo'lishi kerak edi.
Qizig'i shundaki, bu qisqa TTL'ga hech qanday ehtiyoj yo'q edi — admin panelda sozlama saqlanganda cache baribir darhol tozalanadi:
revalidateTag("site-settings");Yechim
export const getSiteSettings = unstable_cache(
fetchSiteSettings,
["site-settings"],
// Every save revalidates the "site-settings" tag, so a long TTL is safe. A short one would
// cap every page's ISR lifetime (they all render the layout) and force re-renders.
{ revalidate: 3600, tags: ["site-settings"] },
);Natijada sahifalar 12 barobar kamroq qayta render bo'ladi, cache-control ham o'zgardi:
Oldin: cache-control: s-maxage=300, stale-while-revalidate=...
Keyin: cache-control: s-maxage=3600, stale-while-revalidate=...Dars: layout'dagi har bir
unstable_cacheyokifetch(..., { next: { revalidate } })butun sayt uchun yuqori chegara bo'ladi. Build log'dagi "Revalidate" ustuniga qarab turing.
4. Muammo №2: Cloudflare HTML'ni cache qilmayapti
Sahifa Next.js cache'idan kelganda ham 0.4–1 soniya ketardi. Sababi oddiy: har bir so'rov Cloudflare'dan o'tib, serverga borib qaytardi.
Cloudflare default holatda HTML'ni cache qilmaydi — faqat JS, CSS, rasm kabi statik fayllarni. HTML uchun alohida Cache Rule yozish kerak.
Lekin shunchaki rule yoqish yetarli emas edi. Header'larni ko'rib chiqsam, ikkita to'siq bor edi.
To'siq 1: har bir javobda Set-Cookie
set-cookie: NEXT_LOCALE=uz; Path=/; SameSite=laxnext-intl har bir javobga til cookie'sini qo'yardi. Cloudflare esa Set-Cookie bor javobni cache qilmaydi — va bu to'g'ri, aks holda bir foydalanuvchining cookie'si boshqasiga ketib qolishi mumkin.
To'siq 2: Accept-Language bo'yicha redirect
curl -H "Accept-Language: ru-RU" https://kim-bu.uz/
# 307 → https://kim-bu.uz/ruSayt brauzer tiliga qarab foydalanuvchini boshqa URL'ga yo'naltirardi. Agar Cloudflare / sahifasining o'zbekcha versiyasini cache qilsa, rus tilidagi foydalanuvchi redirect'ni ko'rmay, to'g'ridan-to'g'ri o'zbekcha sahifani oladi.
Ya'ni bitta URL har xil foydalanuvchiga har xil javob beradi — bu CDN cache uchun eng yomon holat.
Yechim: til faqat URL'dan olinadi
export const routing = defineRouting({
locales: ["uz", "uz-Cyrl", "ru", "en"],
defaultLocale: "uz",
localePrefix: { mode: "as-needed" },
// The locale comes from the URL only: no Accept-Language redirects and no NEXT_LOCALE
// cookie. Each URL then always returns the same page, so Cloudflare can cache the HTML.
localeDetection: false,
localeCookie: false,
// ...
});Endi / doim o'zbekcha, /ru doim ruscha. Foydalanuvchi tilni switcher orqali almashtiradi.
Bu SEO uchun ham yaxshi: Google avtomatik til redirect'larini tavsiya qilmaydi, chunki crawler sahifaning boshqa til versiyalarini ko'ra olmay qolishi mumkin.
Cloudflare Cache Rule
Caching → Cache Rules → Create rule, expression:
(http.host eq "kim-bu.uz"
and not starts_with(http.request.uri.path, "/api")
and not starts_with(http.request.uri.path, "/admin")
and not starts_with(http.request.uri.path, "/monitoring")
and not http.request.uri.path in {"/qidiruv" "/uz-Cyrl/qidiruv" "/ru/poisk" "/en/search"}
and not any(http.request.headers["rsc"][*] eq "1"))Sozlamalar:
- Cache eligibility: Eligible for cache
- Edge TTL: Use cache-control header if present, bypass cache if not
- Browser TTL: Respect origin
- Serve stale content while revalidating: yoqilgan
Ikki narsaga alohida e'tibor bering.
1. rsc header sharti. Next.js client-side navigatsiyada xuddi shu URL'ga RSC: 1 header bilan so'rov yuborib, HTML emas, RSC payload oladi. Next.js buni Vary: rsc bilan belgilaydi, lekin Cloudflare Varyni hisobga olmaydi. Bu shart bo'lmasa, Cloudflare RSC payload'ni cache qilib, keyin oddiy foydalanuvchiga HTML o'rniga berib yuborishi mumkin.
2. "Respect origin". Edge TTL'ni Next.js o'zi beradi: ISR sahifalar uchun s-maxage=3600, dinamik sahifalar (404, qidiruv) uchun no-store. Shunda Cloudflare qaysi sahifani qancha saqlashni Next.js'dan "so'raydi" va dinamik sahifalarni tasodifan cache qilib qo'ymaydi.
Natija:
/ #1 cf-cache-status: MISS ttfb=1.27s
/ #2 cf-cache-status: HIT ttfb=0.26s5. Muammo №3: sekinlik vaqti-vaqti bilan paydo bo'ladi
Cache ishladi, lekin render o'zi hali ham ba'zan 8–20 soniya olardi. Birinchi gipotezam: server CPU yetishmayapti.
Serverda tekshirdim:
load average: 1.40, 2.09, 2.59
%Cpu(s): 3.9 us, 5.9 sy, 88.2 id
kimbu-app 0.01% 263.2MiBServer deyarli bo'sh. Gipoteza rad etildi.
Ikkinchi qadam — so'rovni to'rt xil yo'l bilan, server ichidan o'lchash. Bu sekinlik qaysi bo'g'inda paydo bo'layotganini ajratib beradi:
# 1. to'g'ridan app'ga
curl -w "%{time_starttransfer}\n" http://localhost:3000/qidiruv?q=test
# 2. app'ga, lekin haqiqiy Host header bilan
curl -H "Host: kim-bu.uz" http://localhost:3000/qidiruv?q=test
# 3. server'dagi nginx orqali
curl --resolve kim-bu.uz:443:127.0.0.1 https://kim-bu.uz/qidiruv?q=test
# 4. Cloudflare orqali
curl https://kim-bu.uz/qidiruv?q=test| Yo'l | Vaqt |
|---|---|
App, localhost | 0.14–0.30 s |
App, Host: kim-bu.uz | 0.14–1.47 s |
| nginx orqali | 0.10–0.62 s |
| Cloudflare orqali | 0.27–0.45 s |
Hammasi tez. Bir soat oldin 10 soniya bo'lgan so'rov endi 0.3 soniya.
Demak sekinlik doimiy emas. Qachon paydo bo'lganini deploy tarixiga solishtirdim:
17:35 → 17:42 deploy
17:58 → 18:05 deploy
18:20 → 18:29 deploy
19:12 → 19:19 deployMen o'lchagan sekin natijalarning deyarli hammasi deploy'lar paytida yoki darhol keyin bo'lgan edi.
Nega? Eski deploy jarayoni shunday edi:
deploy:
git pull
docker compose up -d --buildYa'ni Next.js build production serverning o'zida ishlardi. Bu 7–9 daqiqa davomida 4 yadroli serverni band qiladi — va shu serverda yana 20 ta boshqa konteyner ham bor.
Buning ustiga har deploy'dan keyin:
- yangi konteyner "sovuq" ishga tushadi — birinchi render'lar sekin;
revalidateendpoint hamma sahifani eskirgan deb belgilaydi — birinchi tashriflar qaytadan render kutadi.
Uch narsa bir vaqtga to'g'ri kelsa, 10–20 soniyalik javoblar paydo bo'ladi.
6. Yechim: build'ni serverdan olib chiqish
Yangi deploy jarayoni:
GitHub Actions Server
───────────── ──────
CI (lint, test, build) ✓
↓
Docker image build
↓
GHCR'ga push ──────────────────→ docker pull
↓
docker compose up -d --no-build
↓
health check
↓
revalidate
↓
warm-up (84 ta sahifa)
↓
Cloudflare cache purgeImage CI'da build qilinadi
- name: Build and push app image
uses: docker/build-push-action@v6
with:
context: .
target: runner
push: true
tags: ${{ env.APP_IMAGE }}:${{ env.SHA }},${{ env.APP_IMAGE }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_SITE_URL=${{ secrets.NEXT_PUBLIC_SITE_URL }}
# ...
secrets: |
SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}Bu yerda bitta nozik joy bor: NEXT_PUBLIC_* o'zgaruvchilar build vaqtida client bundle'ga yozib qo'yiladi. Oldin ular serverdagi .envdan olinardi, endi GitHub secret'lardan olinadi. Ularni ko'chirishni unutsangiz, sayt bo'sh analitika ID'lari bilan chiqib ketadi. Shuning uchun workflow'da oldindan tekshiruv qo'ydim:
- name: Check build secrets
run: |
if [ -z "$NEXT_PUBLIC_SITE_URL" ]; then
echo "::error::NEXT_PUBLIC_SITE_URL secret is not set"
exit 1
fiSentry token esa build-arg emas, build secret sifatida uzatiladi — shunda u image tarixida qolmaydi:
RUN --mount=type=cache,target=/app/.next/cache \
--mount=type=secret,id=SENTRY_AUTH_TOKEN,required=false \
if [ -f /run/secrets/SENTRY_AUTH_TOKEN ]; then \
export SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN)"; \
fi && \
npm run buildServer faqat pull qiladi
deploy-image:
for ref in $(APP_IMAGE_REF) $(BUILDER_IMAGE_REF); do \
for i in 1 2 3; do docker pull $$ref && break; [ $$i = 3 ] && exit 1; sleep 5; done; \
done
docker tag $(APP_IMAGE_REF) kimbu-app:latest
docker tag $(BUILDER_IMAGE_REF) kimbu-builder:latest
docker rmi $(APP_IMAGE_REF) $(BUILDER_IMAGE_REF)
docker compose up -d --no-build
docker image prune -fPrivate GHCR'dan pull qilish uchun alohida token yaratish shart emas: job'ning GITHUB_TOKENi SSH orqali serverga uzatiladi, login qilinadi, pull'dan keyin docker logout. Token job tugashi bilan o'z-o'zidan eskiradi.
Warm-up
Deploy'dan keyin eng ko'p ochiladigan sahifalar foydalanuvchidan oldin render qilinadi:
PATHS="/ /uz-Cyrl /ru /en"
PATHS="$PATHS $(curl -fsS -H 'Host: kim-bu.uz' -H 'cf-ray: warmup' \
http://localhost:3000/sitemap.xml \
| grep -oE '<loc>https://kim-bu\.uz[^<]*/(kategoriya|category)/[^<]*</loc>' \
| sed -E 's#</?loc>##g; s#https://kim-bu\.uz##')"
for pass in 1 2; do
for p in $PATHS; do
curl -s -o /dev/null -H 'Host: kim-bu.uz' -H 'cf-ray: warmup' "http://localhost:3000$p"
done
sleep 3
doneIkkita nozik joy:
- So'rovlar Cloudflare orqali emas, to'g'ridan app'ga yuboriladi. Aks holda Cloudflare eski sahifani o'zidan berib yuboradi va app hech narsani render qilmaydi.
- Ikki marta yuriladi.
stale-while-revalidatesababli birinchi so'rov eski sahifani oladi va faqat fonda render'ni boshlaydi. Ikkinchi so'rov yangi sahifani cache'da topadi.
Cloudflare purge — bu majburiy
Cloudflare HTML'ni cache qila boshlagandan keyin yangi xavf paydo bo'ldi.
Eski HTML ichida eski build'ning JS chunk nomlari yozilgan:
<script src="/_next/static/chunks/4909-fcf4f3195fcc28f8.js"></script>Yangi konteynerda bu fayllar yo'q. Agar Cloudflare deploy'dan keyin yana bir soat eski HTML'ni bersa, foydalanuvchi sahifani ochadi, JS 404 qaytaradi va sayt buzilib ko'rinadi. Server action'lar ham xuddi shunday: Failed to find Server Action.
Shuning uchun deploy'ning oxirgi qadami:
- name: Purge Cloudflare cache
run: |
curl -fsS -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE/purge_cache" \
-H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'Token uchun faqat bitta ruxsat yetarli: Zone → Cache Purge → Purge.
Dars: CDN'da HTML cache qilsangiz, deploy pipeline'ingiz cache'ni tozalashni bilishi shart. Aks holda eski HTML + yangi static fayllar = buzilgan sahifa.
7. Kutilmagan to'siq: GHCR'dan pull uzilib qoladi
Birinchi yangi deploy muvaffaqiyatsiz tugadi:
failed to copy: read tcp [2a02:...]:37474->[2606:50c0:8002::154]:443:
read: connection reset by peerQayta urinish — o'sha xato. Qo'lda docker pull — o'sha xato. Qiziq joyi: kichik layer'lar yuklanadi, katta layer'da uziladi.
Gipoteza: MTU
Birinchi o'ylaganim — MTU muammosi. MTU (Maximum Transmission Unit) — tarmoqda bitta paketning maksimal hajmi, odatda 1500 bayt. Agar yo'ldagi biror tarmoq kichikroq paketni o'tkazsa va "paket katta" degan ICMP xabari yo'qolib qolsa (PMTU blackhole), kichik so'rovlar o'tadi, katta ma'lumot oqimi esa tiqilib qoladi. Xuddi shu belgi.
Tekshirdim:
ping -6 -c 2 -M do -s 1452 2606:50c0:8002::154 # ✓ javob keldi
curl -6 https://raw.githubusercontent.com/... # ✓ 900 KB yuklandiKatta paketlar o'tyapti, IPv6 ishlayapti. Gipoteza rad etildi.
Aniq sababini oxirigacha topa olmadim: curl bilan IPv6 orqali fayl yuklanadi, lekin Docker daemon'ning katta layer'ni IPv6 orqali yuklashi doim uziladi. Lekin bitta narsa aniq edi — IPv4 orqali hammasi ishlaydi.
Yechim: faqat bitta host uchun IPv4
Layer'lar pkg-containers.githubusercontent.com host'idan yuklanadi. Serverda faqat shu host uchun /etc/hostsga IPv4 manzil yozildi:
IP=$(getent ahostsv4 pkg-containers.githubusercontent.com | awk 'NR==1{print $1}')
echo "$IP pkg-containers.githubusercontent.com" | sudo tee -a /etc/hostsTekshiruv:
Status: Downloaded newer image for ghcr.io/.../kim-bu-app:3c8b50c...
real 0m49.923sButun serverdagi IPv6'ni o'chirish yoki MTU'ni o'zgartirishdan ko'ra bu ancha "tor" yechim: boshqa 20 ta servisga tegmaydi.
Kamchiligi ham bor: IP qo'lda yozilgan. GitHub uni o'zgartirsa, deploy docker pull bosqichida to'xtaydi. Lekin bu xavfsiz to'xtash — eski konteyner ishlashda davom etadi, sayt tushmaydi. Ehtiyot uchun pull'ga 3 martalik retry ham qo'shildi.
Dars: bir gipoteza rad etilganda uni tan olish ham natija. MTU'ga ishonib server tarmog'ini o'zgartirganimda, boshqa servislarni buzib, asl muammoni hal qilmagan bo'lardim.
8. Natijalar
Deploy log'i:
Status: Downloaded newer image for ghcr.io/.../kim-bu-app:595eb21...
Status: Downloaded newer image for ghcr.io/.../kim-bu-builder:595eb21...
Container kimbu-app Recreated
Container kimbu-app Started
Health check passed
Pages revalidated
Warmed up 84 pages
Cloudflare cache purgedTezlik:
| Holat | Oldin | Keyin |
|---|---|---|
Oddiy sahifa (Cloudflare HIT) | 0.4–1 s | 0.2–0.35 s |
| Deploy'dan keyingi birinchi so'rov | 6–20 s | 0.8–1.7 s |
| Qidiruv (har doim render) | 5–11 s | 0.5–1.5 s |
| Deploy paytida server CPU | 7–9 daqiqa band | build yo'q |
| Sahifalar qayta render bo'lish chastotasi | har 5 daqiqa | har 1 soat |
9. Xulosa
Qilingan o'zgarishlar ro'yxati:
1. unstable_cache TTL: 300 → 3600
→ sahifalar 12 barobar kam render bo'ladi
2. next-intl: localeDetection: false, localeCookie: false
→ har bir URL doim bir xil javob → CDN cache qila oladi
3. Cloudflare Cache Rule (Respect origin + RSC istisno)
→ ko'p so'rov serverga umuman bormaydi
4. Docker build GitHub Actions'da, server faqat pull qiladi
→ deploy paytida live sayt sekinlashmaydi
5. Deploy'dan keyin warm-up
→ birinchi foydalanuvchi render kutmaydi
6. Deploy'dan keyin Cloudflare purge
→ eski HTML + yangi JS muammosi yo'q
7. GHCR uchun IPv4 + pull retry
→ deploy barqarorEng muhim mental model:
"Sekin" → qaysi so'rov? qancha? cache'danmi?
→ o'lcha (curl -w, header'lar)
→ bo'g'inlarni ajrat (DB? render? proxy? CDN?)
→ lokal vs production solishtir
→ vaqt bo'yicha solishtir (deploy? cron? trafik?)
→ gipoteza → tekshir → rad etilsa, tan olHech biri murakkab texnika emas. Qiyin qismi — to'g'ri joyni topish edi. Va buni faqat o'lchash orqali topish mumkin bo'ldi.