Next.js के लिए GitLab CI/CD पाइपलाइन (Kubernetes)

Next.js ऐप के लिए पूरी GitLab CI/CD पाइपलाइन: pnpm कैश, टेस्ट, मल्टी-स्टेज Docker इमेज, Helm या Argo CD से Kubernetes डिप्लॉयमेंट।

सर्वर रैक में पैच पैनल से जुड़े फ़िरोज़ी रंग के नेटवर्क केबल

संक्षिप्त उत्तर: Next.js ऐप्लिकेशन के लिए प्रोडक्शन में इस्तेमाल करने योग्य GitLab CI/CD पाइपलाइन चार चरणों में बन जाती है — install (कैश की गई डिपेंडेंसी), test (lint + टेस्ट + next build), build (GitLab रजिस्ट्री में पुश की गई मल्टी-स्टेज Docker इमेज), deploy (या तो CI जॉब से helm upgrade --install, या मैनिफ़ेस्ट रिपॉज़िटरी पर एक कमिट जिसे Argo CD रीकॉन्साइल करता है)। ठीक से घोषित किए गए pnpm कैश और output: 'standalone' मोड के साथ लगभग 150 से 200 MB की इमेज और साझा रनर पर 3 से 6 मिनट की पाइपलाइन मिलती है।

हम जिन टीमों का काम संभालते हैं, उनमें से ज़्यादातर के पास पहले से «एक CI» होती है। वह टेस्ट चलाती है, और डिप्लॉयमेंट when: manual वाले जॉब में एक ssh और एक docker compose pull से होता है। यह उस दिन तक चलता है जब तक दिन में दो बार डिप्लॉय करने, दो मिनट में पीछे लौटने, या किसी नए सदस्य को यह समझाने की ज़रूरत न पड़े कि प्रोडक्शन में असल में कौन-सा वर्शन चल रहा है। यह लेख उस पाइपलाइन का वर्णन करता है जो हम डिफ़ॉल्ट रूप से लगाते हैं — फ़ाइल दर फ़ाइल।

सर्वर रैक में पैच पैनल से जुड़े फ़िरोज़ी रंग के नेटवर्क केबल

CI से पहले Dockerfile

पाइपलाइन को ऑप्टिमाइज़ करने का कोई फ़ायदा नहीं अगर इमेज 1.2 GB की है क्योंकि उसमें पूरा node_modules भरा हुआ है। Next.js वर्शन 12 से एक स्वतंत्र (standalone) आउटपुट मोड देता है, जो डिपेंडेंसी ग्राफ़ का अनुसरण करते हुए केवल वही फ़ाइलें .next/standalone में कॉपी करता है जिनकी सर्वर को असल में ज़रूरत है। इसे next.config.ts में सक्रिय किया जाता है:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
}

export default nextConfig

इसके साथ जाने वाला Dockerfile, तीन स्तरों में:

# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS deps
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
    pnpm install --frozen-lockfile

FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 nodejs && adduser -u 1001 -G nodejs -S nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

तीन बातें, जिन्हें भूल जाना महंगा पड़ता है:

  • HOSTNAME=0.0.0.0। स्टैंडअलोन सर्वर डिफ़ॉल्ट रूप से localhost पर सुनता है; कंटेनर में Kubernetes की लाइवनेस प्रोब तब पोर्ट तक कभी नहीं पहुंच पाती और पॉड CrashLoopBackOff में फंस जाता है।
  • NEXT_PUBLIC_* वेरिएबल बिल्ड के समय ही तय हो जाते हैं। कंपाइलर उन्हें क्लाइंट JavaScript में इनलाइन कर देता है। स्टेजिंग के लिए बनाई गई इमेज को प्रोडक्शन में «प्रोमोट» नहीं किया जा सकता अगर सार्वजनिक API का URL बदलता है: या तो हर एनवायरनमेंट के लिए एक इमेज चाहिए, या इन मानों का उपयोग केवल रनटाइम पर सर्वर साइड ही किया जाए।
  • नॉन-रूट यूज़र। इसके बिना कोई भी PodSecurityPolicy या runAsNonRoot: true वाला securityContext पॉड को अस्वीकार कर देता है।

.gitlab-ci.yml, चरण दर चरण

डिपेंडेंसी का कैश

GitLab कैश बर्बाद होने वाले मिनटों का पहला स्रोत है। की (key) लॉकफ़ाइल पर निर्भर होनी चाहिए, और pnpm स्टोर प्रोजेक्ट डायरेक्टरी के अंदर होना चाहिए — GitLab केवल वही आर्काइव करता है जो CI_PROJECT_DIR के अंतर्गत है।

stages: [test, build, deploy]

default:
  image: node:22-alpine
  before_script:
    - corepack enable
    - pnpm config set store-dir .pnpm-store

variables:
  IMAGE: $CI_REGISTRY_IMAGE
  TAG: $CI_COMMIT_SHORT_SHA

.node_cache: &node_cache
  key:
    files: [pnpm-lock.yaml]
  paths:
    - .pnpm-store
    - .next/cache
  policy: pull-push

lint_test:
  stage: test
  cache: *node_cache
  script:
    - pnpm install --frozen-lockfile
    - pnpm lint
    - pnpm exec tsc --noEmit
    - pnpm test --run
  artifacts:
    when: always
    reports:
      junit: junit.xml

कैश में .next/cache कोई छोटी बात नहीं है: यह Next.js का कंपाइलेशन कैश है। Next.js का आधिकारिक दस्तावेज़ («Continuous Integration (CI) Build Caching») इसका स्पष्ट उल्लेख करता है — इसके बिना हर बिल्ड शून्य से शुरू होता है और लॉग में «no build cache found» संदेश दिखता है। मध्यम आकार के प्रोजेक्ट पर हम नियमित रूप से बिल्ड चरण को 4 मिनट से 90 सेकंड पर आते देखते हैं।

Buildah या Kaniko से इमेज बनाना

पुराना docker:dind अब भी काम करता है, लेकिन उसके लिए प्रिविलेज्ड रनर चाहिए, जो साझा क्लस्टर पर एक समस्या है। डीमन-रहित दो विकल्प:

build_image:
  stage: build
  image: quay.io/buildah/stable:v1.38
  variables:
    STORAGE_DRIVER: vfs
    BUILDAH_FORMAT: docker
  script:
    - echo "$CI_REGISTRY_PASSWORD" | buildah login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - buildah build --layers -t "$IMAGE:$TAG" -t "$IMAGE:latest" .
    - buildah push "$IMAGE:$TAG"
    - buildah push "$IMAGE:latest"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

--layers स्थानीय लेयर कैश सक्रिय करता है; क्षणभंगुर (ephemeral) रनर पर इससे कुछ नहीं मिलता, लेकिन टीम के लिए समर्पित रनर पर यह समय आधा कर देता है। Kaniko (gcr.io/kaniko-project/executor:debug) वही सेवा --cache=true --cache-repo=$CI_REGISTRY_IMAGE/cache के साथ देता है, जो ठीक तब उपयोगी है जब रनर डिस्पोज़ेबल हों, क्योंकि कैश रजिस्ट्री में रहता है।

नामकरण की बात: $CI_COMMIT_SHORT_SHA पर टैग करें और कभी केवल latest पर नहीं। अपरिवर्तनीय टैग ही वह शर्त है जिससे kubectl rollout undo का कोई अर्थ बनता है, और जिससे Argo CD वास्तव में बदलाव पहचान पाता है।

GitLab रजिस्ट्री तेज़ी से बढ़ती है: 180 MB की एक Next.js इमेज दिन में दस बार पुश होने पर महीने में 50 GB से ज़्यादा हो जाती है। एक क्लीनअप नीति सक्रिय करें (Settings → Packages and registries → Clean up image tags) जो अंतिम 10 टैग रखे और 30 दिन से पुराने हटा दे, वरना स्टोरेज का बिल प्रोडक्शन में जाने से पहले ही आ जाएगा।

क्लस्टर इन इमेज को खींच सके, इसके लिए डिप्लॉय टोकन पर आधारित imagePullSecret चाहिए — यह विषय Kubernetes पर प्राइवेट GitLab इमेज वाली हमारी गाइड में विस्तार से समझाया गया है।

डिप्लॉयमेंट: CI से helm upgrade

सबसे सीधा तरीका। deploy/chart में एक न्यूनतम Helm चार्ट, और एक जॉब जो नया वर्शन पुश करता है।

.deploy_template: &deploy
  stage: deploy
  image: alpine/helm:3.16.3
  script:
    - echo "$KUBE_CONFIG" | base64 -d > kubeconfig
    - export KUBECONFIG=$PWD/kubeconfig
    - helm upgrade --install $RELEASE deploy/chart
        --namespace $NAMESPACE --create-namespace
        --set image.repository=$IMAGE
        --set image.tag=$TAG
        --values deploy/values.$ENV.yaml
        --atomic --timeout 5m

deploy_staging:
  <<: *deploy
  variables: { ENV: staging, NAMESPACE: web-staging, RELEASE: site-staging }
  environment:
    name: staging
    url: https://staging.example.com
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy_prod:
  <<: *deploy
  variables: { ENV: prod, NAMESPACE: web-prod, RELEASE: site }
  environment:
    name: production
    url: https://example.com
  when: manual
  allow_failure: false
  rules:
    - if: $CI_COMMIT_TAG

दो विकल्प जो सब कुछ बदल देते हैं: --atomic, जो पॉड तैयार न होने पर रिलीज़ को स्वतः रद्द कर देता है, और --timeout, जिसके बिना एक जॉब घंटों अटका रह सकता है। KUBE_CONFIG को GitLab में मास्क्ड और प्रोटेक्टेड वेरिएबल के रूप में रखा जाता है, और उससे जुड़े ServiceAccount को केवल अपने दो नेमस्पेस में लिखने की अनुमति होनी चाहिए — डिप्लॉयमेंट जॉब के लिए cluster-admin पर ClusterRoleBinding वह सबसे आम ख़ामी है जो हमें ऑडिट में मिलती है।

डिप्लॉयमेंट बिना डाउनटाइम भी होना चाहिए। चार्ट की ओर से:

strategy:
  type: RollingUpdate
  rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
readinessProbe:
  httpGet: { path: /api/health, port: 3000 }
  periodSeconds: 5
livenessProbe:
  httpGet: { path: /api/health, port: 3000 }
  initialDelaySeconds: 20
resources:
  requests: { cpu: 100m, memory: 256Mi }
  limits: { memory: 512Mi }

एक app/api/health/route.ts रूट जो new Response('ok') लौटाता है, पर्याप्त है — बस उसे किसी डेटाबेस पर निर्भर नहीं होना चाहिए, वरना PostgreSQL की सुस्ती सभी पॉड को श्रृंखलाबद्ध रूप से रीस्टार्ट करा देगी। Next.js सर्वर की मेमोरी साइज़िंग पर: रिक्वेस्ट में 256 Mi और लिमिट में 512 Mi एक शोकेस साइट या छोटे बैक-ऑफ़िस के लिए पर्याप्त हैं; गहन ISR के बाद लिमिट तय करने से पहले मापना ज़रूरी है, क्योंकि सर्वर रेंडरिंग पर OOMKilled का मतलब विज़िटर की ओर 502 होता है।

डिप्लॉयमेंट: GitOps में Argo CD

दो एनवायरनमेंट और दिन में एक से ज़्यादा डिप्लॉयमेंट के बाद, CI से helm upgrade अपनी सीमाएं दिखाने लगता है: CI के पास क्लस्टर पर लिखने के अधिकार होते हैं, और क्लस्टर की वास्तविक स्थिति कहीं दर्ज नहीं होती। GitOps मॉडल प्रवाह को उलट देता है — CI क्लस्टर को छूती ही नहीं, वह एक कमिट लिखती है; क्लस्टर में मौजूद एक एजेंट (Argo CD, Flux) उसे खींचकर रीकॉन्साइल करता है।

व्यवहार में, डिप्लॉयमेंट जॉब मैनिफ़ेस्ट अपडेट करने वाला जॉब बन जाता है:

bump_manifest:
  stage: deploy
  image: alpine/git:2.47.1
  script:
    - git clone https://bot:$DEPLOY_TOKEN@gitlab.com/acme/deploy-manifests.git m
    - cd m
    - sed -i "s|tag: .*|tag: $TAG|" apps/site/values-prod.yaml
    - git commit -am "site: deploy $TAG" && git push
  rules:
    - if: $CI_COMMIT_TAG

और क्लस्टर की ओर, Argo CD ऐप्लिकेशन:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: site
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://gitlab.com/acme/deploy-manifests.git
    targetRevision: main
    path: apps/site
    helm:
      valueFiles: [values-prod.yaml]
  destination:
    server: https://kubernetes.default.svc
    namespace: web-prod
  syncPolicy:
    automated: { prune: true, selfHeal: true }
    syncOptions: [CreateNamespace=true]

इससे जो मिलता है: किसी ने हाथ से kubectl edit किया हो तो selfHeal क्लस्टर को रिपॉज़िटरी की स्थिति पर लौटा देता है, Git का इतिहास ही डिप्लॉयमेंट का इतिहास बन जाता है, और पीछे लौटना एक git revert है। इसकी कीमत: एक और रिपॉज़िटरी, होस्ट करने के लिए एक और कंपोनेंट (साधारण इंस्टॉलेशन के लिए Argo CD लगभग 1 vCPU और 1 GB RAM खाता है, यानी Scaleway या OVHcloud पर नोड के रूप में महीने में करीब 15 से 25 €), और GitLab वेबहुक कॉन्फ़िगर न करने पर डिफ़ॉल्ट रूप से 3 मिनट की सिंक्रोनाइज़ेशन लेटेंसी।

हमारा व्यावहारिक नियम:

संदर्भसिफ़ारिश
1 से 2 ऐप्लिकेशन, 1 क्लस्टर, एक टीमCI से helm upgrade
3 या अधिक ऐप्लिकेशन, या 2 क्लस्टरArgo CD
कई टीमें एक ही क्लस्टर पर डिप्लॉय करती हैंArgo CD, हर टीम के लिए AppProject के साथ
«किसने क्या डिप्लॉय किया» का ऑडिट अनिवार्यArgo CD

सबसे ज़्यादा दोहराई जाने वाली गलतियां

next build को दो बार चलाना। एक बार टेस्ट जॉब में, एक बार Dockerfile में। यह हर पाइपलाइन में दो से चार मिनट की बर्बादी है। या तो टेस्ट जॉब केवल tsc --noEmit और यूनिट टेस्ट तक सीमित रहे, या बिल्ड एक आर्टिफ़ैक्ट तैयार करे जिसे दोबारा इस्तेमाल किया जाए — लेकिन दोनों तरीकों को मिलाने में खर्च है, फ़ायदा नहीं।

--frozen-lockfile भूल जाना। इस विकल्प के बिना pnpm या npm डेवलपर की मशीन और CI के बीच अलग-अलग माइनर वर्शन रिज़ॉल्व कर सकता है। «मेरे यहां तो चलता है» की यही क्लासिक वजह है।

maxUnavailable: 1 के साथ एक ही रेप्लिका। हर डिप्लॉयमेंट के दौरान साइट अनुपलब्ध रहती है। दो रेप्लिका और maxUnavailable: 0, साथ में minAvailable: 1 वाला PodDisruptionBudget, अपडेट और नोड इविक्शन को अदृश्य बनाने के लिए पर्याप्त हैं।

सीक्रेट्स को क्लस्टर की जगह CI वेरिएबल में रखना। --set में इंजेक्ट किया गया GitLab वेरिएबल Helm के इतिहास में, और इसलिए नेमस्पेस के एक Secret में, साफ़ टेक्स्ट में दिखता है — जो भी उस नेमस्पेस को पढ़ सकता है, उसके लिए। ऐप्लिकेशन के सीक्रेट्स क्लस्टर के हैं — External Secrets Operator या Sealed Secrets — पाइपलाइन के नहीं।

ब्रांच और टैग को प्रोटेक्ट न करना। «प्रोटेक्टेड» वेरिएबल का अर्थ केवल तब है जब GitLab में डिफ़ॉल्ट ब्रांच और v* टैग पैटर्न प्रोटेक्टेड घोषित हों, और सीधा push निषिद्ध हो।

इन पॉड तक ट्रैफ़िक पहुंचाने की बात करें तो, अगर आपका क्लस्टर अब भी ingress-nginx पर है, तो माइग्रेशन की योजना बनानी होगी: हमने इसका विवरण Gateway API पर अपने लेख में दिया है। और ऐप्लिकेशन के साथ चलने वाले डेटाबेस के लिए, CloudNativePG बनाम मैनेज्ड डेटाबेस की तुलना अनुमानित आंकड़े देती है।

अक्सर पूछे जाने वाले प्रश्न

समर्पित GitLab रनर चाहिए या साझा रनर पर्याप्त हैं?

GitLab.com के साझा रनर दिन में दस-बारह पाइपलाइन तक ठीक हैं; मुफ़्त पैकेज में कंप्यूट मिनटों का मासिक कोटा शामिल है, जो Docker बिल्ड से बहुत जल्दी खप जाता है। उससे आगे, एक छोटे इंस्टेंस (4 vCPU, 8 GB) पर महीने में 30-50 € का सेल्फ-होस्टेड रनर तुरंत अपनी लागत निकाल लेता है, और सबसे बड़ी बात यह है कि वह बिल्ड के बीच लेयर कैश बनाए रखता है। Kubernetes एक्ज़ीक्यूटर सुविधाजनक है अगर आपके पास पहले से क्लस्टर है, लेकिन कैश के लिए एक परसिस्टेंट वॉल्यूम माउंट करना याद रखें, वरना फ़ायदा जाता रहेगा।

क्या फ़ीचर ब्रांच को क्षणिक एनवायरनमेंट में डिप्लॉय किया जा सकता है?

हां, on_stop के साथ environment: name: review/$CI_COMMIT_REF_SLUG का यही मकसद है। हर मर्ज रिक्वेस्ट को अपना नेमस्पेस और अपना URL https://$CI_COMMIT_REF_SLUG.review.example.com मिलता है, जो बंद होने पर नष्ट हो जाते हैं। असल लागत डेटाबेस है: हर ब्रांच के लिए एक इंस्टेंस की जगह हर रिव्यू एनवायरनमेंट के लिए एक स्कीमा की योजना बनाएं।

पाइपलाइन में डेटाबेस माइग्रेशन कैसे संभालें?

Helm के pre-upgrade हुक से चलाए गए Kubernetes Job के ज़रिए, CI जॉब से नहीं। हुक क्लस्टर में चलता है, ऐप्लिकेशन के समान सीक्रेट्स और समान नेटवर्क के साथ, और माइग्रेशन फ़ेल होने पर --atomic रिलीज़ रद्द कर देता है। नियम वही रहता है — आगे की ओर संगतता: प्रगतिशील डिप्लॉयमेंट के दौरान माइग्रेशन को कोड के पुराने वर्शन के साथ सह-अस्तित्व में रहने में सक्षम होना चाहिए।

एक स्वीकार्य पाइपलाइन कितनी देर चलनी चाहिए?

हमारी सीमा git push से स्टेजिंग पर डिप्लॉय होने तक 10 मिनट है। इससे ज़्यादा होने पर डेवलपर बार-बार पुश करना छोड़ देते हैं और बैच बड़े हो जाते हैं। पहला लीवर कैश है (.pnpm-store और .next/cache), दूसरा lint और टेस्ट का समांतरीकरण, तीसरा Docker लेयर कैश।

सारांश

  • Next.js में output: 'standalone' सक्रिय करें और HOSTNAME=0.0.0.0 के साथ नॉन-रूट मल्टी-स्टेज इमेज बनाएं: 1 GB से अधिक की जगह लगभग 150 से 200 MB।
  • pnpm-lock.yaml पर आधारित की के साथ .pnpm-store और .next/cache दोनों कैश करें; यह पाइपलाइन की सबसे लाभदायक समय-बचत है।
  • प्रिविलेज्ड रनर से बचने के लिए Buildah या Kaniko से बिल्ड करें, कमिट SHA पर टैग करें और रजिस्ट्री क्लीनअप नीति सक्रिय करें।
  • एक या दो ऐप्लिकेशन के लिए, संबंधित नेमस्पेस तक सीमित ServiceAccount के साथ CI से helm upgrade --install --atomic --timeout 5m।
  • उससे आगे, Argo CD के साथ GitOps पर जाएं: CI एक कमिट लिखती है, क्लस्टर ख़ुद को रीकॉन्साइल करता है, और पीछे लौटना एक git revert है।
  • बिना डाउनटाइम डिप्लॉयमेंट: 2 रेप्लिका, maxUnavailable: 0, और डेटाबेस को न छूने वाले /api/health रूट पर प्रोब।
  • ऐप्लिकेशन के सीक्रेट्स क्लस्टर में रहते हैं (External Secrets, Sealed Secrets), कभी Helm के --set में नहीं।

अपनी डिलीवरी चेन पर किसी बाहरी नज़र की ज़रूरत है? हम इस तरह के कामों में अपनी इन्फ़्रास्ट्रक्चर और ऑपरेशंस तथा ऐप्लिकेशन डेवलपमेंट सेवाओं के दायरे में सहयोग करते हैं; बात करने के लिए आप हमें लिख सकते हैं।