Levelwise
فارسی
زیرساخت و DevOps

CI/CD و استقرار بی‌خطر

هر تغییر کد خودکار ساخته و تست می‌شود و یک بسته ثابت می‌سازد. همان بسته قدم به قدم به محیط‌ها می‌رود. با روش‌های استقرار امن و تغییر مرحله‌ای دیتابیس، deploy یک کار روزمره و بی‌استرس می‌شود.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۵ دقیقهمثال سرویس سفارش فروشگاه اینترنتیکد GitHub Actions و EF Core

نویسنده: bezzad

مشکل: deploy شب جمعه

تیم فروشگاه ما ماهی یک بار deploy می‌کند. هر بار یک نفر روی لپ‌تاپ خودش برنامه را می‌سازد، فایل‌ها را روی سرور کپی می‌کند و دستی migration دیتابیس را اجرا می‌کند. نتیجه:

  1. تغییرها خیلی زیاد است. یک ماه کار در یک deploy است. اگر چیزی خراب شود، پیدا کردن علت سخت است.
  2. هر بار کمی فرق دارد. یک قدم فراموش می‌شود یا روی لپ‌تاپ نسخه دیگری از SDK نصب است.
  3. همه می‌ترسند. پس deploy را عقب می‌اندازند و تغییرها باز هم بیشتر می‌شوند.

راه حل این است که deploy کوچک، خودکار و تکراری شود.

ایده: یک خط تولید خودکار (Pipeline)

هر بار که کسی کد را push می‌کند، یک Pipeline خودکار اجرا می‌شود.

  • بخش CI (یکپارچه‌سازی پیوسته). کد ساخته می‌شود و همه تست‌ها اجرا می‌شوند. اگر چیزی خراب باشد، در چند دقیقه می‌فهمیم، نه یک ماه بعد.
  • بخش CD (تحویل پیوسته). بعد از تست، یک بسته (Image) ساخته می‌شود و قدم به قدم به محیط‌ها می‌رود.
یکپارچه‌سازی پیوستهCIتحویل پیوستهCDساختbuildتستtestبسته‌بندیdocker pushمحیط تستstagingتأییددستی یا خودکارمشتری‌هاproductionیک تست شکست خورد؟ همین‌جا توقفهر قدم فقط با موفقیت قدم قبلی اجرا می‌شود
هر قدم فقط وقتی اجرا می‌شود که قدم قبلی موفق باشد.

دو اسم شبیه هم داریم که فرق دارند:

  1. تحویل پیوسته (Continuous Delivery). هر نسخه آماده رفتن به Production است. ولی یک آدم دکمه نهایی را می‌زند.
  2. استقرار پیوسته (Continuous Deployment). هیچ دکمه‌ای نیست. هر تغییری که همه تست‌ها را رد کند، خودکار به Production می‌رود.

برای قدم دوم به تست‌های خیلی خوب و مانیتورینگ قوی نیاز داریم. بیشتر تیم‌ها از قدم اول شروع می‌کنند.

یک بار بساز، همه جا استفاده کن

یک قانون مهم: بسته را فقط یک بار بساز. همان بسته به محیط تست، بعد به محیط اصلی برود.

یک بار ساختorders-api:3f9c2a1محیط تستorders-api:3f9c2a1محیط پیش از انتشارorders-api:3f9c2a1محیط اصلیorders-api:3f9c2a1تنظیمات محیط تستتنظیمات پیش از انتشارتنظیمات اصلی و رمزها
همان Image به همه محیط‌ها می‌رود. فقط تنظیمات فرق دارد.

چرا؟

  1. اگر برای هر محیط دوباره بسازی، ممکن است یک پکیج در این فاصله نسخه جدید گرفته باشد.
  2. پس چیزی که به Production می‌رسد، دقیقاً همان چیزی نیست که تست کرده‌ای.
  3. با یک بسته ثابت و برچسب commit، دقیقاً می‌دانی چه چیزی اجرا می‌شود. برگشت به نسخه قبل هم فقط یعنی اجرای Image قبلی.

پس تنظیمات (آدرس دیتابیس، رمزها) داخل بسته نیست. هر محیط تنظیمات خودش را از بیرون می‌دهد.

روش‌های استقرار امن

وقتی نسخه جدید آماده است، چطور آن را جای نسخه قدیم بگذاریم که کاربر چیزی حس نکند؟

RollingBlue-GreenCanaryv2v2v1v1یکی‌یکی عوض می‌شونددو نسخه با هم کار می‌کنندBlue: v1آماده برای برگشتGreen: v2همه ترافیک اینجاترافیک یکجا جابجا می‌شوددو برابر سرور لازم استv195%v2 5%اول درصد کمی از کاربرهاخطا فقط به عده کمی می‌رسد
سه روش رایج. هر کدام هزینه و ریسک متفاوتی دارد.
  • روش Rolling. سرورها یا Pod ها یکی‌یکی عوض می‌شوند. ساده است و سرور اضافه زیادی نمی‌خواهد. ولی چند دقیقه دو نسخه با هم کار می‌کنند. این روش پیش‌فرض Kubernetes است.
  • روش Blue-Green. نسخه جدید کامل کنار نسخه قدیم بالا می‌آید. بعد ترافیک یکجا به نسخه جدید می‌رود. برگشت خیلی سریع است، چون نسخه قدیم هنوز روشن است. ولی برای مدتی دو برابر منابع لازم است.
  • روش Canary. اول درصد کمی از کاربرها نسخه جدید را می‌بینند. اگر خطا و زمان پاسخ خوب بود، درصد کم‌کم بالا می‌رود. اگر مشکلی بود، فقط عده کمی آن را دیده‌اند.
جدا کردن deploy از انتشار: با Feature Flag، کد جدید deploy می‌شود ولی هنوز خاموش است. بعداً بدون deploy دوباره، آن را برای بعضی کاربرها روشن می‌کنی. اگر مشکلی بود، فقط آن را خاموش می‌کنی.

دیتابیس: سخت‌ترین بخش deploy

در روش Rolling و Canary، نسخه قدیم و جدید کد همزمان با یک دیتابیس کار می‌کنند. پس دیتابیس در هر لحظه باید با هر دو نسخه سازگار باشد.

مثال: جدول مشتری‌ها یک ستون «نام کامل» دارد. می‌خواهیم آن را به دو ستون «نام» و «نام خانوادگی» تبدیل کنیم. اگر یک migration ستون قدیمی را همان اول حذف کند:

  1. هنوز چند Pod کد قدیم را دارند و ستون قدیمی را می‌خوانند.
  2. دیتابیس خطای «ستون وجود ندارد» می‌دهد و کاربر خطای 500 می‌بیند.
  3. اگر نسخه جدید باگ داشته باشد، برگشت هم ممکن نیست. کد قدیم به ستونی نیاز دارد که دیگر وجود ندارد.

راه حل الگوی گسترش و جمع کردن (Expand and Contract) است. تغییر بزرگ را در چند deploy کوچک انجام می‌دهیم:

۱. گسترشستون‌های جدیدبا امکان خالی بودن+ FirstName, LastName۲. نوشتن در هر دوکد جدید هر دو را پر می‌کندداده قدیمی کم‌کم پر می‌شود۳. خواندن از جدیدکد فقط ستون جدید رامی‌خواند و می‌نویسد۴. جمع کردنچند روز بعد،ستون قدیمی حذف می‌شود- FullNameدر این سه قدم، برگشت به نسخه قبلی کد همیشه ممکن استاز اینجا برگشت نیست
در هر قدم، نسخه قبلی کد هنوز با دیتابیس کار می‌کند. حذف ستون قدیمی آخرین قدم است.
  1. گسترش. ستون‌های جدید را اضافه کن. با امکان خالی بودن، تا کد قدیم خراب نشود.
  2. نوشتن در هر دو. کد جدید هم ستون قدیمی و هم ستون‌های جدید را پر می‌کند. رکوردهای قدیمی را در دسته‌های کوچک پر کن، نه با یک دستور بزرگ که جدول را طولانی قفل کند.
  3. خواندن از جدید. وقتی همه داده پر شد، کد فقط از ستون‌های جدید می‌خواند.
  4. جمع کردن. چند روز بعد، وقتی مطمئن شدی به نسخه قدیم برنمی‌گردی، ستون قدیمی را حذف کن.

کد

فایل Pipeline با GitHub Actions

name: orders-api

on:
  push:
    branches: [main]
  pull_request:

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release

  publish-image:
    needs: build-test               # only after all tests pass
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    env:
      IMAGE: registry.example.com/shop/orders-api:${{ github.sha }}
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_PASSWORD }}
      - run: docker build -t "$IMAGE" .
      - run: docker push "$IMAGE"   # tagged with the commit, built once

چند نکته:

  • برای هر Pull Request فقط ساخت و تست اجرا می‌شود. ساخت Image فقط برای شاخه اصلی است.
  • رمزها از بخش امن secrets می‌آیند، نه از خود فایل.
  • برچسب Image شناسه commit است. پس همیشه می‌دانی کدام کد در کدام محیط است.

قدم اول Expand در EF Core

public partial class AddFirstAndLastName : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.AddColumn<string>(
            name: "FirstName", table: "Customers", nullable: true);
        migrationBuilder.AddColumn<string>(
            name: "LastName", table: "Customers", nullable: true);

        // No DropColumn("FullName") here.
        // It is removed in a later release, after all code stops using it.
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "FirstName", table: "Customers");
        migrationBuilder.DropColumn(name: "LastName", table: "Customers");
    }
}

اجرای migration جدا از برنامه

اگر هر Pod هنگام شروع، migration را اجرا کند، چند Pod ممکن است همزمان این کار را بکنند. بهتر است migration یک قدم جدا در Pipeline باشد. ابزار EF Core می‌تواند همه migration ها را در یک فایل اجرایی (bundle) بسازد:

# In the pipeline: build the bundle once
dotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle

# Before the new code is deployed: run it once
./efbundle --connection "$ORDERS_DB_CONNECTION"

قانون‌های مهم

  1. تغییرهای کوچک و زیاد. هر deploy کوچک‌تر، ریسک کمتر و پیدا کردن مشکل آسان‌تر.
  2. قدم سریع اول. ساخت و تست واحد باید چند دقیقه طول بکشد. اگر Pipeline یک ساعت طول بکشد، برنامه‌نویس‌ها آن را دور می‌زنند.
  3. قدم شکست‌خورده یعنی توقف. تست قرمز را نادیده نگیر و تست ناپایدار (flaky) را درست کن، نه اینکه دوباره اجرا کنی تا سبز شود.
  4. یک بار ساخت. همان بسته برای همه محیط‌ها.
  5. رمز فقط در secrets. هیچ رمزی در کد، فایل Pipeline یا Image نباشد.
  6. هر migration با کد قبلی سازگار باشد. حذف و تغییر اسم ستون فقط در قدم آخر Expand and Contract.
  7. بعد از deploy نگاه کن. نرخ خطا و زمان پاسخ را بعد از هر deploy چک کن. اگر بد شد، سریع برگرد.

اشتباه‌های رایج

اشتباه نتیجه راه درست
ساخت دوباره برای هر محیط چیزی که تست شد با چیزی که منتشر شد فرق دارد. یک بار ساخت و برچسب commit.
حذف یا تغییر اسم ستون در یک migration وسط deploy خطای 500 و برگشت ناممکن. گسترش و جمع کردن در چند deploy.
اجرای migration در شروع هر Pod چند Pod همزمان migration را اجرا می‌کنند. قدم جدا در Pipeline، مثلاً با bundle.
رمز در فایل Pipeline هر کسی که به مخزن کد دسترسی دارد، رمز را می‌بیند. بخش secrets ابزار CI.
دوباره اجرا کردن تست ناپایدار تا سبز شود کسی دیگر به تست‌ها اعتماد نمی‌کند. پیدا کردن علت و درست کردن تست.
نداشتن برنامه برگشت در زمان مشکل، تیم نمی‌داند چه کند. برگشت به Image قبلی در یک قدم.

کدام روش استقرار؟

ساده و کافی

  • روش Rolling برای بیشتر سرویس‌ها کافی است، به شرطی که تغییر دیتابیس با هر دو نسخه سازگار باشد.
  • روش Canary برای سرویس‌های حساس مثل پرداخت، که خطا گران است.

مراقب باش

  • روش Blue-Green برای سیستمی که منابع کافی برای دو نسخه کامل ندارد.
  • استقرار پیوسته بدون تست‌های قوی و مانیتورینگ. خطا مستقیم به مشتری می‌رسد.

خلاصه در شش خط

  1. در CI هر تغییر خودکار ساخته و تست می‌شود. در CD همان نتیجه قدم به قدم به محیط‌ها می‌رود.
  2. بسته را یک بار بساز و با شناسه commit برچسب بزن. تنظیمات از بیرون بیاید.
  3. روش Rolling ساده است، Blue-Green برگشت سریع دارد، Canary ریسک را کوچک می‌کند.
  4. با Feature Flag، deploy از انتشار جدا می‌شود.
  5. دیتابیس باید با نسخه قدیم و جدید کد سازگار باشد. از گسترش و جمع کردن استفاده کن.
  6. تغییر کوچک، Pipeline سریع، رمز در secrets و نگاه به مانیتورینگ بعد از هر deploy.