Platform & Tooling · پلتفرم و ابزار متوسطIntermediate ~60 دقیقه مطالعه~50 min read

CI/CD: از build تا استقرارِ امنCI/CD: From Build to Safe Deployment

راهنمای عملی CI/CD برای سرویس‌های Java: از آناتومی pipeline و پیکربندی واقعی GitLab CI و GitHub Actions و Jenkins تا cache و build تکرارپذیر، راز با OIDC، مهاجرت expand-contract، استراتژی‌های استقرار و rollback، GitOps و Ansible، و قضاوت با متریک‌های DORA.A practical CI/CD guide for Java services: pipeline anatomy, real GitLab CI, GitHub Actions and Jenkins configuration, caching and reproducible builds, secrets via OIDC, expand-contract migrations, deployment strategies and rollback, GitOps and Ansible, and judging it all with DORA metrics.

پیش‌نیاز:Prerequisites: Git حرفه‌ای: از درون تا جریان کاری تیمیProfessional Git: Internals to Team Workflow


یک سؤال ساده در مصاحبه‌های senior خیلی‌ها را زمین می‌زند: «کد تو از لحظه‌ای که git push می‌زنی تا لحظه‌ای که کاربر واقعی آن را می‌بیند، دقیقاً از چه چیزهایی عبور می‌کند؟» جواب سطح junior «Jenkins build می‌گیرد و deploy می‌کند» است. جواب senior یک زنجیرهٔ کامل است: چه چیزی build می‌شود، چند بار، چه کسی امضایش می‌کند، دیتابیس کِی migrate می‌شود، اگر خراب شد چطور برمی‌گردیم، و از کجا می‌فهمیم خراب شده.

این فصل همان زنجیره است — نه معرفی ابزار، بلکه مهندسیِ مسیر تحویل. چیزهایی که جای دیگری آمده فقط ارجاع داده می‌شوند: تست و Testcontainers در testing، Maven/Gradle در build-tools، Dockerfile و Kubernetes در containers-jvm، متریک و log در observability، و مدیریت کلید در key-management-secrets.

نقشهٔ راه

۱. مفاهیم: CI چیست، و تفاوت continuous delivery با continuous deployment. ۲. آناتومی pipeline: مرحله‌های یک خط لولهٔ واقعی Java و منطق ترتیبشان. ۳. پیاده‌سازی: همان پروژه در GitLab CI، GitHub Actions و Jenkins، کنار هم. ۴. تکرارپذیری و نسخه: cache در برابر artifact، reproducible build، Nexus، SemVer و release خودکار. ۵. راز در CI: variable، OIDC، Vault و اشتباه‌هایی که secret را لو می‌دهند. ۶. دیتابیس: Flyway/Liquibase و الگوی expand-contract. ۷. استقرار: recreate، rolling، blue-green، canary، feature flag و نقشهٔ rollback. ۸. GitOps و IaC: Argo CD/Flux و مقدمات عملی Ansible. ۹. قضاوت: متریک‌های DORA و ضدالگوهای pipeline.


۱. مسئله‌ای که CI حل می‌کند

جهنمِ یکپارچه‌سازی

پنج نفر یک کتاب می‌نویسند. هر کدام یک نسخهٔ کامل برمی‌دارد، سه ماه در اتاق خودش کار می‌کند، و آخر کار همه جمع می‌شوند تا نسخه‌ها را یکی کنند. نفر اول اسم شخصیت اصلی را عوض کرده، نفر دوم فصل ۴ را حذف کرده، نفر سوم زمان روایت را عوض کرده — حالا «یکی کردن» خودش یک پروژهٔ دوماهه است.

این وضعیت تیم‌های نرم‌افزاری قبل از CI بود و اسمش integration hell بود. راه‌حل ساده است: به‌جای یک ادغام سه‌ماهه، روزی چند ادغام کوچک. هر ادغام کوچک، درد کوچک.

CI: یکپارچه‌سازی پیوسته

Continuous Integration یعنی هر توسعه‌دهنده تغییرش را حداقل روزی یک بار با شاخهٔ اصلی ادغام کند و هر ادغام خودکار build و تست شود. دو کلمه در این تعریف معمولاً نادیده گرفته می‌شود:

  • «با شاخهٔ اصلی»: اگر سه هفته روی یک feature branch کار کنی و pipeline هم روی همان شاخه سبز باشد، تو CI نداری؛ یک build server داری. CI یعنی کدت واقعاً با کدِ بقیه ترکیب شده باشد.
  • «خودکار»: اگر کسی باید دکمه‌ای بزند یا اسکریپتی روی لپ‌تاپش اجرا کند، دیر یا زود اجرا نمی‌شود.

سه سؤال برای اینکه بفهمی واقعاً CI داری: شاخه‌ها کمتر از یک روز عمر دارند؟ وقتی pipeline قرمز می‌شود اولویت شمارهٔ یک تیم سبز کردنش است؟ main همیشه قابل release است؟ اگر هر سه بله نیست، فقط CI-theatre داری. به همین دلیل CI با شاخه‌های طولانی‌عمر می‌جنگد: GitFlow برای دنیای نسخه‌های سه‌ماهه طراحی شد، و برای سرویسی که روزی چند بار deploy می‌شود trunk-based development — شاخهٔ کوتاه‌عمر، merge روزانه به main، feature flag برای کارِ نیمه‌تمام — تقریباً همیشه انتخاب درست‌تری است.

CD: تحویل پیوسته در برابر استقرار پیوسته

اینجا جایی است که مصاحبه‌گر دقیقاً می‌ایستد.

  • Continuous Delivery: هر تغییری که از pipeline سبز بیرون بیاید آمادهٔ رفتن به production است — ساخته، تست‌شده، امضاشده. فقط یک نفر دکمه را می‌زند؛ تصمیمِ رفتن تجاری است، نه فنی.
  • Continuous Deployment: همان چیز، منهای دکمه — هر commit که همهٔ دروازه‌ها را رد کند خودکار روی production می‌رود.

تفاوت فقط یک دکمه است، اما آن دکمه وقتی حذف‌شدنی است که تست، feature flag، canary و rollback خودکار آن‌قدر بالغ باشند که آدم لازم نباشد.

«ما CD داریم» معمولاً دروغِ ناخواسته است

اگر برای رفتن به production باید فرم تغییر پر کنی، منتظر جلسهٔ هفتگی بمانی و یک نفر دستی روی سرور اسکریپت بزند، تو CD نداری — یک deploy script داری. معیار ساده: آیا آخرین commit امروز می‌تواند تا شش بعدازظهر روی production باشد؟ اگر نه، جایی یک صف انسانی هست که باید حذف شود.

فرق continuous delivery و continuous deployment چیست و کدام را پیشنهاد می‌دهی؟

Continuous delivery یعنی هر build سبز قابلیت رفتن به production را دارد و تنها مانع یک تصمیم انسانی است؛ continuous deployment یعنی آن تصمیم هم حذف شده.

پیشنهادم به بلوغ تیم بستگی دارد. Continuous deployment سه پیش‌نیاز غیرقابل‌مذاکره دارد: تست و smoke test که خطای آشکار را قبل از production بگیرد؛ استقرار تدریجی — canary یا feature flag — که شعاع انفجار را کوچک نگه دارد؛ و rollback خودکار مبتنی بر متریک، نه بر تماس تلفنی. اگر این سه نیست، continuous deployment یعنی سریع‌تر خراب‌کردن.

مسیر درست: اول delivery را بی‌عیب کن، بعد دکمه را بردار. در حوزه‌های تحت مقررات که الزام تفکیک وظایف داری، delivery با یک تأیید خودکارشده و ثبت‌شده انتخاب درستی باقی می‌ماند.


۲. آناتومی pipeline یک سرویس Java

«مراحل خط لولهٔ یک سرویس Java و ترتیب منطقی‌شان» — Stages of a Java service pipeline and their ordering logic

flowchart TD
  A[1. Checkout] --> B[2. Restore dependency cache]
  B --> C[3. Compile]
  C --> D[4. Unit tests]
  D --> E[5. Integration tests / Testcontainers]
  D --> F[6. Static analysis + quality gate]
  E --> G[7. Dependency + container scan]
  F --> G
  G --> H[8. Package artifact + build image]
  H --> I[9. Push image + sign]
  I --> J[10. Deploy per environment]
  J --> K[11. Smoke tests + observe]

منطق این ترتیب یک اصل دارد: fail fast. ارزان‌ترین و سریع‌ترین بررسی‌ها اول؛ اگر کد کامپایل نمی‌شود، پنج دقیقه صرف اسکن امنیتی بی‌معنی است.

۲.۱ Checkout

shallow clone (git clone --depth 1) روی مخزن بزرگ ده‌ها ثانیه صرفه‌جویی می‌کند، اما اگر ابزارت به تاریخچه نیاز دارد — git describe برای نسخه‌دهی، changelog، یا Sonar برای تشخیص «کد جدید» — خرابش می‌کند؛ در GitHub Actions باید fetch-depth: 0 بدهی. نکتهٔ مهم‌تر پاکیزگی workspace است: یک agent که ماه‌هاست بالاست ~/.m2 مشترک و کانتینرهای رهاشدهٔ Testcontainers دارد، پس build امروز روی چیزی سبز می‌شود که فردا ندارد. استاندارد امروزی runner یک‌بارمصرف در کانتینر است.

۲.۲ Cache وابستگی‌ها

بدون cache هر job کل درخت وابستگی Maven را دوباره دانلود می‌کند — دو تا سه دقیقهٔ هدررفته در هر job.

mvn -B -ntp dependency:go-offline                      # پیش‌گرم‌کردن مخزن محلی
mvn -B -ntp -Dmaven.repo.local=.m2/repository verify   # مخزن محلی داخل پروژه، تا runner بتواند cache کند
cache در برابر artifact

cache یک «شاید موجود باشد» برای سرعت است؛ اگر نبود build باید همچنان کار کند (فقط کندتر). artifact یک «حتماً باید منتقل شود» بین مرحله‌هاست: jar نهایی، گزارش تست، SBOM. قاتی‌شان نکن: jar را cache نکن (build بعدی ممکن است jar کهنه را بردارد) و ~/.m2 را artifact نکن.

کلید cache بد = خرابیِ خاموش

اگر کلید را maven-cache بگذاری و هیچ‌وقت عوضش نکنی، cache بی‌نهایت باد می‌کند و وابستگیِ حذف‌شده از pom.xml هنوز روی دیسک هست — یعنی build سبز می‌ماند در حالی که روی ماشین تازه شکست می‌خورد. کلید را همیشه به hash فایل قفل وابسته کن (hashFiles('**/pom.xml') یا files: [pom.xml]) و یک restore-keys برای برخورد جزئی بگذار. بدتر از آن، در مخزن‌های عمومی cache می‌تواند مسیر cache poisoning باشد — اجرای یک PR غریبه چیزی می‌نویسد که build شاخهٔ اصلی بعداً می‌خواند؛ به همین دلیل جریان‌های نامعتمد باید فقط دسترسی خواندن داشته باشند.

۲.۳ Compile و unit test

mvn -B -ntp -T 1C clean verify -DskipITs

هیچ‌کدام از این پرچم‌ها تزئینی نیستند: -B حالت batch است (بدون رنگ و بدون سؤال تعاملی)، -ntp نوار پیشرفت دانلود را از log حذف می‌کند، -T 1C یک thread به‌ازای هر هستهٔ CPU می‌سازد، -DskipITs فقط تست‌های integration را رد می‌کند، و --fail-at-end در build چندماژولی تا جای ممکن ادامه می‌دهد تا همهٔ خطاها یک‌جا دیده شوند.

تقسیم کاری که همه اشتباه می‌گیرند: Surefire تست‌های unit را در فاز test اجرا می‌کند (الگوی نام: *Test، Test*)، و Failsafe تست‌های integration را در فاز integration-test اجرا و نتیجه را در verify بررسی می‌کند (الگو: *IT). اگر Failsafe شکست بخورد، post-integration-test هنوز اجرا می‌شود تا منابع بسته شوند.

`mvn package` در CI یعنی تست integration تو الکی است

چرخهٔ حیات Maven این است: ... test → package → verify → install → deploy. Failsafe نتیجه‌اش را در verify چک می‌کند. با package تست‌های IT یا اجرا نمی‌شوند یا شکستشان نادیده گرفته می‌شود. در CI همیشه mvn verify بزن.

۲.۴ تست integration با Testcontainers

نوشتن تست در فصل testing است؛ اینجا فقط بخش مخصوص CI: Testcontainers به یک Docker daemon قابل‌دسترس نیاز دارد. سه مدل رایج: mount کردن socket داکر میزبان (سریع‌ترین، ولی job عملاً دسترسی root روی آن ماشین می‌گیرد)، Docker-in-Docker به‌عنوان service (ایزوله‌تر، کندتر)، یا یک endpoint داکر از راه دور.

integration-test:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  services:
    - name: docker:27-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2376"
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: "1"
  script:
    - mvn -B -ntp -Dmaven.repo.local=.m2/repository verify
سه دامِ Testcontainers که فقط در CI خودشان را نشان می‌دهند

۱. testcontainers.reuse.enable=true را در CI روشن نکن؛ این قابلیت برای لپ‌تاپ توسعه‌دهنده است و در CI باعث نشت کانتینر و تداخل بین job‌ها می‌شود. ۲. اگر مجبور شدی Ryuk (کانتینر پاک‌کننده) را با TESTCONTAINERS_RYUK_DISABLED=true خاموش کنی، حتماً runner یک‌بارمصرف لازم داری وگرنه کانتینرهای زامبی دیسک را پر می‌کنند. ۳. کشیدن postgres:16 از رجیستری عمومی در هر اجرا، در ساعت‌های شلوغ به محدودیت نرخ می‌خورد و pipeline بدون هیچ تغییری در کد قرمز می‌شود؛ راه‌حل mirror داخلی است.

۲.۵ تحلیل ایستا و quality gate

تحلیل ایستا یعنی بررسی کد بدون اجرای آن. سه لایهٔ متمایز که قاطی می‌شوند: قالب‌بندی (Spotless، Checkstyle — سلیقه‌ای، باید خودکار اصلاح شود)، کشف باگ (SpotBugs روی bytecode، با افزونهٔ Find Security Bugs برای الگوهای امنیتی)، و کیفیت انباشته (SonarQube، متمرکز روی «کد جدید»).

mvn -B -ntp com.github.spotbugs:spotbugs-maven-plugin:check    # کشف باگ روی bytecode

mvn -B -ntp verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \
    -Dsonar.projectKey=payments-service \
    -Dsonar.host.url="$SONAR_HOST_URL" \
    -Dsonar.token="$SONAR_TOKEN" \
    -Dsonar.qualitygate.wait=true

sonar.qualitygate.wait=true حیاتی است: بدون آن Maven تحلیل را می‌فرستد و بلافاصله موفق برمی‌گردد — یعنی quality gate هرگز build را نمی‌شکند. و برای اینکه Sonar پوشش تست را ببیند، agent جاکوکو باید قبل از تست‌ها فعال شده باشد (jacoco-maven-plugin با اجرای prepare-agent و report).

quality gate را روی «کد جدید» ببند، نه روی کل مخزن

اگر روی یک codebase ده‌ساله شرط بگذاری «پوشش کل باید ۸۰٪ باشد»، هیچ‌وقت سبز نمی‌شود و تیم gate را خاموش می‌کند. رویکرد clean as you code جواب می‌دهد: کد جدید یا تغییریافته باید ۸۰٪ پوشش داشته باشد و آسیب‌پذیری جدید صفر باشد؛ بدهی قدیمی تدریجی پاک می‌شود.

نکتهٔ senior اینجا این است که تحلیل ایستا هزینه هم دارد: false positive. اگر ۴۰٪ یافته‌ها نادرست باشند، تیم یاد می‌گیرد همه را نادیده بگیرد. قوانین را کم و دقیق شروع کن، false positiveها را با توضیح suppress کن، و آستانه را تدریجی سفت کن؛ gate پرسروصدا بدتر از نبودِ gate است.

۲.۶ اسکن وابستگی و image

دو نوع متفاوت اسکن داریم. SCA (Software Composition Analysis) کتابخانه‌های شخص ثالث را با پایگاه‌های آسیب‌پذیری تطبیق می‌دهد:

mvn -B -ntp org.owasp:dependency-check-maven:12.2.2:check \
    -DfailBuildOnCVSS=7 -DnvdApiKey="$NVD_API_KEY"

اسکن image لایه‌های سیستم‌عاملِ image ساخته‌شده را بررسی می‌کند:

IMAGE=registry.example.com/team/payments:1.4.2
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE"
trivy image --format cyclonedx --output sbom.cdx.json "$IMAGE"

SBOM (Software Bill of Materials) فایلی ماشین‌خوان است که می‌گوید داخل این artifact چه کتابخانه‌هایی با چه نسخه‌هایی هست؛ دو فرمت غالب CycloneDX و SPDX. ارزشش روزی معلوم می‌شود که یک آسیب‌پذیری بزرگ منتشر می‌شود و مدیر می‌پرسد «کدام سرویس‌های ما این کتابخانه را دارند؟» — با SBOM جواب یک query است، بدون آن یک هفته کار دستی.

دو تنظیم که تفاوت gate کارآمد با gate خاموش‌شده را می‌سازند

کلید NVD: پایگاه NVD بدون API key به‌شدت محدود می‌شود و اولین همگام‌سازی می‌تواند ده‌ها دقیقه طول بکشد یا با ۴۰۳ شکست بخورد. کلید رایگان است و پوشهٔ دادهٔ dependency-check را هم cache کن؛ چون ابزار روی تطبیق CPE کار می‌کند و false positive می‌دهد، فایل suppression با توضیح و تاریخ انقضا لازم داری.

--ignore-unfixed: یک image پایهٔ لینوکسی ده‌ها CVE بدون وصله دارد؛ اگر build را روی آن‌ها بشکنی، تیم ظرف یک هفته اسکن را رد می‌کند. سیاست عملی: شکستن فقط روی HIGH/CRITICAL که وصله دارند، و گزارش بقیه در داشبورد با SLA جداگانه.

۲.۷ ساخت artifact و image

مهم‌ترین قانونِ کل این فصل: build once, promote many. artifact را یک بار بساز و همان بایت‌ها را از dev به staging به production ببر. اگر برای هر محیط دوباره build کنی، چیزی که در staging تست کردی همان چیزی نیست که در production اجرا می‌شود — و «ولی در staging کار می‌کرد» جملهٔ ثابت جلسه‌های post-mortem می‌شود.

هرگز با tag متغیر deploy نکن — با digest کن

payments:1.4.2 یک اشاره‌گر است و می‌تواند دوباره push شود؛ payments@sha256:9f8c... محتوا-آدرس است و تغییرناپذیر. اگر Kubernetes با tag استقرار کند و کسی همان tag را دوباره push کند، pod بعدی که restart شود چیز دیگری می‌گیرد — یعنی یک استقرار انجام داده‌ای بدون اینکه بدانی.

در رجیستری immutable tag را روشن کن، در manifest از digest استفاده کن (docker inspect --format='{{index .RepoDigests 0}}' ...)، و tag را فقط برای خوانایی انسان نگه دار.

۲.۸ امضا و اثبات منشأ

اسکن می‌گوید «داخل این image چیز بدی هست؟»؛ امضا می‌گوید «این image واقعاً از pipeline ما آمده؟». در حملات زنجیرهٔ تأمین، سؤال دوم مهم‌تر است. Sigstore/cosign امضای بدون کلید (keyless) را ممکن می‌کند: pipeline یک توکن OIDC از فراهم‌کنندهٔ CI می‌گیرد، Fulcio یک گواهی کوتاه‌عمر صادر می‌کند، و امضا در دفترچهٔ شفاف Rekor ثبت می‌شود — هیچ کلید خصوصی‌ای برای دزدیدن وجود ندارد.

cosign sign --yes registry.example.com/team/payments@sha256:9f8c...

cosign verify \
  --certificate-identity "https://github.com/team/service/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  registry.example.com/team/payments@sha256:9f8c...

امضا کردن آسان است؛ ارزشش وقتی ایجاد می‌شود که یک نفر جلوی image بی‌امضا را بگیرد — در Kubernetes با یک admission controller. بدون آن لایه، امضا تزئینی است.

۲.۹ استقرار و smoke test

smoke test یعنی چند بررسی کوچک و سریع بعد از استقرار که می‌گوید «سرویس بالا آمده و کارِ اصلی‌اش را می‌کند» — نه تست کامل، فقط دود بلند نمی‌شود.

kubectl rollout status deployment/payments --namespace prod --timeout=180s

# تأیید اینکه نسخهٔ مستقرشده همانی است که انتظار داشتیم
curl --fail --silent https://payments.example.com/actuator/info | jq -e '.build.version == "1.4.2"'
`kubectl apply` بدون `rollout status` یعنی استقرارِ نابینا

apply فقط می‌گوید «شیء را ثبت کردم»، نه «pod جدید سالم بالا آمد»؛ بدون rollout status، pipeline سبز می‌شود در حالی که pod در CrashLoopBackOff است. و اگر readiness probe درست تعریف نشده باشد، حتی rollout status هم دروغ می‌گوید. برای Spring Boot، probeها را به /actuator/health/readiness و /actuator/health/liveness وصل کن و management.endpoint.health.probes.enabled=true را روشن کن.


۳. همان pipeline در سه ابزار

پروژه: یک سرویس Spring Boot با Maven، Java 21.

۳.۱ GitLab CI

stages: [build, test, package, deploy]

default:
  image: maven:3.9-eclipse-temurin-21
  interruptible: true          # اجرای قدیمی را با push جدید لغو کن
  cache:
    key:
      files: [pom.xml]         # کلید به محتوای pom وابسته است
    paths: [.m2/repository]
    policy: pull               # فقط job ساخت، cache را می‌نویسد

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  MAVEN_CLI_OPTS: "-B -ntp --fail-at-end"

compile:
  stage: build
  cache:
    key: { files: [pom.xml] }
    paths: [.m2/repository]
    policy: pull-push          # این job است که cache را پر می‌کند
  script:
    - mvn $MAVEN_CLI_OPTS -DskipTests clean package
  artifacts:
    paths: [target/*.jar]
    expire_in: 1 day

test:
  stage: test
  needs: [compile]
  script:
    - mvn $MAVEN_CLI_OPTS verify sonar:sonar -Dsonar.qualitygate.wait=true
  artifacts:
    when: always
    reports:
      junit: target/*-reports/TEST-*.xml

package-image:
  stage: package
  image: docker:27
  services: [docker:27-dind]
  needs: [compile]
  variables:
    IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build -t "$IMAGE" .
    - docker push "$IMAGE"
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE"

deploy-staging:
  stage: deploy
  needs: [package-image]
  environment:
    name: staging
    url: https://staging.example.com
    deployment_tier: staging
  id_tokens:
    VAULT_ID_TOKEN:                    # توکن OIDC برای گرفتن راز از Vault
      aud: https://vault.example.com
  script:
    - ./deploy.sh staging "$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-prod:
  stage: deploy
  needs: [deploy-staging]
  environment:
    name: production
    deployment_tier: production
  when: manual                         # دروازهٔ انسانی: continuous delivery
  script:
    - ./deploy.sh production "$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_TAG

needs مهم‌ترین کلیدواژه برای سرعت است: بدون آن هیچ job از stage بعدی شروع نمی‌شود تا همهٔ job‌های stage قبلی تمام شوند، ولی با needs یک گراف جهت‌دار (DAG) می‌سازی و هر job به‌محض آماده‌شدن پیش‌نیازش شروع می‌شود — روی pipelineهای واقعی معمولاً ۳۰ تا ۵۰ درصد از زمان کل را حذف می‌کند. interruptible: true هم اجرای قدیمی را با push جدید لغو می‌کند.

۳.۲ GitHub Actions

name: build-and-deploy

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

permissions:
  contents: read               # پیش‌فرض حداقلی؛ هر job چیزی که لازم دارد اضافه می‌کند

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0       # Sonar و ابزار نسخه‌دهی به تاریخچه نیاز دارند

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: '21'
          cache: maven         # cache داخلی ~/.m2 بر پایهٔ hash فایل pom

      - run: mvn -B -ntp --fail-at-end verify

  image:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write          # لازم برای امضای keyless با cosign
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}

  deploy-prod:
    needs: image
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production     # قوانین تأیید در تنظیمات environment تعریف می‌شوند
    steps:
      - run: ./deploy.sh production "${{ github.sha }}"
پین‌کردن action با tag یک آسیب‌پذیری واقعی است، نه سلیقه

در مارس ۲۰۲۵ یک action پرکاربرد (tj-actions/changed-files، شناسهٔ CVE-2025-30066) دستکاری شد: مهاجم tagهای موجود را به یک commit مخرب اشاره داد که حافظهٔ runner را برای توکن و کلید اسکن می‌کرد و در log چاپ می‌کرد؛ بیش از ۲۰ هزار مخزن در معرض قرار گرفتند. علتِ ریشه‌ای ساده بود: tag در Git تغییرپذیر است. درمان: هر action شخص ثالث را با hash کامل commit پین کن و Dependabot را روی github-actions روشن کن:

- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

در کنارش permissions را حداقلی بگذار و هرگز pull_request_target را با checkout کردن کد PR ترکیب نکن.

۳.۳ Jenkins — declarative pipeline

pipeline {
  agent {
    docker {
      image 'maven:3.9-eclipse-temurin-21'
      args '-v $HOME/.m2:/root/.m2'
    }
  }

  options {
    timeout(time: 30, unit: 'MINUTES')
    buildDiscarder(logRotator(numToKeepStr: '30'))
    disableConcurrentBuilds()
  }

  environment { IMAGE = "registry.example.com/team/payments"; TAG = "${env.GIT_COMMIT.take(8)}" }

  stages {
    stage('Build') {
      steps { sh 'mvn -B -ntp -DskipTests clean package' }
    }

    stage('Test') {
      steps { sh 'mvn -B -ntp verify' }
      post  { always { junit 'target/*-reports/TEST-*.xml' } }
    }

    stage('Image') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'registry',
                                          usernameVariable: 'REG_USER',
                                          passwordVariable: 'REG_PASS')]) {
          sh '''
            echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com
            docker build -t "$IMAGE:$TAG" .
            docker push "$IMAGE:$TAG"
          '''
        }
      }
    }

    stage('Deploy to production') {
      when { buildingTag() }
      input {
        message "Deploy ${TAG} to production?"
        submitter "release-managers"
      }
      steps { sh './deploy.sh production "$TAG"' }
    }
  }

  post { cleanup { cleanWs() } }
}
در Jenkins از رشتهٔ درون‌یابی‌شدهٔ Groovy برای راز استفاده نکن

sh "docker login -p ${PASS}" با دابل‌کوت، رمز را همان لحظه داخل رشته می‌گذارد و آن رشته می‌تواند در log ظاهر شود؛ ضمناً هر کاراکتر خاصی در رمز دستور را می‌شکند. همیشه از withCredentials استفاده کن و داخل sh '''...''' (سینگل‌کوت) به متغیر محیطی ارجاع بده. Jenkins مقادیر credential را ماسک می‌کند — اما فقط اگر به‌شکل متغیر محیطی استفاده شده باشند.

مقایسهٔ سه ابزار

معیار GitLab CI GitHub Actions Jenkins
مدیریت راز variable ماسک/محافظت‌شده، id_tokens secrets، environment، OIDC credential store + withCredentials
دروازهٔ انسانی when: manual + environment environment + reviewer input + RBAC
قوت اصلی یکپارچگی کامل با مخزن و رجیستری سرعت شروع و اکوسیستم انعطاف بی‌نهایت، سازگاری با هر زیرساخت
ضعف اصلی وابستگی به یک فروشنده امنیت زنجیرهٔ تأمین actionها بار نگهداری plugin
اگر یک تیم ۲۰ نفره را از Jenkins به ابزار جدید ببری، چه معیاری داری؟

اول می‌پرسم Jenkins دقیقاً کجا درد دارد. اگر مشکل «plugin قدیمی و ارتقاهای شکننده» است، مهاجرت منطقی است. اگر مشکل «pipelineهای ۸۰۰ خطیِ Groovy» است، ابزار عوض کردن آن را حل نمی‌کند — طراحی بد را به YAML منتقل می‌کند.

معیارهای واقعی: مکان سورس (اگر کد در GitLab است، GitLab CI نزدیک‌ترین مسیر است)، الزامات شبکه (اگر build باید داخل شبکهٔ بسته و روی سخت‌افزار خاص اجرا شود، Jenkins هنوز رقیب جدی است)، و هزینهٔ نگهداری.

و مهم‌ترین نکته: مهاجرت را پیوندی (strangler) انجام می‌دهم، نه یک‌شبه — یک سرویس کم‌ریسک را کامل می‌برم، الگو را به یک قالب مشترک استخراج می‌کنم، بعد بقیه را یکی‌یکی. مهاجرت هم‌زمان ۴۰ pipeline تقریباً همیشه به یک فصل قرمزِ چندهفته‌ای ختم می‌شود.


۴. تکرارپذیری build و مخزن artifact

build تکرارپذیر (reproducible) یعنی با همان سورس، همان دستور و همان محیط، خروجی بایت‌به‌بایت یکسان باشد؛ بدون آن نمی‌توانی ثابت کنی artifactِ روی production از کدام commit آمده. مانع اصلی در Java ساده است: jar در واقع zip است و zip برای هر ورودی یک مُهر زمانی دارد، پس هر build یک jar با هش متفاوت می‌سازد حتی اگر یک بایت کد عوض نشده باشد. راه‌حل رسمی Maven یک ویژگی است:

<properties>
  <project.build.outputTimestamp>2026-07-01T00:00:00Z</project.build.outputTimestamp>
</properties>

با mvn artifact:check-buildplan می‌بینی کدام plugin هنوز سازگار نیست؛ از شاخهٔ ۴ به بعد Maven این حالت را پیش‌فرض می‌کند. اما تکرارپذیری فقط با حذف ورودی‌های نامعین کامل می‌شود: وابستگی SNAPSHOT، image پایه با tag متغیر، و دانلود مستقیم از اینترنت وسط build. درمان هر سه یکی است: نسخهٔ ثابت، digest ثابت، همه‌چیز از mirror داخلی.

Nexus و Artifactory

مخزن artifact سه نقش دارد: proxy (آینهٔ مخزن‌های عمومی؛ اگر اینترنت قطع شد یا بستهٔ بالادست پاک شد، build زنده می‌ماند)، hosted (جای artifactهای خودت)، و group (یک URL که چند مخزن را جمع می‌کند).

در ~/.m2/settings.xml روی runner یک <mirror> با <mirrorOf>*</mirrorOf> تعریف می‌کنی تا همهٔ دانلودها از مخزن داخلی بیایند، و اعتبارنامهٔ انتشار را در <servers> می‌گذاری که مقادیرش از متغیر محیطی خوانده شود.

چیت‌شیت دستورهای Maven در CI

دستور کاربرد
mvn -B -ntp dependency:go-offline پیش‌دانلود وابستگی‌ها برای گرم‌کردن cache
mvn -B -ntp verify build کامل + unit + integration test (دستور استاندارد CI)
mvn -B versions:set -DnewVersion=1.4.2 -DgenerateBackupPoms=false تعیین نسخه بدون فایل پشتیبان
mvn -B -ntp deploy -DskipTests انتشار artifact در Nexus/Artifactory
mvn artifact:check-buildplan بررسی سازگاری plugin‌ها با build تکرارپذیر

1.4.2-SNAPSHOT یعنی «هر بار ممکن است محتوایش عوض شود»؛ اگر artifact مسیر release به یک SNAPSHOT وابسته باشد، build دیروز و امروز دو چیز متفاوت‌اند. با قانون requireReleaseDeps در maven-enforcer اجباری‌اش کن، و در مخزن سیاست نگه‌داری بگذار: SNAPSHOTها بعد از ۳۰ روز پاک شوند، releaseها هرگز بازنویسی یا حذف نشوند.


۵. نسخه‌دهی و release خودکار

نسخه‌دهی معنایی (SemVer) یعنی MAJOR.MINOR.PATCH: MAJOR برای تغییر ناسازگار با عقب، MINOR برای قابلیت جدیدِ سازگار، PATCH برای رفع باگ سازگار. برای یک کتابخانه این قرارداد حیاتی است چون مصرف‌کننده بر اساسش تصمیم می‌گیرد؛ برای یک سرویس مستقر کمتر معنی دارد و خیلی تیم‌ها به نسخهٔ تاریخ‌محور یا هش commit می‌روند. مهم این است که نسخه یکتا، مرتب و قابل ردیابی به یک commit باشد.

Conventional Commits یک قرارداد کوچک روی پیام commit است که ماشین بتواند بخواند:

feat(payments): add idempotency key to transfer API
fix(auth): reject expired refresh tokens
feat(api)!: remove deprecated /v1/transfer endpoint

fix یعنی PATCH، feat یعنی MINOR، و ! یا BREAKING CHANGE یعنی MAJOR. با این قرارداد، ابزار می‌تواند نسخهٔ بعدی و changelog را خودش بسازد.

خیلی تیم‌ها maven-release-plugin را کنار گذاشته‌اند چون release:prepare release:perform دو بار build می‌کند، دو commit می‌زند و در CI با شاخهٔ جداشده دردسر می‌سازد. الگوی امروزی‌تر: نسخه را از tag بگیر، یک بار build کن، همان artifact را منتشر کن، و changelog را از commitهای قراردادی بساز (JReleaser یا semantic-release این را خودکار می‌کنند). اصل تغییرنکردنی: نسخه یک برچسب روی artifact است، نه دلیلی برای build دوباره.

یک artifact را چطور از dev به production ترفیع می‌دهی؟ نسخه‌اش عوض می‌شود؟

نه، و این نکتهٔ اصلی است. artifact یک بار ساخته می‌شود و همان بایت‌ها جابه‌جا می‌شوند؛ چیزی که عوض می‌شود پیکربندی و مقصد است، نه محتوا. در عمل image با digest مشخص در رجیستری می‌ماند و ترفیع یعنی همان digest در manifest محیط بعدی نوشته می‌شود؛ اگر رجیستری‌های جدا داری، crane copy یا skopeo copy لایه‌ها را بدون rebuild کپی می‌کند و digest حفظ می‌شود.

چیزی که هرگز نمی‌کنم build دوباره با پروفایل -Pprod است؛ آن لحظه ضمانت «آنچه تست کردم همان است که اجرا می‌شود» را از دست می‌دهم. برای ردیابی هم نسخه و هش commit را در خودِ artifact می‌گذارم (build-info در Spring Boot) تا /actuator/info بگوید کدام commit در حال اجراست.


۶. ترفیع محیط و پیکربندی

ترفیع (promotion) یعنی حرکت همان artifact به محیطی که اعتماد بیشتری می‌طلبد. مسیر معمول این است: یک بار build، بعد dev (خودکار) → test (خودکار + e2e) → staging (شبیه production) → دروازه → production (اول canary، بعد کامل). نکتهٔ کلیدی این است که پیکربندی بیرون از artifact است و در زمان اجرا تزریق می‌شود.

سه لایه روی هم می‌نشینند: پیش‌فرض‌های بی‌خطر داخل artifact (server.port=8080)، تفاوت‌های هر محیط از متغیر محیطی (spring.datasource.url=${DB_URL})، و راز از Secret یا Vault (spring.datasource.password=${DB_PASSWORD}) — هرگز از فایلی داخل image.

دو قاعدهٔ عملی خیلی کمک می‌کنند. اول fail fast روی پیکربندی: اگر متغیر لازم غایب است، سرویس باید در startup بمیرد، نه اینکه با مقدار پیش‌فرض بالا بیاید و ساعت‌ها بعد رفتار عجیب بدهد (در Spring با @ConfigurationProperties و @Validated). دوم، جدول تفاوت محیط‌ها را کوچک نگه دار؛ هرچه staging و production بیشتر فرق کنند، ارزش تست در staging کمتر است.

و یک هشدار: استفاده از spring.profiles.active=prod برای انتخاب یک URL بی‌ضرر است، اما وقتی پروفایل تعیین می‌کند کدام @Bean ساخته شود، تو در staging یک برنامهٔ متفاوت اجرا کرده‌ای و باگی که فقط با bean واقعی رخ می‌دهد اولین بار روی production دیده می‌شود. پروفایل فقط مقدار را عوض کند، نه گراف اشیاء را.


۷. راز در CI، به‌شکل امن

روش امنیت مناسب برای ریسک اصلی
مقدار ثابت در مخزن هیچ‌وقت برای همیشه در تاریخچهٔ Git می‌ماند
متغیر CI (masked + protected) متوسط توکن داخلی، رمز رجیستری طولانی‌عمر؛ با نشتِ log لو می‌رود
OIDC / ID token به ابر یا Vault خیلی خوب دسترسی ابر و Vault شرط اعتمادِ گشاد
Vault با راز پویا عالی دسترسی دیتابیس و ابر پیچیدگی عملیاتی

قبلاً برای اینکه pipeline روی ابر deploy کند، یک کلید طولانی‌عمر در متغیرهای CI می‌گذاشتی که سال‌ها زنده می‌ماند. با OIDC فراهم‌کنندهٔ CI برای هر job یک توکن کوتاه‌عمر امضا می‌کند با ادعاهایی مثل «مخزن X، شاخهٔ main، workflow مشخص»؛ طرف مقابل آن را تأیید می‌کند و یک credential چنددقیقه‌ای می‌دهد. هیچ راز طولانی‌عمری برای دزدیدن وجود ندارد.

# GitLab: درخواست ID token برای Vault
deploy-prod:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  secrets:
    DB_PASSWORD:
      vault: prod/payments/db@ops
پنج راهی که راز از pipeline بیرون می‌زند

۱. set -x یا echo — در حالت trace هر دستور با مقدار بازشدهٔ متغیرها چاپ می‌شود؛ ماسک‌کردنِ CI فقط تطبیق رشتهٔ دقیق است، پس اگر راز را base64 کنی ماسک کار نمی‌کند. ۲. artifact و گزارش — یک فایل .env که تصادفاً در artifacts:paths افتاده برای هر کسی که به pipeline دسترسی دارد قابل دانلود است. ۳. MR از fork — در مخزن عمومی، اگر متغیر protected نباشد کد غریبه به رازها می‌رسد. ۴. لایه‌های imageARG SECRET در RUN در تاریخچهٔ لایه می‌ماند؛ از RUN --mount=type=secret استفاده کن. ۵. خط فرمان-Dsonar.token=$TOKEN در جدول process دیده می‌شود؛ راز را از متغیر محیطیِ ابزار بخوان.

و مستقل از همه: چرخش را از قبل تمرین کن. اگر عوض‌کردن یک راز production ترسناک است، هنوز لو نرفته ولی مشکل داری.

چطور به pipeline دسترسی production می‌دهی بدون کلید ثابت؟

با federation هویت از طریق OIDC: یک بار اعتماد بین فراهم‌کنندهٔ CI و طرف مقابل برقرار می‌شود و یک نقش با شرط‌های دقیق تعریف می‌شود. نکتهٔ حساس دقیق‌بودن آن شرط‌هاست — اگر شرط را «هر job از این سازمان» بگذاری، هر مخزنی در سازمان به production می‌رسد؛ شرط باید مخزن، شاخه یا تگ، و ترجیحاً environment مشخص را ببندد. گشاد بستنِ همین شرط رایج‌ترین خطای پیکربندی OIDC است.

بعد job توکن را می‌گیرد، با نقش عوضش می‌کند و credential کوتاه‌عمر می‌گیرد؛ برای دیتابیس، Vault می‌تواند راز پویا بدهد. مزیت ممیزی هم مهم است: هر دسترسی به یک اجرای مشخص گره خورده.


۸. مهاجرت دیتابیس در pipeline

عوض‌کردن ریل زیر قطارِ در حال حرکت

استقرار بدون downtime یعنی نسخهٔ قدیم و جدید برنامه برای مدتی هم‌زمان روی همان دیتابیس کار می‌کنند. مثل عوض‌کردن ریل زیر قطاری که نمی‌ایستد: اول ریل جدید را کنارش می‌سازی، قطار را روی آن می‌بری، بعد قدیمی را جمع می‌کنی. هر تغییر schema که این قانون را نقض کند — حذف ستون، تغییر نام، سفت‌کردن محدودیت — نسخهٔ قدیمی را همان لحظه می‌شکند.

ویژگی Flyway Liquibase
قالب تغییر SQL خام (و migration جاوایی) XML/YAML/JSON/SQL
مستقل از دیتابیس نه — SQL خودت را می‌نویسی بله — SQL هر موتور را تولید می‌کند
rollback فقط در نسخهٔ تجاری خودکار برای بسیاری از changeTypeها
مناسب وقتی یک موتور داری و SQL دقیق می‌خواهی باید از چند موتور پشتیبانی کنی
flyway -url="$DB_URL" -user="$DB_USER" -password="$DB_PASSWORD" migrate

liquibase --changelog-file=db/changelog-master.yaml rollback --tag=v1.4.1
هرگز یک migration اجراشده را ویرایش نکن

هر دو ابزار checksum فایل را ذخیره می‌کنند. اگر V12__add_index.sql را که دیروز روی production اجرا شده ویرایش کنی، اجرای بعدی با خطای checksum شکست می‌خورد — و این درست است: دارد می‌گوید «چیزی که در دیتابیس اجرا شده با چیزی که در مخزن است فرق دارد». راه درست یک فایل جدید است؛ flyway repair فقط برای تغییر واقعاً بی‌ضرر است.

الگوی expand-contract

«چهار گام تغییر سازگارِ عقب‌رو در schema» — Four phases of a backward-compatible schema change

stateDiagram-v2
  [*] --> Expand: add new column, keep old
  Expand --> DualWrite: app writes both, reads old
  DualWrite --> Migrate: backfill + switch reads
  Migrate --> Contract: drop old column
  Contract --> [*]

هر گام یک release جداگانه است و قانون طلایی این است: هر نسخهٔ برنامه باید با schema قبلی و بعدیِ خودش کار کند. مثال: تغییر نام email به email_address بدون downtime.

گام ۱ — گسترش: ستون جدید را nullable و بدون محدودیت اضافه کن.

ALTER TABLE customers ADD COLUMN email_address varchar(320);

گام ۲ — نوشتن دوگانه: نسخهٔ جدید در هر دو ستون می‌نویسد و هنوز از قدیمی می‌خواند.

گام ۳ — پرکردن عقب‌ماند: داده‌های قدیمی را دسته‌دسته منتقل کن، نه با یک UPDATE غول‌پیکر.

UPDATE customers SET email_address = email
 WHERE email_address IS NULL
   AND id IN (SELECT id FROM customers WHERE email_address IS NULL
              ORDER BY id LIMIT 10000);

بعد index را بدون قفل‌کردن جدول بساز و محدودیت را با مهلت قفل سفت کن:

CREATE INDEX CONCURRENTLY idx_customers_email_address ON customers (email_address);
SET lock_timeout = '3s';
ALTER TABLE customers ALTER COLUMN email_address SET NOT NULL;
قفل‌ها را زمان‌دار کن، و مراقب تراکنش باش

در PostgreSQL دستور CREATE INDEX CONCURRENTLY نمی‌تواند داخل بلوک تراکنشی اجرا شود؛ Flyway پیش‌فرض هر migration را در transaction می‌پیچد، پس باید برای آن migration خاموشش کنی، و اگر نیمه‌کاره بماند یک index در وضعیت INVALID می‌ماند.

مهم‌تر: یک ALTER TABLE که پشت یک تراکنش طولانی گیر کند، صف قفل می‌سازد و جدول را برای خواندن هم می‌بندد — همیشه قبل از DDL مهلت بگذار و در صورت شکست دوباره تلاش کن. روی جدول خیلی بزرگ در PostgreSQL، به‌جای SET NOT NULL مستقیم می‌توانی اول CHECK ... NOT VALID اضافه کنی و بعد VALIDATE CONSTRAINT بزنی.

گام ۴ — جمع‌کردن: بعد از اطمینان از اینکه هیچ نسخهٔ قدیمی در حال اجرا نیست، ستون قدیمی را حذف کن.

ALTER TABLE customers DROP COLUMN email;

در Oracle، SET UNUSED فوری و بی‌هزینه است و DROP UNUSED COLUMNS کار سنگین را در پنجرهٔ کم‌ترافیک انجام می‌دهد؛ در PostgreSQL خودِ DROP COLUMN فقط ستون را نامرئی می‌کند ولی همچنان یک قفل انحصاری کوتاه می‌گیرد.

migration را در startup برنامه اجرا می‌کنی یا در یک job جدا؟

برای production، در یک job مجزا و قبل از استقرار. اجرای خودکار در startup برای توسعه عالی است اما در production سه مشکل دارد: وقتی چند replica هم‌زمان بالا می‌آیند همه سعی می‌کنند migrate کنند و startup کند و غیرقابل‌پیش‌بینی می‌شود (probeها ممکن است pod را بکشند)؛ برنامه به کاربر دیتابیس با دسترسی DDL نیاز پیدا می‌کند، یعنی سرویسی که به اینترنت وصل است می‌تواند جدول drop کند؛ و شکست migration به شکل «pod بالا نمی‌آید» ظاهر می‌شود، نه یک مرحلهٔ قرمز و خوانا در pipeline.

پس: job جداگانه با کاربر دارای DDL قبل از استقرار؛ برنامه با کاربری که فقط DML دارد و spring.flyway.enabled=false. ترتیب همیشه ثابت است: migration سازگارِ عقب‌رو اول، کد بعد.

یک ستون را چطور بدون downtime تغییر نام می‌دهی؟

با expand-contract در چند release، هرگز در یک release. اول ستون جدید nullable اضافه می‌شود (برای نسخهٔ در حال اجرا نامرئی است)؛ بعد کدی که در هر دو ستون بنویسد ولی از قدیمی بخواند — تا اینجا rollback هم بی‌خطر است؛ بعد backfill دسته‌دسته و با مکث تا replication و I/O خفه نشود؛ بعد انتقال خواندن به ستون جدید و یک دورهٔ مشاهده؛ بعد قطع نوشتن در ستون قدیمی؛ و فقط در آخر drop.

چیزی که هرگز نمی‌کنم ALTER TABLE ... RENAME COLUMN در یک قدم است: در همان میلی‌ثانیه هر instance در حال اجرا خطای «ستون وجود ندارد» می‌گیرد. و چون هزینه‌اش پنج release است، seniorها سؤال بعدی را هم می‌پرسند: «آیا این تغییر نام واقعاً ارزشش را دارد؟»


۹. استراتژی‌های استقرار

«چهار الگوی استقرار و نحوهٔ جابه‌جایی ترافیک» — Four deployment patterns and how traffic moves

flowchart TD
  subgraph Recreate
    R1[Stop v1] --> R2[Start v2]
  end
  subgraph Rolling
    O1[v1 pods] --> O2[replace one by one] --> O3[v2 pods]
  end
  subgraph BlueGreen
    B1[Blue v1 live] --> B2[Green v2 tested] --> B3[Switch router]
  end
  subgraph Canary
    C1[5% to v2] --> C2[Watch metrics] --> C3[25%] --> C4[100%]
  end
استراتژی Downtime منابع سرعت rollback شعاع انفجار مناسب برای
Recreate دارد ۱× متوسط همه کاربران کار دسته‌ای، dev، سرویسی که دو نسخه هم‌زمان نمی‌پذیرد
Rolling ندارد ~۱.۲× متوسط افزایشی پیش‌فرض معقول برای سرویس stateless
Blue-green ندارد ۲× خیلی سریع همه کاربران، اما کوتاه rollback فوری حیاتی است
Canary ندارد ~۱.۱× سریع درصد کوچکی از کاربران ترافیک بالا، تغییر پرریسک
Feature flag ندارد ۱× آنی، بدون deploy همان چیزی که flag می‌بندد جداکردن انتشار از استقرار
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments
spec:
  replicas: 6
  revisionHistoryLimit: 10       # چند نسخهٔ قبلی برای rollback نگه داشته شود
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2                # حداکثر ۲ pod بیشتر از هدف
      maxUnavailable: 0          # هیچ‌وقت ظرفیت کم نشود
  minReadySeconds: 15
  template:
    spec:
      containers:
        - name: app
          image: registry.example.com/team/payments@sha256:9f8c...
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
          lifecycle:
            preStop:
              exec: { command: ["sleep", "10"] }   # مهلت خروج از load balancer

preStop مشکلی را حل می‌کند که خیلی‌ها دیر کشف می‌کنند: وقتی pod حذف می‌شود، خروجش از endpointهای سرویس و ارسال سیگنال خاتمه هم‌زمان اتفاق می‌افتند، پس چند بستهٔ در راه به pod در حال مرگ می‌رسند و کاربر ۵۰۲ می‌بیند؛ یک مکث کوتاه این پنجره را می‌بندد. در کنارش server.shutdown=graceful را روشن کن.

برای canary تدریجی، Argo Rollouts یک kind: Rollout با گام‌های setWeight: 5pause: { duration: 5m }analysissetWeight: 25 → … → setWeight: 100 می‌دهد. مرحلهٔ analysis است که canary را از «امیدواری» به «تصمیم مبتنی بر داده» تبدیل می‌کند: یک کوئری به سیستم متریک، و اگر از آستانه رد شد rollout خودکار برگردانده می‌شود. اما اگر داشبورد تو نتواند نرخ خطا را به تفکیک نسخه نشان دهد، canary فقط یک rollout کندتر است — ۵٪ خطای بد در ۹۵٪ ترافیک سالم دیده نمی‌شود. پیش‌نیاز واقعی برچسب version روی همهٔ متریک‌هاست (فصل observability).

feature flag: انتشار در برابر استقرار

مهم‌ترین ایدهٔ اینجا: deploy یعنی کد روی سرور است؛ release یعنی کاربر آن را می‌بیند.

if (features.isEnabled("new-settlement-engine", customerId)) {
    return newEngine.settle(request);
}
return legacyEngine.settle(request);

خاموش‌کردن یک قابلیت خراب چند ثانیه طول می‌کشد، نه یک rollout کامل. هزینه‌اش این است که هر flag یک شاخهٔ اجرایی جدید است — با ۱۰ flag، ۱۰۲۴ ترکیب داری که هیچ‌کدام کامل تست نشده‌اند. قاعده: هر flag از روز اول تاریخ انقضا و مالک داشته باشد.

blue-green یا canary — کدام را انتخاب می‌کنی؟

به شکل ریسک بستگی دارد، نه به مد روز. blue-green وقتی درست است که ریسک اصلی «کار نکردن» باشد و برگشت فوری بخواهی: کل نسخهٔ جدید را کنار فعلی بالا می‌آوری، smoke test می‌زنی، بعد router را سوییچ می‌کنی. هزینه‌اش دو برابر منابع در لحظهٔ گذار است و مشکل اصلی‌اش دیتابیس مشترک — هر دو محیط روی یک دیتابیس‌اند، پس schema همچنان باید سازگارِ عقب‌رو باشد؛ blue-green جایگزین expand-contract نیست. canary وقتی درست است که ریسک اصلی «بد کار کردن زیر بار واقعی» باشد: نشت حافظه، کندی، رفتار بد با دادهٔ واقعی.

در عمل ترکیبشان می‌کنم: rolling برای روزمره، canary برای مسیرهای پرریسک، feature flag روی همه به‌عنوان کلید خاموش فوری. سؤال ثابت من: «معیار خودکارِ توقف چیست؟» و انتخاب معیار هم مهم است — برای سرویس پرداخت «نرخ موفقیت تراکنش» بهتر از نرخ خطای HTTP است، چون نسخه‌ای که ۲۰۰ برمی‌گرداند ولی تراکنش را رد می‌کند در متریک HTTP سالم به‌نظر می‌رسد.


۱۰. نقشهٔ rollback

هر استقرار باید قبل از شروع جواب سه سؤال را داشته باشد: از کجا می‌فهمیم خراب است؟ چطور برمی‌گردیم؟ چقدر طول می‌کشد؟

kubectl rollout history deployment/payments -n prod
kubectl rollout undo deployment/payments -n prod --to-revision=7

نکته‌های عملی: rollout undo فقط manifest را برمی‌گرداند و اگر GitOps داری کنترلر ظرف چند ثانیه دوباره حالت Git را اعمال می‌کند — در GitOps، rollback یعنی revert در Git. revisionHistoryLimit را خیلی کم نگذار وگرنه نسخه‌ای برای برگشت نداری. و rollback فقط وقتی کار می‌کند که نسخهٔ قبلی با وضعیت دنیا سازگار باشد: schema، قالب پیام در صف، قرارداد API.

rollback کد آسان است؛ rollback داده تقریباً هیچ‌وقت آسان نیست

اگر migration ستونی را drop کرده، برگشتِ کد داده را برنمی‌گرداند. اگر نسخهٔ جدید رکوردها را با قالب جدید نوشته، نسخهٔ قدیمی نمی‌تواند بخواندشان. اگر پیامی با schema جدید منتشر شده، مصرف‌کنندهٔ قدیمی روی آن می‌شکند.

سه قاعده: migrationها فقط اضافه کنند تا وقتی مطمئن شوی هیچ نسخهٔ قدیمی در گردش نیست؛ هر انتشار پرریسک پشت feature flag باشد تا «rollback» بدون استقرار ممکن شود؛ و برای تغییرات برگشت‌ناپذیر، fix رو به جلو را از قبل تمرین کن. سؤال درست در جلسهٔ طراحی: «نقشهٔ rollback ما چیست؟» — و اگر جواب «نداریم» است، آن تغییر باید پشت flag برود.


۱۱. GitOps

GitOps یعنی وضعیت مطلوب سیستم در Git توصیف شود و یک عامل داخل کلاستر مدام وضعیت واقعی را با Git مقایسه کند. تفاوت اصلی با CI/CD کلاسیک جهت است: در مدل push، pipeline به کلاستر وصل می‌شود و kubectl apply می‌زند (یعنی CI باید credential production را داشته باشد)؛ در مدل pull، کنترلر داخل کلاستر خودش Git را می‌خواند و CI فقط image می‌سازد و یک YAML را به‌روز می‌کند.

«مدل pull در GitOps» — The pull model in GitOps

flowchart LR
  CI[CI pipeline] -->|push image| Reg[(Registry)]
  CI -->|commit new digest| Cfg[(Config repo)]
  Ctl[GitOps controller in cluster] -->|watch| Cfg
  Ctl -->|pull image| Reg
  Ctl -->|apply| K8s[Cluster state]
  K8s -->|drift detected| Ctl
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: payments-prod
spec:
  source:
    repoURL: https://git.example.com/team/deploy-config.git
    targetRevision: main
    path: apps/payments/overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: payments
  syncPolicy:
    automated:
      prune: true        # چیزی که از Git حذف شد، از کلاستر هم حذف شود
      selfHeal: true     # تغییر دستی در کلاستر را پس بزن

دو ابزار غالب: Argo CD با رابط کاربری قوی و ApplicationSet برای تولید انبوه اپلیکیشن از یک قالب، و Flux که مجموعه‌ای از کنترلرهای ماژولار است و به‌روزرسانی خودکار image را داخلی دارد (در Argo با ابزار جداگانهٔ Image Updater).

دو تلهٔ اصلی GitOps

۱. راز در مخزن config. لحظه‌ای که manifestها را در Git می‌گذاری وسوسه می‌شوی Secret را هم بگذاری؛ base64 رمزنگاری نیست. راه‌حل: SOPS با کلید KMS، Sealed Secrets، یا External Secrets Operator که راز را در زمان اجرا از Vault می‌کشد.

۲. selfHeal و اشکال‌زدایی اضطراری. با selfHeal روشن، هر kubectl edit ظرف چند ثانیه پس زده می‌شود — این دقیقاً هدف است، اما تیم باید مسیر رسمی اضطراری داشته باشد (توقف موقت sync) وگرنه نیمه‌شبِ یک حادثه کسی نمی‌فهمد چرا تغییرش ناپدید می‌شود.

GitOps دقیقاً چه چیزی می‌دهد که یک اسکریپت deploy نمی‌دهد؟

سه چیز. کاهش سطح دسترسی: در مدل push، CI باید credential کلاستر production را داشته باشد و هر کسی که pipeline را دستکاری کند به production می‌رسد؛ در مدل pull، CI فقط به رجیستری و یک مخزن config می‌نویسد. همگرایی به‌جای اجرای یک‌باره: اسکریپت یک بار اجرا می‌شود و اگر بعداً کسی چیزی را دستی عوض کند هیچ‌کس نمی‌فهمد، ولی کنترلر GitOps انحراف را گزارش می‌کند یا برمی‌گرداند. ممیزی و rollback رایگان: تاریخچهٔ Git می‌گوید چه کسی کِی چه چیزی را عوض کرد و git revert یک rollback واقعی است.

محدودیتش هم روشن است: GitOps برای وضعیت اعلانی عالی است؛ کارهای رویه‌ای — migration، دادهٔ اولیه، ترتیب چندمرحله‌ای — هنوز به pipeline نیاز دارند.


۱۲. زیرساخت به‌عنوان کد با Ansible

IaC یعنی زیرساخت را با فایل‌های نسخه‌دار توصیف کنی، نه با کلیک و SSH. دو خانوادهٔ مکمل دارد: provisioning (ساختِ منابع — VM، شبکه، کلاستر؛ جای Terraform/OpenTofu) و configuration management (پیکربندی چیزی که ساخته شده؛ جای Ansible). Ansible agentless است (فقط SSH و Python روی مقصد) و idempotent.

دستور پخت در برابر توصیف غذا

اسکریپت shell یک دستور پخت است: «سیب‌زمینی را خرد کن» — دو بار اجرایش کنی، دو بار خرد می‌کند. Ansible توصیف نتیجه است: «سیب‌زمینی باید خردشده باشد»؛ اگر قبلاً خرد شده کاری نمی‌کند و می‌گوید ok، وگرنه انجام می‌دهد و می‌گوید changed. اسم این خاصیت idempotency است و تمام ارزش Ansible روی آن بنا شده.

میزبان‌ها در یک فایل inventory گروه‌بندی می‌شوند (مثلاً گروه [app] با app-01.example.com و app-02.example.com در inventories/production/hosts.ini)، و playbook روی همان گروه اجرا می‌شود:

- name: Deploy payments service
  hosts: app
  become: true
  serial: 1                    # یکی‌یکی — استقرار چرخشی روی VM
  vars:
    app_version: "1.4.2"
    app_dir: /opt/payments

  tasks:
    - name: Fetch the artifact from the repository
      ansible.builtin.get_url:
        url: "https://nexus.example.com/repository/releases/payments-{{ app_version }}.jar"
        dest: "{{ app_dir }}/payments-{{ app_version }}.jar"
        checksum: "sha256:{{ app_checksum }}"
        mode: "0640"

    - name: Point the current symlink at the new version
      ansible.builtin.file:
        src: "{{ app_dir }}/payments-{{ app_version }}.jar"
        dest: "{{ app_dir }}/current.jar"
        state: link
      notify: Restart payments

    - name: Wait until the instance reports ready
      ansible.builtin.uri:
        url: "http://127.0.0.1:8080/actuator/health/readiness"
        status_code: 200
      register: health
      retries: 30
      delay: 2
      until: health.status == 200

  handlers:
    - name: Restart payments
      ansible.builtin.systemd_service:
        name: payments
        state: restarted

سه ایدهٔ کلیدی: serial: 1 یعنی هر بار فقط یک ماشین به‌روزرسانی می‌شود — همان rolling update روی VM؛ handler فقط وقتی اجرا می‌شود که task مربوطه چیزی را عوض کرده باشد؛ و بررسی سلامت با until قبل از رفتن به ماشین بعدی جلوی «همه را با هم خراب کردن» را می‌گیرد.

دستور کاربرد
ansible all -i inventories/production/hosts.ini -m ping آزمودن اتصال به همهٔ میزبان‌ها
ansible-playbook ... --check --diff اجرای خشک: چه چیزی عوض می‌شد
ansible-playbook ... -e app_version=1.4.3 متغیر از خط فرمان (بالاترین اولویت)
ansible-vault encrypt_string 's3cr3t' --name db_password رمزکردن یک مقدار برای گذاشتن در Git
ansible-lint site.yml بررسی ایستای playbook قبل از اجرا
idempotency در Ansible یعنی چه و چرا مهم است؟

یعنی اجرای دوبارهٔ همان playbook روی همان سیستم هیچ تغییر اضافه‌ای ایجاد نکند؛ Ansible قبل از هر task وضعیت فعلی را می‌خواند و فقط در صورت اختلاف عمل می‌کند، به همین دلیل خروجی هر task یا ok است یا changed. اهمیتش سه‌لایه است: قابلیت اجرای دوباره، تشخیص انحراف (اجرای روزانه با --check می‌گوید کدام سرور دستی دستکاری شده)، و خوانایی خروجی (در اجرای سالم تعداد changed باید صفر باشد).

نکتهٔ senior این است که idempotency خودکار نیست: لحظه‌ای که shell: rm -rf /opt/app && tar xzf ... می‌نویسی آن را شکسته‌ای. ضمناً --check فقط برای ماژول‌هایی معنی دارد که حالت check را پیاده کرده‌اند — command و shell پیش‌فرض رد می‌شوند، یعنی خروجی اجرای خشک ممکن است بخشی از واقعیت را نشان ندهد. برای دستورهای ناگزیر creates و changed_when را صریح بنویس و ansible-lint را در CI اجباری کن.


۱۳. DORA: قضاوت pipeline با عدد

پژوهش DORA چهار متریک را به‌عنوان شاخص عملکرد تحویل تثبیت کرده — دو تا دربارهٔ سرعت و دو تا دربارهٔ پایداری:

متریک یعنی چه چطور اندازه می‌گیری
Deployment Frequency چند وقت یک‌بار به production می‌روی شمارش استقرارهای موفق در واحد زمان
Lead Time for Changes از commit تا اجرا روی production زمان merge تا استقرار همان commit
Change Failure Rate چند درصد استقرارها به مشکل ختم می‌شوند استقرارهای منجر به rollback/hotfix ÷ کل
Failed Deployment Recovery Time بعد از خرابی چقدر طول می‌کشد تا سالم شوی شروع حادثه تا بازیابی

به‌طور تقریبی، تیم‌های سطح Elite چند بار در روز deploy می‌کنند، lead time زیر یک روز دارند، نرخ خرابی حدود ۵٪ و بازیابی زیر یک ساعت؛ سطح Low کمتر از ماهی یک‌بار deploy می‌کند، lead time یک تا شش ماه، نرخ خرابی ۴۰٪ و بیشتر، و بازیابی یک هفته تا یک ماه (آستانه‌های دقیق هر سال کمی جابه‌جا می‌شوند). نکتهٔ مهم: این چهار عدد با هم بالا می‌روند؛ تصور رایج «یا سریع یا پایدار» غلط است، چون تیمی که سریع‌تر deploy می‌کند تغییرهای کوچک‌تری می‌فرستد. گزارش‌های اخیر شاخص‌های تکمیلی هم اضافه کرده‌اند — rework rate و reliability — و نشان داده‌اند افزایش سریع نرخ تولید کد بدون تقویت مسیر بازبینی و تحویل، پایداری را بدتر می‌کند.

متریک را برای بهبود سیستم استفاده کن، نه برای مقایسهٔ افراد

لحظه‌ای که «تعداد deploy» به ارزیابی عملکرد یک نفر وصل شود، عدد بی‌معنی می‌شود. استفادهٔ درست: نمودار lead time را بکش و ببین بیشترین زمان کجا هدر می‌رود — انتظار برای بازبینی؟ صف runner؟ معمولاً بزرگ‌ترین بخش زمان انتظار است، نه زمان اجرا. و همیشه هر چهار عدد را با هم گزارش کن؛ بالا بردن سرعت بدون نگاه به نرخ خرابی فقط سریع‌تر خراب‌کردن است.

pipeline تیم ۵۰ دقیقه طول می‌کشد. چه می‌کنی؟

اول اندازه می‌گیرم، بعد حدس می‌زنم: تفکیک زمان هر مرحله در چند اجرای اخیر، بعد سراغ بزرگ‌ترین سهم. الگوهای رایج: تست‌های integration ترتیبی؛ نبود needs/DAG؛ cache خراب یا کلید نامناسب؛ ساخت image بدون cache لایه؛ و اسکن‌های سنگین در مسیر بحرانی هر commit.

بعد pipeline را دولایه می‌کنم: لایهٔ سریع روی هر push (کامپایل، unit test، lint) با هدف زیر ۱۰ دقیقه چون این عددی است که توسعه‌دهنده منتظرش می‌ماند، و لایهٔ کامل روی merge یا زمان‌بندی‌شده (end-to-end، اسکن عمیق، تست بار). دو کار دیگر بازدهی بالایی دارند: runnerهای بزرگ‌تر برای مرحله‌های محدود به CPU، و کشتن تست‌های flaky — تستی که ۵٪ مواقع شکست می‌خورد اعتماد به pipeline را از بین می‌برد. در آخر زمان pipeline را به یک SLO تبدیل می‌کنم.

یک تغییر روی production خراب شده. قدم‌به‌قدم چه می‌کنی؟

اول بازیابی، بعد تشخیص؛ هدف اولیه سالم‌کردن سرویس است، نه فهمیدن علت. قدم یک: تأیید ارتباط با استقرار — تطبیق زمان شروع خرابی با rollout و مقایسهٔ متریک‌ها به تفکیک نسخه. قدم دو: توقف پیشروی؛ اگر canary است rollout را pause می‌کنم و اگر feature flag دارد همان لحظه خاموشش می‌کنم. قدم سه: rollback، مگر اینکه migration اجازه ندهد — در آن صورت fix رو به جلو. قدم چهار: اعلام وضعیت با یک کانال و یک هماهنگ‌کننده؛ نبود این، حادثه را طولانی‌تر می‌کند تا خودِ باگ.

بعد از سالم‌شدن، post-mortem بدون سرزنش فرد: سؤال درست «چرا pipeline اجازه داد این به production برسد» است و خروجی باید یک تغییر مشخص در سیستم باشد. معیاری که همیشه می‌سنجم زمان تا rollback است؛ اگر بیش از ۱۵ دقیقه است، همین مهم‌ترین کار بعدی تیم است.


۱۴. ضدالگوهای pipeline

ضدالگو چرا خراب است جایگزین
build جدا برای هر محیط چیزی که تست کردی همان چیزی نیست که اجرا می‌شود یک بار build، ترفیع همان artifact
تست flaky که با retry پوشانده می‌شود اعتماد به pipeline از بین می‌رود و باگ واقعی گم می‌شود تست را پایدار کن یا حذفش کن
pipeline کپی‌شده در هر مخزن هر اصلاح باید ۴۰ جا تکرار شود template / reusable workflow / shared library
راز در متغیر ساده و طولانی‌عمر با یک log ناقص لو می‌رود و هیچ‌وقت چرخانده نمی‌شود OIDC، راز پویا، متغیر protected
migration در startup برنامه روی production startup کند، دسترسی DDL بی‌مورد، خطای نامفهوم job مجزا قبل از استقرار
شاخهٔ طولانی‌عمر با pipeline سبز تو CI نداری؛ فقط build server داری شاخهٔ کوتاه‌عمر + feature flag
جمع‌بندی

CI یعنی عادتِ ادغام مکرر، نه داشتن ابزار. Continuous delivery یعنی هر build سبز آمادهٔ production است؛ continuous deployment یعنی دکمهٔ انسانی هم برداشته شده — و برداشتنش فقط با تست خوب، استقرار تدریجی و rollback خودکار امن است. pipeline را با منطق fail fast بچین؛ در Java همیشه mvn verify بزن، نه package.

قانون مرکزی: یک بار build کن، همان artifact را promote کن. با digest استقرار کن نه با tag متغیر، actionها را با hash پین کن، و راز را با OIDC و توکن کوتاه‌عمر بگیر نه با کلید ثابت.

دیتابیس گلوگاه واقعی استقرار بدون downtime است: expand-contract با migrationهای فقط-افزایشی، backfill دسته‌ای، DDL با مهلت قفل، و اجرا در job جدا نه در startup. استراتژی استقرار را از روی شکل ریسک انتخاب کن — rolling برای روزمره، blue-green برای برگشت فوری، canary برای ریسک زیر بار واقعی، feature flag به‌عنوان کلید خاموش آنی — و قبل از هر استقرار بدان چطور و در چند دقیقه برمی‌گردی.

GitOps دسترسی production را از CI می‌گیرد و انحراف را همگرا می‌کند؛ Ansible پیکربندی سرور را idempotent می‌کند و با serial همان rolling update را روی VM می‌دهد. و در آخر با DORA قضاوت کن: سرعت و پایداری با هم بالا می‌روند، نه به قیمت هم.

One senior-interview question floors a lot of people: "From git push to a real user seeing your code, what exactly does it pass through?" The junior answer is "Jenkins builds it and deploys it." The senior answer is a chain: what gets built, how many times, who signs it, when the database migrates, how you get back if it breaks, and how you find out it broke.

This chapter is that chain — not a tour of tools, but the engineering of the delivery path. Anything covered elsewhere is only referenced: tests in testing, Maven/Gradle in build-tools, Docker and Kubernetes in containers-jvm, metrics and logs in observability, keys in key-management-secrets.

Roadmap
  1. Concepts: CI, and continuous delivery vs continuous deployment.
  2. Pipeline anatomy: the stages of a real Java pipeline and why they are ordered that way.
  3. Implementation: the same project in GitLab CI, GitHub Actions and Jenkins.
  4. Reproducibility and versioning: cache vs artifact, reproducible builds, Nexus, SemVer, release automation.
  5. Secrets in CI: variables, OIDC, Vault, and the mistakes that leak them.
  6. Database: Flyway/Liquibase and expand-contract.
  7. Deployment: recreate, rolling, blue-green, canary, feature flags, rollback plans.
  8. GitOps and IaC: Argo CD/Flux and practical Ansible basics.
  9. Judgement: DORA metrics and pipeline anti-patterns.

1. The problem CI solves

Integration hell

Five people write one book. Each takes a full copy, works alone for three months, and at the end they sit down to merge. The first renamed the main character, the second deleted chapter 4, the third changed the tense — now "merging" is itself a two-month project.

That was software before CI, and it had a name: integration hell. The fix: instead of one three-month merge, several small merges a day. Small merge, small pain.

CI: continuous integration

Continuous Integration means every developer merges into the mainline at least once a day, and every merge is built and tested automatically. Two words in that definition get ignored:

  • "into the mainline": if you work three weeks on a feature branch and the pipeline is green on that branch, you don't have CI — you have a build server. CI means your code is actually combined with everyone else's.
  • "automatically": if someone has to press a button or run a script on their laptop, sooner or later it won't run.

Three questions tell you whether you really have CI: do branches live less than a day? When the pipeline goes red, is turning it green the top priority? Is main always releasable? If not, you have CI theatre. This is why CI fights long-lived branches: GitFlow was built for quarterly releases, while a service deploying several times a day wants trunk-based development — short-lived branches, daily merges, feature flags for unfinished work.

CD: continuous delivery vs continuous deployment

This is exactly where interviewers stop.

  • Continuous Delivery: anything out of a green pipeline is ready for production — built, tested, signed. A human presses the button; shipping is a commercial decision, not a technical one.
  • Continuous Deployment: the same minus the button — every commit that clears the gates goes to production automatically.

The difference is one button, and it can only be removed when tests, feature flags, canaries and automated rollback are mature enough that no human is needed.

"We do CD" is usually an accidental lie

If shipping needs a change form, a weekly meeting, and someone running a script on a server by hand, you don't have CD — you have a deploy script. Simple test: could today's last commit be in production by six this evening? If not, there is a human queue in the path that needs removing.

What is the difference between continuous delivery and continuous deployment, and which do you recommend?

Continuous delivery means every green build is capable of going to production and the only obstacle is a human decision; continuous deployment means that decision is gone too.

It depends on team maturity. Continuous deployment has three non-negotiable prerequisites: tests and smoke tests that catch obvious breakage; progressive delivery — canary or feature flags — keeping the blast radius small; and metric-driven automated rollback, not a phone call. Without them it just means breaking things faster.

The right path: make delivery flawless first, then remove the button. In regulated domains needing segregation of duties, delivery with one automated, recorded approval remains right.


2. Anatomy of a Java service pipeline

«مراحل خط لولهٔ یک سرویس Java و ترتیب منطقی‌شان» — Stages of a Java service pipeline and their ordering logic

flowchart TD
  A[1. Checkout] --> B[2. Restore dependency cache]
  B --> C[3. Compile]
  C --> D[4. Unit tests]
  D --> E[5. Integration tests / Testcontainers]
  D --> F[6. Static analysis + quality gate]
  E --> G[7. Dependency + container scan]
  F --> G
  G --> H[8. Package artifact + build image]
  H --> I[9. Push image + sign]
  I --> J[10. Deploy per environment]
  J --> K[11. Smoke tests + observe]

One principle drives that order: fail fast. Cheapest and fastest checks first; if the code doesn't compile, five minutes of security scanning is pointless.

2.1 Checkout

A shallow clone (git clone --depth 1) saves tens of seconds on a big repo but breaks anything needing history — git describe, changelogs, or Sonar deciding what "new code" is; in GitHub Actions use fetch-depth: 0. The bigger issue is workspace cleanliness: an agent up for months has a shared ~/.m2 and orphaned Testcontainers containers, so today's build goes green on something tomorrow's won't have. The standard answer is an ephemeral runner in a container.

2.2 Dependency cache

Without a cache, every job re-downloads the whole Maven dependency tree — two to three wasted minutes each.

mvn -B -ntp dependency:go-offline                      # warm the local repository
mvn -B -ntp -Dmaven.repo.local=.m2/repository verify   # local repo inside the project so the runner can cache it
Cache vs artifact

A cache is a "may or may not be there" speed optimisation; if it's missing the build must still work, just slower. An artifact "must be transferred" between stages: the final jar, test reports, the SBOM. Don't mix them: never cache the jar (a later build may pick up a stale one), never publish ~/.m2 as an artifact.

A bad cache key is a silent failure

If your key is maven-cache and never changes, the cache grows forever and a dependency you deleted from pom.xml is still on disk — the build stays green while it would fail on a clean machine. Tie the key to a hash of the lock file (hashFiles('**/pom.xml') or files: [pom.xml]) and add restore-keys for partial hits. Worse, in public repos the cache is a cache poisoning path — an untrusted PR run writes something the mainline build later reads; untrusted triggers should get read-only cache access.

2.3 Compile and unit tests

mvn -B -ntp -T 1C clean verify -DskipITs

None of those flags are decoration: -B is batch mode (no colour, no prompts), -ntp drops the download progress bar, -T 1C runs one thread per CPU core, -DskipITs skips only integration tests, and --fail-at-end keeps a multi-module build going so you see all errors at once.

The split everybody gets wrong: Surefire runs unit tests in the test phase (*Test, Test*), Failsafe runs integration tests in integration-test and checks results in verify (*IT). If Failsafe fails, post-integration-test still runs so resources get released.

`mvn package` in CI means your integration tests are theatre

The Maven lifecycle is ... test → package → verify → install → deploy. Failsafe verifies its results in verify. With package, IT tests either don't run at all or their failures are ignored. In CI, always run mvn verify.

2.4 Integration tests with Testcontainers

Writing tests belongs to testing; here only the CI part: Testcontainers needs a reachable Docker daemon. Three models: mounting the host Docker socket (fastest, but the job effectively gets root there), Docker-in-Docker as a service (isolated, slower), or a remote Docker endpoint.

integration-test:
  stage: test
  image: maven:3.9-eclipse-temurin-21
  services:
    - name: docker:27-dind
      alias: docker
  variables:
    DOCKER_HOST: "tcp://docker:2376"
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_CERT_PATH: "/certs/client"
    DOCKER_TLS_VERIFY: "1"
  script:
    - mvn -B -ntp -Dmaven.repo.local=.m2/repository verify
Three Testcontainers traps that only show up in CI

1. Don't enable testcontainers.reuse.enable=true in CI; it's a laptop feature, and in CI it leaks containers and causes cross-job interference. 2. If you must disable Ryuk (the reaper) with TESTCONTAINERS_RYUK_DISABLED=true, you need ephemeral runners or zombie containers fill the disk. 3. Pulling postgres:16 from a public registry every run hits rate limits at busy hours and turns the pipeline red with no code change; use an internal mirror.

2.5 Static analysis and quality gates

Static analysis means inspecting code without running it. Three layers that get conflated: formatting (Spotless, Checkstyle — subjective, auto-fix it), bug detection (SpotBugs on bytecode, plus Find Security Bugs for security patterns), and accumulated quality (SonarQube, focused on "new code").

mvn -B -ntp com.github.spotbugs:spotbugs-maven-plugin:check    # bug detection on bytecode

mvn -B -ntp verify org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \
    -Dsonar.projectKey=payments-service \
    -Dsonar.host.url="$SONAR_HOST_URL" \
    -Dsonar.token="$SONAR_TOKEN" \
    -Dsonar.qualitygate.wait=true

sonar.qualitygate.wait=true is critical: without it Maven ships the analysis and returns success immediately, so the gate can never break the build. And for Sonar to see coverage the JaCoCo agent must attach before the tests run (jacoco-maven-plugin with prepare-agent and report).

Gate on "new code", not on the whole repo

If you demand "80% total coverage" on a ten-year-old codebase, it will never be green and the team will switch the gate off. Clean as you code works: new or changed code must hit 80% coverage with zero new vulnerabilities; old debt gets cleaned up gradually.

The senior nuance: static analysis costs something too — false positives. If 40% of findings are wrong, the team learns to ignore all of them. Start with few precise rules, suppress with a comment, tighten gradually; a noisy gate is worse than no gate.

2.6 Dependency and container scanning

There are two different kinds of scan. SCA (Software Composition Analysis) matches your third-party libraries against vulnerability databases:

mvn -B -ntp org.owasp:dependency-check-maven:12.2.2:check \
    -DfailBuildOnCVSS=7 -DnvdApiKey="$NVD_API_KEY"

Image scanning inspects the OS layers of the image you built:

IMAGE=registry.example.com/team/payments:1.4.2
trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE"
trivy image --format cyclonedx --output sbom.cdx.json "$IMAGE"

An SBOM (Software Bill of Materials) lists exactly which libraries and versions are inside an artifact; the dominant formats are CycloneDX and SPDX. Its value shows the day a big vulnerability drops and a manager asks "which of our services ship that library?" — with an SBOM that's one query, without it a week of manual work.

Two settings that decide between a working gate and a disabled one

The NVD key: without an API key the NVD feed is heavily rate-limited and the first sync can take tens of minutes or fail with a 403. The key is free; cache the data directory too, and since the tool matches on CPE it needs a suppression file with a justification and an expiry date.

--ignore-unfixed: a Linux base image carries dozens of CVEs with no fix available; break the build on those and the team bypasses the scan within a week. Fail only on HIGH/CRITICAL that have a fix, and report the rest on a dashboard with its own SLA.

2.7 Building the artifact and image

The most important rule here: build once, promote many. Build the artifact once and move those same bytes from dev to staging to production. Rebuild per environment and what you tested in staging is not what runs in production — "but it worked in staging" becomes the standing post-mortem line.

Never deploy a mutable tag — deploy a digest

payments:1.4.2 is a pointer and can be re-pushed; payments@sha256:9f8c... is content-addressed and immutable. If Kubernetes deploys by tag and someone re-pushes it, the next pod that restarts gets something different — a deployment you never knew you made.

Turn on immutable tags in the registry, use the digest in manifests (docker inspect --format='{{index .RepoDigests 0}}' ...), and keep tags for readability only.

2.8 Signing and provenance

Scanning answers "is there something bad inside this image?"; signing answers "did this image really come from our pipeline?" In supply-chain attacks the second matters more. Sigstore/cosign makes keyless signing possible: the pipeline gets an OIDC token from the CI provider, Fulcio issues a short-lived certificate, and the signature lands in the Rekor transparency log — no private key to steal.

cosign sign --yes registry.example.com/team/payments@sha256:9f8c...

cosign verify \
  --certificate-identity "https://github.com/team/service/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  registry.example.com/team/payments@sha256:9f8c...

Signing is easy; the value appears when something rejects an unsigned image — in Kubernetes, a policy admission controller. Without that, a signature is decoration.

2.9 Deploy and smoke tests

A smoke test is a few small, fast checks right after deployment saying "the service is up and doing its job" — not a full suite, just no smoke.

kubectl rollout status deployment/payments --namespace prod --timeout=180s

# confirm the deployed version is the one we expected
curl --fail --silent https://payments.example.com/actuator/info | jq -e '.build.version == "1.4.2"'
`kubectl apply` without `rollout status` is blind deployment

apply only says "I recorded the object", not "a healthy pod came up"; without rollout status the pipeline goes green while the pod is in CrashLoopBackOff. And if the readiness probe is wrong, even rollout status lies. For Spring Boot, wire probes to /actuator/health/readiness and /actuator/health/liveness and set management.endpoint.health.probes.enabled=true.


3. The same pipeline in three tools

A Spring Boot service on Maven, Java 21.

3.1 GitLab CI

stages: [build, test, package, deploy]

default:
  image: maven:3.9-eclipse-temurin-21
  interruptible: true          # cancel an old run when a new push arrives
  cache:
    key:
      files: [pom.xml]         # key derived from pom contents
    paths: [.m2/repository]
    policy: pull               # only the build job writes the cache

variables:
  MAVEN_OPTS: "-Dmaven.repo.local=$CI_PROJECT_DIR/.m2/repository"
  MAVEN_CLI_OPTS: "-B -ntp --fail-at-end"

compile:
  stage: build
  cache:
    key: { files: [pom.xml] }
    paths: [.m2/repository]
    policy: pull-push          # this is the job that fills the cache
  script:
    - mvn $MAVEN_CLI_OPTS -DskipTests clean package
  artifacts:
    paths: [target/*.jar]
    expire_in: 1 day

test:
  stage: test
  needs: [compile]
  script:
    - mvn $MAVEN_CLI_OPTS verify sonar:sonar -Dsonar.qualitygate.wait=true
  artifacts:
    when: always
    reports:
      junit: target/*-reports/TEST-*.xml

package-image:
  stage: package
  image: docker:27
  services: [docker:27-dind]
  needs: [compile]
  variables:
    IMAGE: "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
    - docker build -t "$IMAGE" .
    - docker push "$IMAGE"
    - trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed "$IMAGE"

deploy-staging:
  stage: deploy
  needs: [package-image]
  environment:
    name: staging
    url: https://staging.example.com
    deployment_tier: staging
  id_tokens:
    VAULT_ID_TOKEN:                    # OIDC token used to fetch secrets from Vault
      aud: https://vault.example.com
  script:
    - ./deploy.sh staging "$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

deploy-prod:
  stage: deploy
  needs: [deploy-staging]
  environment:
    name: production
    deployment_tier: production
  when: manual                         # the human gate: continuous delivery
  script:
    - ./deploy.sh production "$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_TAG

needs is the most important keyword for speed: without it no job in the next stage starts until every job of the previous one finishes; with it you get a DAG where each job starts as soon as its own prerequisite is done — typically 30–50% off wall-clock time. interruptible: true cancels superseded runs and keeps the runner queue free.

3.2 GitHub Actions

name: build-and-deploy

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

permissions:
  contents: read               # minimal default; each job adds what it needs

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
        with:
          fetch-depth: 0       # Sonar and versioning tools need history

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: '21'
          cache: maven         # built-in ~/.m2 cache keyed on the pom hash

      - run: mvn -B -ntp --fail-at-end verify

  image:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write          # required for keyless signing with cosign
    steps:
      - uses: actions/checkout@v5
      - uses: docker/setup-buildx-action@v3
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

      - run: cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.push.outputs.digest }}

  deploy-prod:
    needs: image
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production     # approval rules live in the environment settings
    steps:
      - run: ./deploy.sh production "${{ github.sha }}"
Pinning actions by tag is a real vulnerability, not a preference

In March 2025 a widely used action (tj-actions/changed-files, CVE-2025-30066) was compromised: the attacker repointed existing tags at a malicious commit that scanned runner memory for tokens and printed them into the log; over 20,000 repositories were exposed. The root cause: a Git tag is mutable. Pin every third-party action to a full commit SHA and let Dependabot bump the pins:

- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

Alongside that, keep permissions minimal, and never combine pull_request_target with checking out the PR's code.

3.3 Jenkins — declarative pipeline

pipeline {
  agent {
    docker {
      image 'maven:3.9-eclipse-temurin-21'
      args '-v $HOME/.m2:/root/.m2'
    }
  }

  options {
    timeout(time: 30, unit: 'MINUTES')
    buildDiscarder(logRotator(numToKeepStr: '30'))
    disableConcurrentBuilds()
  }

  environment { IMAGE = "registry.example.com/team/payments"; TAG = "${env.GIT_COMMIT.take(8)}" }

  stages {
    stage('Build') {
      steps { sh 'mvn -B -ntp -DskipTests clean package' }
    }

    stage('Test') {
      steps { sh 'mvn -B -ntp verify' }
      post  { always { junit 'target/*-reports/TEST-*.xml' } }
    }

    stage('Image') {
      steps {
        withCredentials([usernamePassword(credentialsId: 'registry',
                                          usernameVariable: 'REG_USER',
                                          passwordVariable: 'REG_PASS')]) {
          sh '''
            echo "$REG_PASS" | docker login -u "$REG_USER" --password-stdin registry.example.com
            docker build -t "$IMAGE:$TAG" .
            docker push "$IMAGE:$TAG"
          '''
        }
      }
    }

    stage('Deploy to production') {
      when { buildingTag() }
      input {
        message "Deploy ${TAG} to production?"
        submitter "release-managers"
      }
      steps { sh './deploy.sh production "$TAG"' }
    }
  }

  post { cleanup { cleanWs() } }
}
In Jenkins, never put a secret in an interpolated Groovy string

sh "docker login -p ${PASS}" with double quotes bakes the password into the string, and that string can end up in the log; a special character in the password also breaks the command. Use withCredentials and reference the environment variable inside sh '''...''' (single quotes). Jenkins masks credentials — but only when they are used as environment variables.

The three tools compared

Criterion GitLab CI GitHub Actions Jenkins
Secrets masked/protected variables, id_tokens secrets, environments, OIDC credential store + withCredentials
Human gate when: manual + environment environment + reviewers input + RBAC
Main strength tight integration with repo and registry fastest start, huge ecosystem unlimited flexibility, fits any infrastructure
Main weakness single-vendor lock-in supply-chain risk of third-party actions plugin maintenance burden
If you moved a 20-person team off Jenkins, what would drive the decision?

First I'd ask where Jenkins actually hurts. If the pain is "aging plugins and fragile upgrades", migrating makes sense. If it's "800-line Groovy pipelines", changing tools just moves bad design into YAML.

The real criteria: where the source lives (code in GitLab makes GitLab CI the shortest path), network constraints (closed networks and special hardware keep Jenkins a serious contender), and maintenance cost. And I'd migrate strangler-style: move one low-risk service completely, extract the pattern into a shared template, then move the rest one by one. Migrating 40 pipelines at once almost always ends in a multi-week red period.


4. Build reproducibility and the artifact repository

A reproducible build means the same source, command and environment produce a byte-for-byte identical output; without it you cannot prove which commit the production artifact came from. The obstacle in Java: a jar is a zip, and zip stores a timestamp per entry, so every build yields a different hash even if no code changed. Maven's answer is a property:

<properties>
  <project.build.outputTimestamp>2026-07-01T00:00:00Z</project.build.outputTimestamp>
</properties>

mvn artifact:check-buildplan shows which plugins are still incompatible; from the 4.x line Maven enables this by default. But reproducibility needs the non-deterministic inputs gone too: SNAPSHOT dependencies, base images on floating tags, downloads straight from the internet mid-build. The cure for all three: fixed versions, fixed digests, everything through an internal mirror.

Nexus and Artifactory

An artifact repository plays three roles: proxy (a mirror of public repositories, so your build survives an internet outage or a deleted upstream package), hosted (where your own artifacts are published), and group (one URL fronting several repositories). In ~/.m2/settings.xml on the runner you define a <mirror> with <mirrorOf>*</mirrorOf> so all downloads come from inside, and put publishing credentials in <servers>, reading their values from environment variables.

Maven-in-CI cheat sheet

Command Purpose
mvn -B -ntp dependency:go-offline pre-download dependencies to warm the cache
mvn -B -ntp verify full build + unit + integration tests (the standard CI command)
mvn -B versions:set -DnewVersion=1.4.2 -DgenerateBackupPoms=false set the version without backup poms
mvn -B -ntp deploy -DskipTests publish the artifact to Nexus/Artifactory
mvn artifact:check-buildplan check plugin compatibility with reproducible builds

1.4.2-SNAPSHOT means "the contents may change on every fetch"; if a release-path artifact depends on one, yesterday's and today's builds are different things. Enforce it with maven-enforcer's requireReleaseDeps rule, and set repository retention: SNAPSHOTs purged after 30 days, releases never overwritten.


5. Versioning and automated release

Semantic Versioning (SemVer) is MAJOR.MINOR.PATCH: MAJOR for a backward-incompatible change, MINOR for a compatible capability, PATCH for a compatible fix. For a library the contract is vital because consumers decide on it; for a deployed service it matters less, and many teams use date-based versions or the commit hash. What matters is that it is unique, ordered and traceable to one commit.

Conventional Commits is a small contract on the commit message that a machine can read:

feat(payments): add idempotency key to transfer API
fix(auth): reject expired refresh tokens
feat(api)!: remove deprecated /v1/transfer endpoint

fix means PATCH, feat means MINOR, and ! or BREAKING CHANGE means MAJOR. With that convention, tooling can compute the next version and the changelog for you.

Many teams dropped maven-release-plugin because release:prepare release:perform builds twice, pushes two commits, and fights detached HEADs and scoped tokens in CI. The modern pattern: derive the version from the tag, build once, publish that artifact, generate the changelog from conventional commits (JReleaser or semantic-release automate this). The invariant: a version is a label on an artifact, not a reason to rebuild.

How do you promote an artifact from dev to production? Does its version change?

No, and that's the point. The artifact is built once and the same bytes move; only configuration and destination change. The image sits in the registry under a specific digest, and promotion means writing that digest into the next environment's manifest; across registries, crane copy or skopeo copy move layers without rebuilding and preserve the digest.

What I never do is rebuild with a -Pprod profile — that loses the guarantee that "what I tested is what runs". For traceability I embed version and commit hash in the artifact (build-info in Spring Boot).


6. Environment promotion and config

Promotion means moving the same artifact into an environment that demands more trust: build once, then dev (automatic) → test (automatic + e2e) → staging (production-like) → gate → production (canary first, then full). The key point: configuration lives outside the artifact and is injected at runtime.

Three layers stack up: safe defaults in the artifact (server.port=8080), per-environment values from env vars (spring.datasource.url=${DB_URL}), and secrets from a Secret or Vault (spring.datasource.password=${DB_PASSWORD}) — never a file inside the image.

Two rules help. First, fail fast on configuration: if a required variable is missing the service must die at startup, not come up on a default and behave strangely hours later (in Spring, @ConfigurationProperties plus @Validated). Second, keep per-environment differences few; the more staging and production differ, the less a staging test is worth.

One warning: spring.profiles.active=prod picking a URL is harmless, but when a profile decides which @Bean gets created you are running a different application in staging, and a bug that only appears with the real bean shows up first in production. A profile should change values, not the object graph.


7. Secrets in CI, done safely

Approach Security Good for Main risk
Hardcoded in the repo never stays in Git history forever
CI variable (masked + protected) medium internal tokens, registry passwords long-lived; leaks with a sloppy log
OIDC / ID token to cloud or Vault very good cloud and Vault access trust conditions scoped too loosely
Vault with dynamic secrets excellent database and cloud access operational complexity

Historically, to let a pipeline deploy to a cloud you put a long-lived key in CI variables that stayed alive for years. With OIDC the CI provider signs a short-lived token per job with claims like "repo X, branch main, this workflow"; the other side verifies it and returns a credential valid for minutes. No long-lived secret is left to steal.

# GitLab: request an ID token for Vault
deploy-prod:
  id_tokens:
    VAULT_ID_TOKEN:
      aud: https://vault.example.com
  secrets:
    DB_PASSWORD:
      vault: prod/payments/db@ops
Five ways secrets escape a pipeline

1. set -x or echo — in trace mode every command prints with variables expanded; CI masking is exact string matching, so base64-encoding a secret defeats it. 2. Artifacts and reports — a .env file that landed in artifacts:paths is downloadable by anyone with pipeline access. 3. Fork MRs — in a public repo, if the variable isn't protected, a stranger's code reaches your secrets. 4. Image layersARG SECRET used in a RUN stays in layer history; use RUN --mount=type=secret. 5. Command lines-Dsonar.token=$TOKEN is visible in the process table; read it from the tool's own environment variable.

And regardless of all of these: rehearse rotation. If rotating a production secret is scary, it hasn't leaked yet but you already have a problem.

How do you give a pipeline production access without storing a static key?

With identity federation over OIDC: trust is established once between the CI provider and the other side, and a role is defined with precise conditions. The delicate part is how precise those conditions are — scope it to "any job from this org" and any repository in the org reaches production; the condition must pin the repository, the branch or tag, and ideally the environment. Scoping it too loosely is the most common OIDC misconfiguration.

The job then exchanges its token for a short-lived credential; for databases Vault can issue a dynamic secret with a one-hour lease. The audit benefit matters too: every access is tied to one specific pipeline run.


8. Database migrations in the pipeline

Changing the rail under a moving train

Zero-downtime deployment means old and new versions run simultaneously against the same database for a while. Like changing the rail under a train that never stops: build the new rail alongside, move the train onto it, then remove the old. Any schema change that violates this — dropping a column, renaming, tightening a constraint — breaks the old version instantly.

Feature Flyway Liquibase
Change format raw SQL (and Java migrations) XML/YAML/JSON/SQL
Database-agnostic no — you write the SQL yes — it generates per-engine SQL
Rollback commercial editions only automatic for many change types
Best when one engine, exact SQL control you must support several engines
flyway -url="$DB_URL" -user="$DB_USER" -password="$DB_PASSWORD" migrate

liquibase --changelog-file=db/changelog-master.yaml rollback --tag=v1.4.1
Never edit a migration that has already run

Both tools store a checksum of the file. If you edit V12__add_index.sql after it ran in production, the next run fails with a checksum error — and that is correct: what ran in the database differs from what is in the repo. The right move is a new file; flyway repair is only for genuinely harmless changes.

The expand-contract pattern

«چهار گام تغییر سازگارِ عقب‌رو در schema» — Four phases of a backward-compatible schema change

stateDiagram-v2
  [*] --> Expand: add new column, keep old
  Expand --> DualWrite: app writes both, reads old
  DualWrite --> Migrate: backfill + switch reads
  Migrate --> Contract: drop old column
  Contract --> [*]

Each phase is its own release, and the golden rule is: every version of the application must work with the schema before and after it. Example: renaming email to email_address with no downtime.

Phase 1 — expand: add the new column, nullable and unconstrained.

ALTER TABLE customers ADD COLUMN email_address varchar(320);

Phase 2 — dual write: the new version writes both columns and still reads the old one.

Phase 3 — backfill: move the old data in batches, never in one giant UPDATE.

UPDATE customers SET email_address = email
 WHERE email_address IS NULL
   AND id IN (SELECT id FROM customers WHERE email_address IS NULL
              ORDER BY id LIMIT 10000);

Then build the index without locking the table and tighten the constraint under a lock timeout:

CREATE INDEX CONCURRENTLY idx_customers_email_address ON customers (email_address);
SET lock_timeout = '3s';
ALTER TABLE customers ALTER COLUMN email_address SET NOT NULL;
Put a timeout on locks, and watch the transaction

In PostgreSQL, CREATE INDEX CONCURRENTLY cannot run inside a transaction block; Flyway wraps each migration in a transaction by default, so you must disable it for that migration, and a half-finished run leaves an index in the INVALID state.

More importantly: an ALTER TABLE stuck behind a long transaction builds a lock queue and blocks reads too — always set a timeout before DDL and retry on failure. On a very large PostgreSQL table, instead of a direct SET NOT NULL, add a CHECK ... NOT VALID first and then VALIDATE CONSTRAINT.

Phase 4 — contract: once you are sure no old version is running, drop the old column.

ALTER TABLE customers DROP COLUMN email;

In Oracle, SET UNUSED is instant while DROP UNUSED COLUMNS does the heavy work in a quiet window; in PostgreSQL DROP COLUMN only hides the column but still takes a brief exclusive lock.

Do you run migrations at application startup or in a separate job?

For production, in a separate job before the deployment. Startup migration is great for development but has three problems in production: several replicas starting at once all try to migrate, so startup becomes slow and unpredictable and probes may kill the pod; the application needs a DDL-capable database user, meaning an internet-facing service can drop tables; and failure shows up as "the pod won't start" rather than a readable red stage.

So: a separate job with a DDL user before deployment, and the application running as a DML-only user with spring.flyway.enabled=false. The order is always backward-compatible migration first, code second.

How do you rename a column with no downtime?

With expand-contract across several releases, never in one. First the new nullable column (invisible to the running version); then code that writes both but reads the old one — rollback is still safe here; then a batched, paced backfill so replication and I/O don't choke; then reads switch over with an observation period; then writes to the old column stop; and only at the end, the drop.

What I never do is a single-step ALTER TABLE ... RENAME COLUMN: in that millisecond every running instance starts getting "column does not exist". And because the cost is five releases, seniors ask the follow-up: "is this rename actually worth it?"


9. Deployment strategies

«چهار الگوی استقرار و نحوهٔ جابه‌جایی ترافیک» — Four deployment patterns and how traffic moves

flowchart TD
  subgraph Recreate
    R1[Stop v1] --> R2[Start v2]
  end
  subgraph Rolling
    O1[v1 pods] --> O2[replace one by one] --> O3[v2 pods]
  end
  subgraph BlueGreen
    B1[Blue v1 live] --> B2[Green v2 tested] --> B3[Switch router]
  end
  subgraph Canary
    C1[5% to v2] --> C2[Watch metrics] --> C3[25%] --> C4[100%]
  end
Strategy Downtime Resources Rollback speed Blast radius Good for
Recreate yes medium all users batch jobs, dev, services that can't run two versions
Rolling no ~1.2× medium incremental sane default for stateless services
Blue-green no very fast all users, but briefly when instant rollback is critical
Canary no ~1.1× fast a small % of users high traffic, risky change
Feature flag no instant, no deploy exactly what the flag wraps separating release from deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payments
spec:
  replicas: 6
  revisionHistoryLimit: 10       # how many old revisions stay available for rollback
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 2                # at most 2 pods above target
      maxUnavailable: 0          # never dip below capacity
  minReadySeconds: 15
  template:
    spec:
      containers:
        - name: app
          image: registry.example.com/team/payments@sha256:9f8c...
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
          lifecycle:
            preStop:
              exec: { command: ["sleep", "10"] }   # grace period to leave the load balancer

preStop fixes a problem many teams discover late: when a pod is deleted, its removal from service endpoints and the termination signal happen at the same time, so in-flight requests reach a dying pod and users see 502s; a short pause closes that window. Turn on server.shutdown=graceful too.

For progressive canary, Argo Rollouts gives you a kind: Rollout with steps setWeight: 5pause: { duration: 5m }analysissetWeight: 25 → … → setWeight: 100. The analysis step turns a canary from hope into a data-driven decision: a query against your metrics system, and if it crosses the threshold the rollout aborts automatically. But if your dashboard can't show error rate per version, a canary is just a slower rollout — 5% of bad traffic inside 95% healthy traffic is invisible. The real prerequisite is a version label on every metric (see observability).

Feature flags: release vs deployment

The core idea here: deploy means the code is on the server; release means the user sees it.

if (features.isEnabled("new-settlement-engine", customerId)) {
    return newEngine.settle(request);
}
return legacyEngine.settle(request);

Turning off a broken feature takes seconds instead of a full rollout. The cost: every flag is a new execution branch — 10 flags mean 1024 combinations, none fully tested. Rule: every flag gets an owner and an expiry date on day one.

Blue-green or canary — which do you pick?

It depends on the shape of the risk, not fashion. Blue-green fits when the main risk is "it doesn't work" and you want instant reversal: bring the new version up beside the current one, smoke-test it, switch the router. It costs double resources during the transition, and its catch is the shared database — both sides use the same one, so the schema must still be backward compatible; blue-green does not replace expand-contract. Canary fits when the risk is "it works badly under real load": memory leaks, latency, bad behaviour on real data.

I combine them: rolling for everyday changes, canary for risky paths, feature flags as a kill switch. My constant question is "what is the automated abort criterion?" — and the metric matters: for payments, "transaction success rate" beats HTTP error rate, because a version returning 200 while rejecting transactions looks healthy in HTTP metrics.


10. Rollback plans

Every deployment must answer three questions before it starts: how will we know it broke? how do we get back? how long does that take?

kubectl rollout history deployment/payments -n prod
kubectl rollout undo deployment/payments -n prod --to-revision=7

Notes: rollout undo only reverts the manifest, and with GitOps the controller re-applies the Git state within seconds — in GitOps, rollback means reverting in Git. Don't set revisionHistoryLimit too low or you have nothing to roll back to. And rollback works only if the previous version is still compatible with the world: schema, message format, API contract.

Rolling back code is easy; rolling back data almost never is

If a migration dropped a column, reverting the code doesn't bring the data back. If the new version wrote records in a new shape, the old version can't read them. If a message with a new schema was published, old consumers break on it.

Three rules: migrations only add until you're sure no old version is in circulation; every risky release goes behind a feature flag so "rollback" needs no deployment; and for irreversible changes, rehearse fixing forward — the pipeline must ship a patch in 15 minutes. The right design-review question is "what is our rollback plan?", and if the answer is "we don't have one", that change belongs behind a flag.


11. GitOps

GitOps means the desired state is described in Git and an agent inside the cluster continuously reconciles actual state with it. The difference from classic CI/CD is direction: in the push model the pipeline connects to the cluster and runs kubectl apply (so CI holds production credentials); in the pull model a controller inside the cluster reads Git itself, and CI only builds an image and updates one YAML file.

«مدل pull در GitOps» — The pull model in GitOps

flowchart LR
  CI[CI pipeline] -->|push image| Reg[(Registry)]
  CI -->|commit new digest| Cfg[(Config repo)]
  Ctl[GitOps controller in cluster] -->|watch| Cfg
  Ctl -->|pull image| Reg
  Ctl -->|apply| K8s[Cluster state]
  K8s -->|drift detected| Ctl
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: payments-prod
spec:
  source:
    repoURL: https://git.example.com/team/deploy-config.git
    targetRevision: main
    path: apps/payments/overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: payments
  syncPolicy:
    automated:
      prune: true        # what is deleted from Git is deleted from the cluster
      selfHeal: true     # revert manual changes made in the cluster

Two dominant tools: Argo CD, with a strong UI and ApplicationSet for generating many applications from one template, and Flux, modular controllers with built-in automated image updates (in Argo that is a separate Image Updater).

The two main GitOps traps

1. Secrets in the config repo. Once manifests live in Git you are tempted to put Secrets there too; base64 is not encryption. Use SOPS with a KMS key, Sealed Secrets, or the External Secrets Operator, which pulls the value from Vault at runtime.

2. selfHeal and emergency debugging. With selfHeal on, every kubectl edit is reverted within seconds — exactly the point, but the team needs a sanctioned emergency path (pausing sync), otherwise at 3am nobody understands why their change keeps vanishing.

What does GitOps actually give you that a deploy script doesn't?

Three things. Reduced access surface: in the push model CI holds production credentials, so anyone who can tamper with the pipeline reaches production; in the pull model CI only writes to a registry and a config repo. Convergence instead of one-shot execution: a script runs once and nobody notices later hand-edits, whereas a GitOps controller reports or reverts drift. Free audit and rollback: Git history says who changed what and when.

Its limit is clear: GitOps is excellent for declarative state; procedural work — migrations, seed data, multi-step ordering — still needs a pipeline.


12. Infrastructure as code with Ansible

IaC means describing infrastructure in versioned files instead of clicks and SSH. Two complementary families: provisioning (creating resources — VMs, networks, clusters; Terraform/OpenTofu territory) and configuration management (configuring what was created; Ansible territory). Ansible is agentless (only SSH and Python on the target) and idempotent.

A recipe versus a description of the dish

A shell script is a recipe: "chop the potato" — run it twice and it chops twice. Ansible describes the result: "the potato must be chopped"; if it already is, it reports ok, otherwise it acts and reports changed. That property is idempotency, and all of Ansible's value rests on it.

Hosts are grouped in an inventory file — an [app] group listing app-01.example.com and app-02.example.com — and the playbook targets that group:

- name: Deploy payments service
  hosts: app
  become: true
  serial: 1                    # one at a time — a rolling update on VMs
  vars:
    app_version: "1.4.2"
    app_dir: /opt/payments

  tasks:
    - name: Fetch the artifact from the repository
      ansible.builtin.get_url:
        url: "https://nexus.example.com/repository/releases/payments-{{ app_version }}.jar"
        dest: "{{ app_dir }}/payments-{{ app_version }}.jar"
        checksum: "sha256:{{ app_checksum }}"
        mode: "0640"

    - name: Point the current symlink at the new version
      ansible.builtin.file:
        src: "{{ app_dir }}/payments-{{ app_version }}.jar"
        dest: "{{ app_dir }}/current.jar"
        state: link
      notify: Restart payments

    - name: Wait until the instance reports ready
      ansible.builtin.uri:
        url: "http://127.0.0.1:8080/actuator/health/readiness"
        status_code: 200
      register: health
      retries: 30
      delay: 2
      until: health.status == 200

  handlers:
    - name: Restart payments
      ansible.builtin.systemd_service:
        name: payments
        state: restarted

Three key ideas: serial: 1 updates one machine at a time — a rolling update on VMs; a handler only fires when its task actually changed something; and the until health check before moving on prevents breaking every host at once.

Command Purpose
ansible all -i inventories/production/hosts.ini -m ping test connectivity to every host
ansible-playbook ... --check --diff dry run: what would change
ansible-playbook ... -e app_version=1.4.3 set a variable from the CLI (highest precedence)
ansible-vault encrypt_string 's3cr3t' --name db_password encrypt one value so it can live in Git
ansible-lint site.yml statically check the playbook before running it
What does idempotency mean in Ansible and why does it matter?

Running the same playbook again on the same system produces no additional change; Ansible reads current state before each task and acts only on a difference, which is why each task reports ok or changed. It matters on three levels: re-runnability, drift detection (a daily --check run shows which server was touched by hand), and readable output (in a healthy run the changed count should be zero).

The senior nuance: idempotency is not automatic — the moment you write shell: rm -rf /opt/app && tar xzf ... you've broken it. And --check only means something for modules implementing check mode; command and shell are skipped, so a dry run can hide part of reality. Set creates and changed_when explicitly, and make ansible-lint mandatory in CI.


13. DORA: judging a pipeline by numbers

DORA research established four metrics as the indicators of delivery performance — two about speed, two about stability:

Metric Meaning How you measure it
Deployment Frequency how often you ship to production count of successful production deployments per period
Lead Time for Changes commit to running in production merge time to deployment time for that commit
Change Failure Rate share of deployments that cause trouble deployments leading to rollback/hotfix ÷ total
Failed Deployment Recovery Time how long until healthy again incident start to recovery

Roughly, Elite teams deploy multiple times a day, lead time under a day, change failure rate near 5%, recovery under an hour; Low performers deploy less than monthly, lead time one to six months, failure rate 40%+, recovery in a week to a month (thresholds shift each year).

The key finding is that these four move together: the familiar "either fast or stable" trade-off is false, because a team deploying more often ships smaller changes. Recent reports added complementary indicators — rework rate and reliability — and showed that raising code output without strengthening review and delivery makes stability worse.

Use the metrics to improve the system, not to compare people

The moment "number of deploys" is tied to someone's performance review the number becomes meaningless. The right use: plot lead time and see where the time goes — waiting for review? runner queue? Usually the largest share is waiting time, not execution time. And always report all four together; raising speed without watching failure rate is breaking things faster.

The team's pipeline takes 50 minutes. What do you do?

Measure first, guess second: break down time per stage across recent runs and go after the biggest share. Common patterns: sequential integration tests; no needs/DAG; a broken or badly keyed cache; image builds without layer caching; heavy scans in the critical path of every commit.

Then I split the pipeline in two: a fast lane on every push (compile, unit tests, lint) under 10 minutes, because that's the number a developer waits for, and a full lane on merge or on a schedule (end-to-end, deep scans, load tests). Two other things pay off: bigger runners for CPU-bound stages, and killing flaky tests. Finally I make pipeline duration an SLO.

A change broke production. Walk me through what you do.

Recover first, diagnose second — the initial goal is a healthy service, not root cause. Step one: confirm the link to the deployment by correlating incident start with the rollout and comparing metrics per version. Step two: stop the progression — pause the rollout if it's a canary, and switch off the feature flag if there is one. Step three: roll back, unless a migration forbids it, in which case fix forward. Step four: communicate on one channel with one coordinator; the absence of that stretches incidents more than the bug does.

Once healthy, a blameless post-mortem: the right question is "why did the pipeline let this reach production?", and the output must be a concrete change to the system. The metric I always check is time to rollback; over 15 minutes, and that is the team's next most important work.


14. Pipeline anti-patterns

Anti-pattern Why it breaks Do instead
A separate build per environment what you tested isn't what runs build once, promote the same artifact
Flaky tests papered over with retries trust dies and real bugs hide stabilise the test or delete it
Pipelines copy-pasted into every repo one fix has to be repeated 40 times templates / reusable workflows / shared libraries
Secrets in plain, long-lived variables leak via one sloppy log and never rotated OIDC, dynamic secrets, protected variables
Migrations at application startup in production slow startup, needless DDL rights, opaque failures a separate job before deployment
A long-lived branch with a green pipeline you don't have CI, you have a build server short-lived branches + feature flags
Wrap-up

CI is the habit of frequent integration, not a tool you own. Continuous delivery means every green build is production-ready; continuous deployment removes the human button too — safe only with good tests, progressive delivery and automated rollback. Order the pipeline by fail fast; in Java always run mvn verify, never package.

The central rule: build once, promote the same artifact. Deploy by digest rather than a mutable tag, pin actions by commit hash, and obtain secrets through OIDC and short-lived tokens instead of static keys.

The database is the real bottleneck of zero-downtime deployment: expand-contract with additive-only migrations, batched backfills, DDL under a lock timeout, run in a separate job rather than at startup. Pick the deployment strategy from the shape of the risk — rolling for everyday changes, blue-green for instant reversal, canary for risk that only shows under real load, feature flags as a kill switch — and before every deployment know how, and in how many minutes, you get back.

GitOps removes production access from CI and converges drift; Ansible makes server configuration idempotent and, with serial, gives you the same rolling update on VMs. And judge all of it with DORA: speed and stability rise together, not at each other's expense.