CI/CD و استقرار بیخطر
هر تغییر کد خودکار ساخته و تست میشود و یک بسته ثابت میسازد. همان بسته قدم به قدم به محیطها میرود. با روشهای استقرار امن و تغییر مرحلهای دیتابیس، deploy یک کار روزمره و بیاسترس میشود.
نویسنده: bezzad
مشکل: deploy شب جمعه
تیم فروشگاه ما ماهی یک بار deploy میکند. هر بار یک نفر روی لپتاپ خودش برنامه را میسازد، فایلها را روی سرور کپی میکند و دستی migration دیتابیس را اجرا میکند. نتیجه:
- تغییرها خیلی زیاد است. یک ماه کار در یک deploy است. اگر چیزی خراب شود، پیدا کردن علت سخت است.
- هر بار کمی فرق دارد. یک قدم فراموش میشود یا روی لپتاپ نسخه دیگری از SDK نصب است.
- همه میترسند. پس deploy را عقب میاندازند و تغییرها باز هم بیشتر میشوند.
راه حل این است که deploy کوچک، خودکار و تکراری شود.
ایده: یک خط تولید خودکار (Pipeline)
هر بار که کسی کد را push میکند، یک Pipeline خودکار اجرا میشود.
- بخش CI (یکپارچهسازی پیوسته). کد ساخته میشود و همه تستها اجرا میشوند. اگر چیزی خراب باشد، در چند دقیقه میفهمیم، نه یک ماه بعد.
- بخش CD (تحویل پیوسته). بعد از تست، یک بسته (Image) ساخته میشود و قدم به قدم به محیطها میرود.
دو اسم شبیه هم داریم که فرق دارند:
- تحویل پیوسته (Continuous Delivery). هر نسخه آماده رفتن به Production است. ولی یک آدم دکمه نهایی را میزند.
- استقرار پیوسته (Continuous Deployment). هیچ دکمهای نیست. هر تغییری که همه تستها را رد کند، خودکار به Production میرود.
برای قدم دوم به تستهای خیلی خوب و مانیتورینگ قوی نیاز داریم. بیشتر تیمها از قدم اول شروع میکنند.
یک بار بساز، همه جا استفاده کن
یک قانون مهم: بسته را فقط یک بار بساز. همان بسته به محیط تست، بعد به محیط اصلی برود.
چرا؟
- اگر برای هر محیط دوباره بسازی، ممکن است یک پکیج در این فاصله نسخه جدید گرفته باشد.
- پس چیزی که به Production میرسد، دقیقاً همان چیزی نیست که تست کردهای.
- با یک بسته ثابت و برچسب commit، دقیقاً میدانی چه چیزی اجرا میشود. برگشت به نسخه قبل هم فقط یعنی اجرای Image قبلی.
پس تنظیمات (آدرس دیتابیس، رمزها) داخل بسته نیست. هر محیط تنظیمات خودش را از بیرون میدهد.
روشهای استقرار امن
وقتی نسخه جدید آماده است، چطور آن را جای نسخه قدیم بگذاریم که کاربر چیزی حس نکند؟
- روش Rolling. سرورها یا Pod ها یکییکی عوض میشوند. ساده است و سرور اضافه زیادی نمیخواهد. ولی چند دقیقه دو نسخه با هم کار میکنند. این روش پیشفرض Kubernetes است.
- روش Blue-Green. نسخه جدید کامل کنار نسخه قدیم بالا میآید. بعد ترافیک یکجا به نسخه جدید میرود. برگشت خیلی سریع است، چون نسخه قدیم هنوز روشن است. ولی برای مدتی دو برابر منابع لازم است.
- روش Canary. اول درصد کمی از کاربرها نسخه جدید را میبینند. اگر خطا و زمان پاسخ خوب بود، درصد کمکم بالا میرود. اگر مشکلی بود، فقط عده کمی آن را دیدهاند.
دیتابیس: سختترین بخش deploy
در روش Rolling و Canary، نسخه قدیم و جدید کد همزمان با یک دیتابیس کار میکنند. پس دیتابیس در هر لحظه باید با هر دو نسخه سازگار باشد.
مثال: جدول مشتریها یک ستون «نام کامل» دارد. میخواهیم آن را به دو ستون «نام» و «نام خانوادگی» تبدیل کنیم. اگر یک migration ستون قدیمی را همان اول حذف کند:
- هنوز چند Pod کد قدیم را دارند و ستون قدیمی را میخوانند.
- دیتابیس خطای «ستون وجود ندارد» میدهد و کاربر خطای 500 میبیند.
- اگر نسخه جدید باگ داشته باشد، برگشت هم ممکن نیست. کد قدیم به ستونی نیاز دارد که دیگر وجود ندارد.
راه حل الگوی گسترش و جمع کردن (Expand and Contract) است. تغییر بزرگ را در چند deploy کوچک انجام میدهیم:
- گسترش. ستونهای جدید را اضافه کن. با امکان خالی بودن، تا کد قدیم خراب نشود.
- نوشتن در هر دو. کد جدید هم ستون قدیمی و هم ستونهای جدید را پر میکند. رکوردهای قدیمی را در دستههای کوچک پر کن، نه با یک دستور بزرگ که جدول را طولانی قفل کند.
- خواندن از جدید. وقتی همه داده پر شد، کد فقط از ستونهای جدید میخواند.
- جمع کردن. چند روز بعد، وقتی مطمئن شدی به نسخه قدیم برنمیگردی، ستون قدیمی را حذف کن.
کد
فایل 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"
قانونهای مهم
- تغییرهای کوچک و زیاد. هر deploy کوچکتر، ریسک کمتر و پیدا کردن مشکل آسانتر.
- قدم سریع اول. ساخت و تست واحد باید چند دقیقه طول بکشد. اگر Pipeline یک ساعت طول بکشد، برنامهنویسها آن را دور میزنند.
- قدم شکستخورده یعنی توقف. تست قرمز را نادیده نگیر و تست ناپایدار (flaky) را درست کن، نه اینکه دوباره اجرا کنی تا سبز شود.
- یک بار ساخت. همان بسته برای همه محیطها.
- رمز فقط در secrets. هیچ رمزی در کد، فایل Pipeline یا Image نباشد.
- هر migration با کد قبلی سازگار باشد. حذف و تغییر اسم ستون فقط در قدم آخر Expand and Contract.
- بعد از deploy نگاه کن. نرخ خطا و زمان پاسخ را بعد از هر deploy چک کن. اگر بد شد، سریع برگرد.
اشتباههای رایج
| اشتباه | نتیجه | راه درست |
|---|---|---|
| ساخت دوباره برای هر محیط | چیزی که تست شد با چیزی که منتشر شد فرق دارد. | یک بار ساخت و برچسب commit. |
| حذف یا تغییر اسم ستون در یک migration | وسط deploy خطای 500 و برگشت ناممکن. | گسترش و جمع کردن در چند deploy. |
| اجرای migration در شروع هر Pod | چند Pod همزمان migration را اجرا میکنند. | قدم جدا در Pipeline، مثلاً با bundle. |
| رمز در فایل Pipeline | هر کسی که به مخزن کد دسترسی دارد، رمز را میبیند. | بخش secrets ابزار CI. |
| دوباره اجرا کردن تست ناپایدار تا سبز شود | کسی دیگر به تستها اعتماد نمیکند. | پیدا کردن علت و درست کردن تست. |
| نداشتن برنامه برگشت | در زمان مشکل، تیم نمیداند چه کند. | برگشت به Image قبلی در یک قدم. |
کدام روش استقرار؟
ساده و کافی
- روش Rolling برای بیشتر سرویسها کافی است، به شرطی که تغییر دیتابیس با هر دو نسخه سازگار باشد.
- روش Canary برای سرویسهای حساس مثل پرداخت، که خطا گران است.
مراقب باش
- روش Blue-Green برای سیستمی که منابع کافی برای دو نسخه کامل ندارد.
- استقرار پیوسته بدون تستهای قوی و مانیتورینگ. خطا مستقیم به مشتری میرسد.
خلاصه در شش خط
- در CI هر تغییر خودکار ساخته و تست میشود. در CD همان نتیجه قدم به قدم به محیطها میرود.
- بسته را یک بار بساز و با شناسه commit برچسب بزن. تنظیمات از بیرون بیاید.
- روش Rolling ساده است، Blue-Green برگشت سریع دارد، Canary ریسک را کوچک میکند.
- با Feature Flag، deploy از انتشار جدا میشود.
- دیتابیس باید با نسخه قدیم و جدید کد سازگار باشد. از گسترش و جمع کردن استفاده کن.
- تغییر کوچک، Pipeline سریع، رمز در secrets و نگاه به مانیتورینگ بعد از هر deploy.