اگر در ایران روی یک تیم نرمافزاری کار میکنید، احتمالاً GitLab را روی سرورهای خودتان دارید. دلیلش هم روشن است: GitHub و بسیاری از سرویسهای CI ابری یا برای کاربران ایرانی محدود شدهاند یا هر لحظه ممکن است محدود شوند. خبر خوب این است که GitLab CI/CD یکی از کاملترین ابزارهای CI/CD دنیاست و همراه خود GitLab، بدون هیچ هزینهی اضافهای در اختیارتان است.
در این راهنما قدمبهقدم یک Pipeline واقعی میسازیم؛ همان الگویی که برای بیش از ۱۰ میکروسرویس بانکی استفاده کردهام و زمان Deployment را حدود ۷۰٪ کاهش داد.
مفاهیم پایه در یک نگاه
| مفهوم | توضیح |
|---|---|
| Pipeline | مجموعهی کامل کارهایی که با هر Push یا Merge Request اجرا میشود |
| Stage | مرحلهای از Pipeline، مثل build، test یا deploy |
| Job | یک کار مشخص داخل یک Stage که روی Runner اجرا میشود |
| Runner | سروری که Jobها را اجرا میکند؛ معمولاً با Docker executor |
| Artifact | خروجی یک Job که به Jobهای بعدی یا کاربر تحویل داده میشود |
| Cache | فایلهایی مثل node_modules که بین اجراها نگه داشته میشوند |
قدم اول: راهاندازی Runner
Runner را روی یک سرور جدا از خود GitLab نصب کنید تا Build سنگین، GitLab را کند نکند. سادهترین روش اجرای Runner بهصورت کانتینر است:
docker run -d --name gitlab-runner --restart always \
-v /srv/gitlab-runner/config:/etc/gitlab-runner \
-v /var/run/docker.sock:/var/run/docker.sock \
gitlab/gitlab-runner:latest
docker exec -it gitlab-runner gitlab-runner register \
--url https://gitlab.example.ir \
--token "$RUNNER_TOKEN" \
--executor docker \
--docker-image alpine:3.20
قدم دوم: اولین Pipeline واقعی
فایل .gitlab-ci.yml را در ریشهی مخزن بسازید. این نمونه برای یک سرویس .NET نوشته شده، ولی ساختارش برای هر زبانی یکسان است:
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- if: $CI_COMMIT_TAG
stages: [build, test, package, deploy]
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
NUGET_PACKAGES: $CI_PROJECT_DIR/.nuget
default:
interruptible: true # cancel outdated pipelines on new pushes
retry:
max: 1
when: [runner_system_failure, stuck_or_timeout_failure]
build:
stage: build
image: mcr.microsoft.com/dotnet/sdk:10.0
cache:
key:
files: ["**/*.csproj"]
paths: [.nuget/]
script:
- dotnet restore
- dotnet build -c Release --no-restore
artifacts:
paths: ["**/bin/Release/"]
expire_in: 1 day
unit-test:
stage: test
image: mcr.microsoft.com/dotnet/sdk:10.0
needs: [build]
script:
- dotnet test -c Release --logger "junit;LogFilePath=report.xml"
artifacts:
when: always
reports:
junit: "**/report.xml"
docker-image:
stage: package
image: docker:27
services: [docker:27-dind]
needs: [unit-test]
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build --pull -t "$IMAGE" .
- docker push "$IMAGE"
چند نکته در همین فایل کوچک، تفاوت زیادی ایجاد میکند:
workflow:rulesجلوی Pipelineهای تکراری را میگیرد؛ مثلاً اجرای همزمان Pipeline برای Branch و Merge Request.interruptible: trueباعث میشود با Push جدید، Pipelineهای قدیمی همان Branch لغو شوند و Runner آزاد بماند.cache:key:filesکش را فقط وقتی باطل میکند که فایلهای پروژه (.csproj) عوض شوند.needsگرافی از وابستگیها (DAG) میسازد. Job بلافاصله بعد از وابستگیاش اجرا میشود و منتظر تمام شدن کل Stage قبلی نمیماند.- گزارش JUnit نتیجهی تستها را مستقیم داخل Merge Request نشان میدهد.
قدم سوم: استقرار روی Develop، Staging و Production
در پروژههای بانکی معمولاً سه محیط داریم. قانون طلایی این است: Image را یکبار بسازید و همان را بین محیطها جابهجا کنید. اگر برای هر محیط دوباره Build کنید، چیزی که در Staging تست شده با چیزی که به Production میرود یکی نیست.
.deploy:
stage: deploy
image: alpine/helm:3.16.2
script:
- helm upgrade --install api ./chart
-f chart/values-$ENV.yaml
--set image.tag=$CI_COMMIT_SHORT_SHA
--namespace $ENV --atomic --wait --timeout 5m
deploy-dev:
extends: .deploy
variables: { ENV: dev }
environment: { name: dev, url: https://dev.example.ir }
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
deploy-staging:
extends: .deploy
variables: { ENV: staging }
environment: { name: staging, url: https://staging.example.ir }
needs: [deploy-dev]
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manual
deploy-production:
extends: .deploy
variables: { ENV: production }
environment: { name: production, url: https://api.example.ir }
needs: [deploy-staging]
rules:
- if: $CI_COMMIT_TAG
when: manual
--atomic در Helm یعنی اگر استقرار موفق نشد (مثلاً Podها Ready نشدند)، نسخهی قبلی بهصورت خودکار برمیگردد. یک خط ساده که بارها جلوی Downtime را گرفته است.
قدم چهارم: یک قالب برای دهها میکروسرویس
وقتی ۱۰ یا ۲۰ میکروسرویس دارید، کپی کردن .gitlab-ci.yml در هر مخزن یعنی هر تغییر کوچک را باید ۲۰ بار تکرار کنید. راه درست، یک مخزن مرکزی برای قالبهاست:
# devops/ci-templates → templates/dotnet-service.yml
.dotnet-build:
image: mcr.microsoft.com/dotnet/sdk:10.0
script:
- dotnet restore
- dotnet build -c Release --no-restore
و در هر سرویس فقط:
include:
- project: devops/ci-templates
ref: v1.4.0 # pin a version, never "main"
file: templates/dotnet-service.yml
build:
extends: .dotnet-build
با نسخهگذاری قالبها (Tag کردن مخزن قالب)، میتوانید تغییرات را اول روی یک سرویس تست کنید و بعد بقیه را بهروز کنید.
اشتباهات رایجی که زیاد دیدهام
- ذخیرهی رمز داخل
.gitlab-ci.yml: همیشه از CI/CD Variables با گزینههای Masked و Protected استفاده کنید و Gitleaks را در Pipeline بگذارید. - استفاده از Tag
latest: Deploy باید دقیقاً بداند کدام نسخه را اجرا میکند. از SHA کامیت یا Semantic Version استفاده کنید. - یک Runner برای همهچیز: Runnerهای Build را از Runnerهای Deploy جدا کنید و Runner محیط Production را با Tag مخصوص فقط به Jobهای Production بدهید.
- Pipelineهای ۳۰ دقیقهای: با
needs، Cache و اجرای موازی تستها (parallel) معمولاً میشود زمان را به زیر ۱۰ دقیقه رساند.
جمعبندی
GitLab CI/CD در ظاهر فقط یک فایل YAML است، اما با طراحی درست به ستون فقرات فرایند تحویل نرمافزار تبدیل میشود. از یک Pipeline ساده شروع کنید، بعد محیطها را اضافه کنید و در نهایت قالب مشترک بسازید. در مقالهی بعدی سراغ GitOps با ArgoCD میرویم؛ جایی که مرحلهی Deploy را هم از Pipeline بیرون میآوریم.