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 خودکار آنقدر بالغ باشند که آدم لازم نباشد.
اگر برای رفتن به production باید فرم تغییر پر کنی، منتظر جلسهٔ هفتگی بمانی و یک نفر دستی روی سرور اسکریپت بزند، تو CD نداری — یک deploy script داری. معیار ساده: آیا آخرین commit امروز میتواند تا شش بعدازظهر روی production باشد؟ اگر نه، جایی یک صف انسانی هست که باید حذف شود.
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 یک «شاید موجود باشد» برای سرعت است؛ اگر نبود build باید همچنان کار کند (فقط کندتر). artifact یک «حتماً باید منتقل شود» بین مرحلههاست: jar نهایی، گزارش تست، SBOM. قاتیشان نکن: jar را cache نکن (build بعدی ممکن است jar کهنه را بردارد) و ~/.m2 را artifact نکن.
اگر کلید را 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 هنوز اجرا میشود تا منابع بسته شوند.
چرخهٔ حیات 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.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).
اگر روی یک 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 است، بدون آن یک هفته کار دستی.
کلید 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 میشود.
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"'
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 پرکاربرد (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() } }
}
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 دقیقاً کجا درد دارد. اگر مشکل «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 یک بار ساخته میشود و همان بایتها جابهجا میشوند؛ چیزی که عوض میشود پیکربندی و مقصد است، نه محتوا. در عمل 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
۱. set -x یا echo — در حالت trace هر دستور با مقدار بازشدهٔ متغیرها چاپ میشود؛ ماسککردنِ CI فقط تطبیق رشتهٔ دقیق است، پس اگر راز را base64 کنی ماسک کار نمیکند.
۲. artifact و گزارش — یک فایل .env که تصادفاً در artifacts:paths افتاده برای هر کسی که به pipeline دسترسی دارد قابل دانلود است.
۳. MR از fork — در مخزن عمومی، اگر متغیر protected نباشد کد غریبه به رازها میرسد.
۴. لایههای image — ARG SECRET در RUN در تاریخچهٔ لایه میماند؛ از RUN --mount=type=secret استفاده کن.
۵. خط فرمان — -Dsonar.token=$TOKEN در جدول process دیده میشود؛ راز را از متغیر محیطیِ ابزار بخوان.
و مستقل از همه: چرخش را از قبل تمرین کن. اگر عوضکردن یک راز 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
هر دو ابزار 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);ALTER TABLE customers ADD (email_address VARCHAR2(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);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 FETCH FIRST 10000 ROWS ONLY);بعد 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;CREATE INDEX idx_customers_email_address ON customers (email_address) ONLINE;
ALTER SESSION SET ddl_lock_timeout = 3;
ALTER TABLE customers MODIFY (email_address 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;ALTER TABLE customers SET UNUSED (email);
ALTER TABLE customers DROP UNUSED COLUMNS;در Oracle، SET UNUSED فوری و بیهزینه است و DROP UNUSED COLUMNS کار سنگین را در پنجرهٔ کمترافیک انجام میدهد؛ در PostgreSQL خودِ DROP COLUMN فقط ستون را نامرئی میکند ولی همچنان یک قفل انحصاری کوتاه میگیرد.
برای production، در یک job مجزا و قبل از استقرار. اجرای خودکار در startup برای توسعه عالی است اما در production سه مشکل دارد: وقتی چند replica همزمان بالا میآیند همه سعی میکنند migrate کنند و startup کند و غیرقابلپیشبینی میشود (probeها ممکن است pod را بکشند)؛ برنامه به کاربر دیتابیس با دسترسی DDL نیاز پیدا میکند، یعنی سرویسی که به اینترنت وصل است میتواند جدول drop کند؛ و شکست migration به شکل «pod بالا نمیآید» ظاهر میشود، نه یک مرحلهٔ قرمز و خوانا در pipeline.
پس: job جداگانه با کاربر دارای DDL قبل از استقرار؛ برنامه با کاربری که فقط DML دارد و spring.flyway.enabled=false. ترتیب همیشه ثابت است: migration سازگارِ عقبرو اول، کد بعد.
با 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: 5 → pause: { duration: 5m } → analysis → setWeight: 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 وقتی درست است که ریسک اصلی «کار نکردن» باشد و برگشت فوری بخواهی: کل نسخهٔ جدید را کنار فعلی بالا میآوری، 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.
اگر 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).
۱. راز در مخزن config. لحظهای که manifestها را در Git میگذاری وسوسه میشوی Secret را هم بگذاری؛ base64 رمزنگاری نیست. راهحل: SOPS با کلید KMS، Sealed Secrets، یا External Secrets Operator که راز را در زمان اجرا از Vault میکشد.
۲. selfHeal و اشکالزدایی اضطراری. با selfHeal روشن، هر kubectl edit ظرف چند ثانیه پس زده میشود — این دقیقاً هدف است، اما تیم باید مسیر رسمی اضطراری داشته باشد (توقف موقت sync) وگرنه نیمهشبِ یک حادثه کسی نمیفهمد چرا تغییرش ناپدید میشود.
سه چیز. کاهش سطح دسترسی: در مدل 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 قبل از اجرا |
یعنی اجرای دوبارهٔ همان 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؟ معمولاً بزرگترین بخش زمان انتظار است، نه زمان اجرا. و همیشه هر چهار عدد را با هم گزارش کن؛ بالا بردن سرعت بدون نگاه به نرخ خرابی فقط سریعتر خرابکردن است.
اول اندازه میگیرم، بعد حدس میزنم: تفکیک زمان هر مرحله در چند اجرای اخیر، بعد سراغ بزرگترین سهم. الگوهای رایج: تستهای integration ترتیبی؛ نبود needs/DAG؛ cache خراب یا کلید نامناسب؛ ساخت image بدون cache لایه؛ و اسکنهای سنگین در مسیر بحرانی هر commit.
بعد pipeline را دولایه میکنم: لایهٔ سریع روی هر push (کامپایل، unit test، lint) با هدف زیر ۱۰ دقیقه چون این عددی است که توسعهدهنده منتظرش میماند، و لایهٔ کامل روی merge یا زمانبندیشده (end-to-end، اسکن عمیق، تست بار). دو کار دیگر بازدهی بالایی دارند: runnerهای بزرگتر برای مرحلههای محدود به CPU، و کشتن تستهای flaky — تستی که ۵٪ مواقع شکست میخورد اعتماد به pipeline را از بین میبرد. در آخر زمان pipeline را به یک SLO تبدیل میکنم.
اول بازیابی، بعد تشخیص؛ هدف اولیه سالمکردن سرویس است، نه فهمیدن علت. قدم یک: تأیید ارتباط با استقرار — تطبیق زمان شروع خرابی با 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.
- Concepts: CI, and continuous delivery vs continuous deployment.
- Pipeline anatomy: the stages of a real Java pipeline and why they are ordered that way.
- Implementation: the same project in GitLab CI, GitHub Actions and Jenkins.
- Reproducibility and versioning: cache vs artifact, reproducible builds, Nexus, SemVer, release automation.
- Secrets in CI: variables, OIDC, Vault, and the mistakes that leak them.
- Database: Flyway/Liquibase and expand-contract.
- Deployment: recreate, rolling, blue-green, canary, feature flags, rollback plans.
- GitOps and IaC: Argo CD/Flux and practical Ansible basics.
- Judgement: DORA metrics and pipeline anti-patterns.
1. The problem CI solves
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.
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.
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
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.
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.
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
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).
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.
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.
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"'
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 }}"
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() } }
}
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 |
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.
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
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 layers — ARG 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.
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
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
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);ALTER TABLE customers ADD (email_address VARCHAR2(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);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 FETCH FIRST 10000 ROWS ONLY);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;CREATE INDEX idx_customers_email_address ON customers (email_address) ONLINE;
ALTER SESSION SET ddl_lock_timeout = 3;
ALTER TABLE customers MODIFY (email_address NOT NULL);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;ALTER TABLE customers SET UNUSED (email);
ALTER TABLE customers DROP UNUSED COLUMNS;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.
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.
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 | 1× | 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 | 2× | 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 | 1× | 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: 5 → pause: { duration: 5m } → analysis → setWeight: 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.
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.
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).
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.
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 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 |
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.
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.
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.
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 |
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.