CI/CD سطح: مقدماتی

راهنمای کامل GitLab CI/CD: از اولین Job تا Pipeline چندمحیطی

همه‌چیز درباره‌ی GitLab CI/CD برای تیم‌های ایرانی: Runner، Stage، Cache، Artifact، استقرار روی Develop/Staging/Production و قالب‌های مشترک برای ده‌ها میکروسرویس.

۶ دقیقه مطالعه

اگر در ایران روی یک تیم نرم‌افزاری کار می‌کنید، احتمالاً 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 کردن مخزن قالب)، می‌توانید تغییرات را اول روی یک سرویس تست کنید و بعد بقیه را به‌روز کنید.

اشتباهات رایجی که زیاد دیده‌ام

  1. ذخیره‌ی رمز داخل .gitlab-ci.yml: همیشه از CI/CD Variables با گزینه‌های Masked و Protected استفاده کنید و Gitleaks را در Pipeline بگذارید.
  2. استفاده از Tag latest: Deploy باید دقیقاً بداند کدام نسخه را اجرا می‌کند. از SHA کامیت یا Semantic Version استفاده کنید.
  3. یک Runner برای همه‌چیز: Runnerهای Build را از Runnerهای Deploy جدا کنید و Runner محیط Production را با Tag مخصوص فقط به Jobهای Production بدهید.
  4. Pipelineهای ۳۰ دقیقه‌ای: با needs، Cache و اجرای موازی تست‌ها (parallel) معمولاً می‌شود زمان را به زیر ۱۰ دقیقه رساند.

جمع‌بندی

GitLab CI/CD در ظاهر فقط یک فایل YAML است، اما با طراحی درست به ستون فقرات فرایند تحویل نرم‌افزار تبدیل می‌شود. از یک Pipeline ساده شروع کنید، بعد محیط‌ها را اضافه کنید و در نهایت قالب مشترک بسازید. در مقاله‌ی بعدی سراغ GitOps با ArgoCD می‌رویم؛ جایی که مرحله‌ی Deploy را هم از Pipeline بیرون می‌آوریم.