Перейти до вмісту

Ви увійшли як

Вхід у Curious Dev Learn

Увійдіть, щоб перевіряти лаби, зберігати прогрес і отримати сертифікат. Уроки відкриті й без акаунта.

Модуль 051 год 45 хвЛаба

Релізи без страху

Спершу викатіть нову версію на URL з тегом, переводьте трафік кроками, які ви контролюєте через Pulumi config, і робіть відкат за секунди без перезбирання.

Хтось перейменував url на targetUrl у відповіді з посиланням, бо так читалося краще. Тести оновили в тому ж pull request, тож вони пройшли. Вебклієнт передеплоїли того ж дня. А мобільний застосунок, який живе в сторі й оновлюється тоді, коли зручно користувачам, не передеплоїли. 40 хвилин кожне посилання, на яке тапали в застосунку, не працювало, а «відкат» виявився revert-комітом, що знову пройшов увесь пайплайн: 12 хвилин збирання образу, який і так уже існував.

Не так пішли дві речі, і вони незалежні. Модель релізу була «все або нічого»: щойно нова версія ставала готовою, вона отримувала 100 % користувачів. А сама зміна була ламаючою: дві версії API не могли обслуговувати тих самих клієнтів. Цей модуль виправляє обидві. Ви зробите «деплой» і «реліз» двома різними діями, винесете розподіл трафіку в Pulumi config і розберетеся, які зміни API безпечно викочувати поступово.

Ревізії незмінні, трафік окремо

Section titled “Ревізії незмінні, трафік окремо”

Щоразу, коли ви змінюєте будь-що в template сервісу Cloud Run (образ, змінні середовища, проби, concurrency, том із секретом), Cloud Run створює нову ревізію. Ревізія є замороженим знімком: один digest образу плюс одна конфігурація. Редагувати ревізію не можна. Можна лише створити нову.

У сервісу є друга, незалежна частина конфігурації: трафік. Трафік є списком цілей, кожна з яких каже «ця ревізія отримує такий відсоток запитів». Створення ревізії й спрямування на неї трафіку є двома різними операціями, які виглядають як одна лише через значення за замовчуванням.

Це значення TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST означає «віддавай 100 % останній готовій ревізії, хоч якою вона буде». З модуля 1 ваш сервіс працює саме так, бо ви взагалі не задавали Traffics і Cloud Run заповнив його сам. У цьому режимі нова ревізія отримує весь трафік, щойно пройде startup probe. Це рівно той інцидент, що вище.

Альтернативою є TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION, тобто «віддавай N % ревізії з ось цим конкретним іменем». Коли кожну ціль закріплено так, нова ревізія створюється, стає готовою й отримує нуль трафіку, доки ви не зміните список трафіку. Деплой стає дешевим і безпечним, а реліз стає свідомим окремим кроком.

Оскільки ревізії незмінні, а Cloud Run зберігає старі, відкат не потребує перезбирання. Стара ревізія досі існує, зі своїм старим образом і старим env. Відкотитися означає знову спрямувати на неї трафік, і це займає секунди.

Подивіться, що у вас є зараз:

Terminal window
export PROJECT_ID=your-project-id
export REGION=europe-west1
# All revisions of the service, newest first
gcloud run revisions list --service sniplink-api --region $REGION
# The current traffic configuration (read-only)
gcloud run services describe sniplink-api --region $REGION \
--format="yaml(spec.traffic,status.traffic)"

Ви побачите автозгенеровані імена на кшталт sniplink-api-00007-zuf і єдиний запис трафіку з latestRevision: true. Зараз зміниться і те, і інше.

Імена ревізій, які обираєте ви

Section titled “Імена ревізій, які обираєте ви”

Автозгенеровані імена годяться для машин і погано читаються людьми. Речення «віддати 10 % на sniplink-api-00008-qix» ніхто не зможе відрев’ювити в pull request. Тож перша зміна полягає в тому, щоб явно називати ревізії через Template.Revision.

Правило: ім’я ревізії має починатися з імені сервісу та дефіса. sniplink-api-v1 валідне; v1 чи sniplink-v1 API відхилить. gcloud run deploy --revision-suffix=v1 приховує це, бо сам додає ім’я сервісу на початок; API, а отже й Pulumi, цього не роблять, тому ви пишете повне ім’я.

Крім того, імена унікальні в межах сервісу назавжди, або принаймні доки ревізія існує. Щойно sniplink-api-v1 створено, іншу sniplink-api-v1 з іншим образом створити вже не вийде. Я ще повернуся до цього в підводних каменях, бо це перша помилка, на яку натрапляють усі.

Заразом дайте застосунку версію, яку він зможе повідомляти. LabKit уже повертає version з /_lab/info, читаючи змінну середовища APP_VERSION; досі ви її не задавали, тож вона була порожня. Відтепер ім’я ревізії та версія рухаються разом, і обидва беруться з конфігу stack.

Ось дизайн, яким я користуюся. Програма Pulumi не хардкодить жодних відсотків. Вона читає п’ять значень конфігу й будує з них список Traffics:

Ключ Приклад Що означає
sniplink:release v2 Суфікс ревізії, яку створить template
sniplink:version 2.0.0 Значення APP_VERSION у цій ревізії
sniplink:stableRevision sniplink-api-v1 Ревізія, яку отримує більшість користувачів
sniplink:canaryRevision sniplink-api-v2 Необов’язкове; ревізія за тегом canary
sniplink:canaryPercent 10 Частка трафіку, яку отримує canary (від 0 до 100)

Тоді кожен етап релізу зводиться до pulumi config set і pulumi up. Diff займає два-три рядки в Pulumi.dev.yaml, які можна відрев’ювити, відкотити й прочитати в історії git через пів року. У цьому і весь сенс робити це в коді, а не повзунком трафіку в консолі.

infra/Program.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Pulumi;
using Gcp = Pulumi.Gcp;
using Pulumi.Gcp.CloudRunV2;
using Pulumi.Gcp.CloudRunV2.Inputs;
return await Deployment.RunAsync(() =>
{
const string serviceName = "sniplink-api";
var config = new Config("sniplink");
var region = new Config("gcp").Require("region");
var image = config.Require("image");
var release = config.Require("release"); // "v1", "v2", ...
var version = config.Require("version"); // "1.0.0", "2.0.0", ...
var stableRevision = config.Require("stableRevision"); // "sniplink-api-v1"
var canaryRevision = config.Get("canaryRevision"); // null when no canary
var canaryPercent = config.GetInt32("canaryPercent") ?? 0;
var studentId = config.Require("studentId");
// Fail at preview time, not halfway through an update.
if (canaryPercent is < 0 or > 100)
throw new ArgumentException("canaryPercent must be 0..100");
if (canaryRevision is null && canaryPercent != 0)
throw new ArgumentException("canaryPercent > 0 needs canaryRevision");
if (canaryRevision == stableRevision)
throw new ArgumentException("canary and stable must differ");
var traffics = new InputList<ServiceTrafficArgs>
{
new ServiceTrafficArgs
{
Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
Revision = stableRevision,
Percent = 100 - canaryPercent,
},
};
if (canaryRevision is not null)
{
traffics.Add(new ServiceTrafficArgs
{
Type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION",
Revision = canaryRevision,
Percent = canaryPercent,
Tag = "canary",
});
}
// … repo from module 1; runtimeSa, labSecret and IAM from module 3 (unchanged)
var service = new Service(serviceName, new ServiceArgs
{
Name = serviceName,
Location = region,
DeletionProtection = false,
Ingress = "INGRESS_TRAFFIC_ALL",
Template = new ServiceTemplateArgs
{
Revision = $"{serviceName}-{release}",
ServiceAccount = runtimeSa.Email,
MaxInstanceRequestConcurrency = 10,
// … Scaling from module 4, Volumes from module 3 (unchanged)
Containers =
{
new ServiceTemplateContainerArgs
{
Image = image,
Envs =
{
new ServiceTemplateContainerEnvArgs
{
Name = "LABKIT_STUDENT_ID",
Value = studentId,
},
new ServiceTemplateContainerEnvArgs
{
Name = "APP_VERSION",
Value = version,
},
},
// … ports, resources, probes and volume mounts from modules 1, 3 and 4
},
},
},
Traffics = traffics,
}, new CustomResourceOptions { DependsOn = { repo, labKeyAccess, labKeyVersion } });
// … public invoker binding from module 1 (unchanged)
return new Dictionary<string, object?>
{
["url"] = service.Uri,
["canaryUrl"] = service.TrafficStatuses.Apply(statuses =>
statuses.FirstOrDefault(s => s.Tag == "canary")?.Uri ?? ""),
["latestRevision"] = service.LatestReadyRevision,
};
});

Кілька рядків заслуговують на пояснення:

  • Revision = $"{serviceName}-{release}". Правило префікса живе в одному місці, тож конфіг ніколи не дасть невалідного імені.
  • APP_VERSION поруч з іменем ревізії. Ревізію, яку не можна ідентифікувати ззовні, неможливо й перевірити. Лаба на це покладається.
  • Обидві цілі мають тип ..._REVISION. Жодна ціль не вказує на «latest», тож нічого не зрушить, доки ви самі цього не зробите.
  • Guard-умови. Відсотки, що в сумі не дають 100, або canary, що вказує на стабільну ревізію, API й так відхилить, але лише після того, як Pulumi вже почав оновлення. Впасти на pulumi preview дешевше.
  • canaryUrl з TrafficStatuses. URL з тегом призначає Cloud Run, а не ви. Блок статусу повідомляє його, щойно тег з’являється.

Окрім читання конфігу, імені ревізії, однієї змінної середовища та списку Traffics, це незмінена програма з модуля 4. Якщо прибрати Traffics, Cloud Run повернеться до «100 % на latest», тому я завжди задаю його явно, щойно в сервісу з’являються користувачі.

Етап a: v1 на 100 %, за іменем

Section titled “Етап a: v1 на 100 %, за іменем”

Перш ніж щось викочувати через canary, поточна версія має отримати ім’я. У застосунку тут нічого не змінюється: ви лише перейменовуєте те, що працює, і закріплюєте на ньому трафік.

Terminal window
cd infra
pulumi config set sniplink:release v1
pulumi config set sniplink:version 1.0.0
pulumi config set sniplink:stableRevision sniplink-api-v1
pulumi config rm sniplink:canaryRevision # no-op if not set
pulumi config set sniplink:canaryPercent 0
pulumi up

Preview показує одне оновлення сервісу: template.revision змінюється з порожнього на sniplink-api-v1, з’являється змінна APP_VERSION, а traffics змінюється з «latest 100» на «sniplink-api-v1 100». Оскільки template змінився, Cloud Run створює нову ревізію, а оскільки список трафіку називає саме її, вона отримує весь трафік у тому ж оновленні.

Перевірте:

Terminal window
SERVICE_URL=$(pulumi stack output url)
curl -s $SERVICE_URL/_lab/info | jq '{service, revision, version}'
# { "service": "sniplink-api", "revision": "sniplink-api-v1", "version": "1.0.0" }

Закомітьте Pulumi.dev.yaml і Program.cs. Відтепер файл конфігу stack стає вашим журналом релізів.

Зміна, яку переживуть обидві версії

Section titled “Зміна, яку переживуть обидві версії”

Тепер власне v2. Інцидент спричинила ламаюча зміна, тож перш ніж її писати, засвойте правило, без якого canary взагалі неможливий:

Під час canary дві версії одночасно обслуговують тих самих клієнтів. Користувач може створити посилання через v2 і за секунду прочитати його через v1. Мобільний застосунок, зібраний під v1, частину запитів відправлятиме на v2. Якщо версії розходяться в контракті, canary не зменшує ризик, а просто розмазує поломку на 10 % запитів, випадковим чином, і це важче дебажити, ніж 100 %.

Тому v2 Sniplink робить адитивну зміну: посилання отримують необов’язкове поле expiresAt. Клієнти, які про нього не знають, його ігнорують; ті, що знають, можуть його задати. Нічого з наявного не перейменовано, не видалено й не зроблено обов’язковим.

src/Sniplink.Api/Links.cs
using System.Text.Json.Serialization;
namespace Sniplink.Api;
public sealed record CreateLinkRequest(string Url, DateTimeOffset? ExpiresAt = null);
public sealed record LinkResponse(
string Slug,
string Url,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
DateTimeOffset? ExpiresAt = null);

Досі посилання жили в ConcurrentDictionary<string, string> зі стартового проєкту, де немає місця для терміну дії. Сховайте його за невеликим сховищем; воно й далі в пам’яті, й далі окреме для кожного інстансу:

src/Sniplink.Api/LinkStore.cs
using System.Collections.Concurrent;
using System.Security.Cryptography;
namespace Sniplink.Api;
public sealed record Link(string Slug, string Url, DateTimeOffset? ExpiresAt)
{
public bool IsExpired(DateTimeOffset now) => ExpiresAt is { } exp && exp <= now;
}
public interface ILinkStore
{
Link Add(string url, DateTimeOffset? expiresAt);
bool TryGet(string slug, out Link link);
}
// In-memory on purpose, per instance. Persistence is the next course.
public sealed class InMemoryLinkStore : ILinkStore
{
private readonly ConcurrentDictionary<string, Link> _links = new();
public Link Add(string url, DateTimeOffset? expiresAt)
{
while (true)
{
var slug = RandomNumberGenerator.GetString("abcdefghijkmnpqrstuvwxyz23456789", 6);
var link = new Link(slug, url, expiresAt);
if (_links.TryAdd(slug, link)) return link;
}
}
public bool TryGet(string slug, out Link link) => _links.TryGetValue(slug, out link!);
}

Зареєструйте його через builder.Services.AddSingleton<ILinkStore, InMemoryLinkStore>();, видаліть старий словник і стартовий record CreateLink та змініть два endpoint-и, які створюють і резолвлять посилання. GET /api/links/{slug} так само переходить на сховище й повертає LinkResponse. Усе інше в Program.cs (прив’язка до PORT, /health, health check і налаштування завершення з модуля 4, LabKit) лишається як було.

src/Sniplink.Api/Program.cs
// …
app.MapPost("/api/links", (CreateLinkRequest req, ILinkStore store) =>
{
if (!Uri.TryCreate(req.Url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
return Results.ValidationProblem(new Dictionary<string, string[]> { ["url"] = ["Must be an absolute http or https URL."] });
if (req.ExpiresAt is { } exp && exp <= DateTimeOffset.UtcNow)
return Results.ValidationProblem(new Dictionary<string, string[]> { ["expiresAt"] = ["Must be in the future."] });
var link = store.Add(req.Url, req.ExpiresAt);
return Results.Created($"/api/links/{link.Slug}",
new LinkResponse(link.Slug, link.Url, link.ExpiresAt));
});
app.MapGet("/{slug}", (string slug, ILinkStore store) =>
store.TryGet(slug, out var link) && !link.IsExpired(DateTimeOffset.UtcNow)
? Results.Redirect(link.Url)
: Results.NotFound());
// …

Чому саме так:

  • Необов’язкове в запиті, зі значенням за замовчуванням. Клієнт v1, що надсилає { "url": "…" }, отримує рівно поведінку v1.
  • Відсутнє у відповіді, коли null. Більшість JSON-клієнтів толерують невідомі поля, але деякі суворі (частина згенерованих клієнтів, частина мобільних декодерів) не толерують неочікуваний null там, де не чекали нічого. Якщо поле пропускати, клієнт v1 бачить байт-у-байт відповідь v1, доки сам не ввімкне нове поле.
  • Прострочені посилання повертають 404, а не новий статус. Клієнт v1 уже обробляє 404 для невідомого slug. 410 Gone був би точнішим, але це також зміна контракту для кожного клієнта, що робить switch за статус-кодами. Це можна зробити пізніше, свідомим рішенням.
  • Лише URL з http або https. Сам по собі Uri.TryCreate з UriKind.Absolute на Linux приймає /foo, бо розбирає його як file:///foo, а редірект на таку адресу нікуди корисного не веде. Це суворіше, ніж у v1, але жоден робочий клієнт не покладається на створення посилання, за яким неможливо перейти.

Коли зміна мусить зламати

Section titled “Коли зміна мусить зламати”

Іноді вам справді потрібен targetUrl замість url або взагалі інша структура. Два патерни роблять це безпечним:

  • Новий маршрут для нового контракту. /api/v2/links повертає нову структуру; /api/links зберігає стару, на тому самому коді. Старі клієнти працюють, доки ви не доведете (за логами запитів, а не за надією), що старий маршрут ніхто не викликає.
  • Expand and contract. Для перейменування поля: випустіть версію, яка пише обидва url і targetUrl та читає будь-яке з них (expand). Переведіть усіх клієнтів на targetUrl. Лише тоді випустіть версію без url (contract). Кожен крок зворотно сумісний з попереднім, тож кожен можна викотити через canary.

Expand-and-contract повільніший: три релізи замість одного. Це реальна ціна клієнтів, яких ви не деплоїте самі, а інцидент вище показує, як виглядає спроба цей крок пропустити.

Зберіть і запуште v2 командами Docker з модуля 2, використавши версію як тег образу:

Terminal window
docker build --platform linux/amd64 -t $REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:2.0.0 .
docker push $REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:2.0.0

Етап b: v2 задеплоєна, 0 % трафіку, з тегом

Section titled “Етап b: v2 задеплоєна, 0 % трафіку, з тегом”

Тепер задеплойте v2, не релізячи її. Template переходить на v2; трафік лишається на v1; нова ревізія отримує тег, щоб до неї можна було звернутися напряму.

Terminal window
pulumi config set sniplink:image $REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:2.0.0
pulumi config set sniplink:release v2
pulumi config set sniplink:version 2.0.0
pulumi config set sniplink:canaryRevision sniplink-api-v2
pulumi config set sniplink:canaryPercent 0
# stableRevision stays sniplink-api-v1
pulumi up

Уважно прочитайте preview, бо саме тут модель «клацає»: template змінюється (новий образ, нове ім’я ревізії, нова версія), а список трафіку отримує другий запис із percent: 0 і tag: canary. Перший запис, sniplink-api-v1 на 100, не змінюється. Увесь продакшн-трафік увесь цей час іде на v1.

Тег дає ревізії власний URL, незалежно від її відсотка трафіку. Формат: тег, три дефіси, далі звичайний хост сервісу:

https://sniplink-api-123456789012.europe-west1.run.app # main URL
https://canary---sniplink-api-123456789012.europe-west1.run.app # canary tag

Кожен сервіс також має старіший хост на основі хешу, як-от sniplink-api-abc123xyz-ew.a.run.app, і тег працює і з ним: canary---sniplink-api-abc123xyz-ew.a.run.app. URL, який Cloud Run повідомляє для тегу (у TrafficStatuses, а отже в canaryUrl, і в gcloud), має саме цю форму з хешем, тож не дивуйтеся, що він не схожий на основний URL вище. Складати жоден із них вручну не потрібно:

Terminal window
# From the stack (recommended)
pulumi stack output canaryUrl
# From Cloud Run directly (read-only): every traffic target with its URL
gcloud run services describe sniplink-api --region $REGION \
--format="table(status.traffic[].tag,status.traffic[].revisionName,status.traffic[].percent,status.traffic[].url)"

Запити на URL з тегом завжди йдуть на sniplink-api-v2. Вони не враховуються в розподілі відсотків ні в той, ні в інший бік, тож ви можете тестувати v2 у продакшні (з продакшн runtime SA, продакшн-секретом і продакшн-мережевим шляхом), поки жоден користувач її не бачить.

Terminal window
CANARY_URL=$(pulumi stack output canaryUrl)
curl -s $CANARY_URL/_lab/info | jq '{service, revision, version}'
# { "service": "sniplink-api", "revision": "sniplink-api-v2", "version": "2.0.0" }
# The new field, opt-in
curl -s -X POST $CANARY_URL/api/links \
-H 'content-type: application/json' \
-d '{"url":"https://example.com","expiresAt":"2030-01-01T00:00:00Z"}'
# An old-style request must still get an old-style response
curl -s -X POST $CANARY_URL/api/links \
-H 'content-type: application/json' \
-d '{"url":"https://example.com"}'
# { "slug": "…", "url": "https://example.com" } no expiresAt key

Останній запит є найважливішим тестом релізу. Якщо у вас є набір контрактних тестів для клієнтів v1, саме тут їх варто націлити на canary URL.

Коли ревізія з тегом виглядає правильно, дайте їй частку реального трафіку:

Terminal window
pulumi config set sniplink:canaryPercent 10
pulumi up

Preview показує рівно одну зміну: traffics[0].percent 100 → 90 і traffics[1].percent 0 → 10. Жодної нової ревізії, жодного образу, жодного білда. Оновлення триває стільки, скільки займає власний round trip Pulumi; сама зміна маршрутизації застосовується за секунди.

Тепер спостерігайте. У консолі (режим лише для читання) вкладка Metrics сервісу вміє розбивати кількість запитів і латентність за ревізіями; у Cloud Logging можна фільтрувати за resource.labels.revision_name="sniplink-api-v2". Порівняйте частку 5xx і латентність canary зі стабільною ревізією за період, що покриває ваш звичайний патерн трафіку. Для Sniplink у лабі це кілька хвилин; для реального сервісу я хочу бачити щонайменше одну пікову годину.

Terminal window
# Errors from the canary only, last 30 minutes
gcloud logging read \
'resource.type="cloud_run_revision"
resource.labels.service_name="sniplink-api"
resource.labels.revision_name="sniplink-api-v2"
severity>=ERROR' \
--freshness=30m --limit=20

Якщо все тримається, промоутьте. Промоут означає, що v2 стає стабільною ревізією, а запис canary зникає:

Terminal window
pulumi config set sniplink:stableRevision sniplink-api-v2
pulumi config rm sniplink:canaryRevision
pulumi config set sniplink:canaryPercent 0
pulumi up

Видалення запису canary прибирає й тег canary, і URL з тегом перестає резолвитися. Наступний реліз знову починається з етапу b, з release v3.

Відкат як зміна конфігу

Section titled “Відкат як зміна конфігу”

Відкат є зворотною дією до вашого останнього кроку, і template він ніколи не чіпає.

  • Під час canary (v2 на 10 %): pulumi config set sniplink:canaryPercent 0 і pulumi up. v1 знову на 100 %, а тег лишається, тож ви можете далі дебажити v2 на її власному URL без жодного користувача на ній.
  • Після промоуту (v2 на 100 %): pulumi config set sniplink:stableRevision sniplink-api-v1 і pulumi up. Ревізія sniplink-api-v1 досі існує зі своїм старим образом і старим APP_VERSION, тож одразу знову обслуговує запити.

Порівняйте з інцидентом: 12 хвилин перезбирання завідомо робочого образу проти одного рядка конфігу та одного pulumi up. Після відкату template і далі каже v2, і це правильно. «Що задеплоєно останнім» і «що обслуговує трафік» є різними питаннями, і тепер Cloud Run відповідає на них окремо.

Чого Cloud Run за вас не зробить

Section titled “Чого Cloud Run за вас не зробить”

Cloud Run дає механізм: незмінні ревізії, розподіл за відсотками й теги. Політики він не дає. Зокрема, він не буде:

  • стежити за часткою помилок чи латентністю canary і сам робити відкат;
  • переходити з 10 % на 25 % і на 50 % за розкладом;
  • зупиняти промоут, бо впав перевірочний тест.

Усе це досі на вас: дивитися на дашборд і запускати pulumi up. Для одного сервісу й кількох релізів на місяць це, чесно кажучи, нормально, і саме так насправді працює більшість команд, з якими я працював.

Коли потрібна автоматизація, власним інструментом Google є Cloud Deploy. Він підтримує canary-стратегії для цілей Cloud Run: поетапні відсотки, verification jobs між фазами, апрували та промоут між середовищами. У нього своя модель (delivery pipelines, targets, releases, rollouts) і свій спосіб володіти розподілом трафіку, який конфліктуватиме з тим, що Pulumi напряму керує Traffics. Ця проєктна робота виходить за межі курсу; тут важливо знати, що такий інструмент існує, і що відкат за метриками ви додаєте самі, а Cloud Run за замовчуванням його не робить.

Мета: залишити сервіс у стані, де v1 обслуговує весь звичайний трафік, а v2 доступна за тегом canary, і обидві керуються через Pulumi config.

  1. Назвіть поточний реліз sniplink-api-v1 з APP_VERSION, що починається з 1. (наприклад, 1.0.0), і закріпіть на ньому 100 % трафіку (етап a).
  2. Реалізуйте адитивну зміну з expiresAt, зберіть і запуште образ v2.
  3. Задеплойте його як sniplink-api-v2 з APP_VERSION, що починається з 2., на 0 % і з тегом canary (етап b).
  4. За бажанням пройдіть етап c: 10 %, потім відкат на 0 %. Один раз відрепетирувати відкат, поки нічого не горить, варто цих п’яти хвилин.
  5. Переконайтеся, що canaryPercent дорівнює 0, коли натискаєте «Перевірити» (див. нижче).
  6. Вставте pulumi stack output url як URL сервісу і pulumi stack output canaryUrl як canary URL.

Перевірте лабу

Завантажуємо лабу…

Увійдіть, щоб перевірити цю лабу й зберегти прогрес. Усе вище працює без акаунта.

  • URL вашого сервісу Cloud Run
  • URL вашого canary-тегу

Перевірок для сервісу, який ви розгорнули: 5.

Деякі перевірки самозвітні: перевірка довіряє тому, що сервіс каже про себе, а решту перевіряє ззовні.

Що перевіряють перевірки:

  • Базова перевірка, двічі. POST /_lab/verify на обох URL повертає валідний Google ID token для вашого проєкту. Це доводить, що обидва URL є справжніми endpoint-ами Cloud Run в одному проєкті.
  • Тег canary. Хост canary URL починається з canary---, тож це тег, а не другий сервіс; service (K_SERVICE) і проєкт на обох URL однакові, а revision (K_REVISION) відрізняється.
  • Main віддає v1. GET /_lab/info на основному URL повідомляє version, що починається з 1..
  • Canary віддає v2. Той самий endpoint на canary URL повідомляє version, що починається з 2..

Перевірки version самозвітні: значенням є те, що ви поклали в APP_VERSION. Імена сервісу й ревізії беруться зі змінних середовища, які Cloud Run задає сам, і це надійніше, але LabKit усе одно повертає їх у тілі відповіді, а не в підписаному токені. Сувора перевірка читала б конфігурацію трафіку сервісу через Cloud Run Admin API, і саме це робитиме майбутній рівень Verified.

Чому 0 % на момент перевірки: при 10 % приблизно кожен десятий запит на основний URL потрапляє на v2, тож «main віддає v1» падала б випадково. Чекер міг би зробити багато запитів і прийняти певне співвідношення, але тоді pass чи fail залежали б від везіння й від того, скільки інстансів випадково виявляться прогрітими. Перевірка, яка може змінити результат при повторному запуску, гірша за відсутність перевірки, тому лаба стверджує лише те, що істинно напевно: основний URL при 0 % canary завжди віддає v1, а тег завжди віддає v2.

Залиште stack. Модуль 6 будує CI/CD-пайплайн поверх саме цього сервісу. Пайплайн зберігає цю модель (стабільна ревізія, відсоток canary й тег canary у конфігу stack) і замінює лише вручну набраний release на суфікс ревізії, який кожен білд отримує зі свого коміту.

Якщо ви ставите курс на паузу надовго, можна прибрати зайве, не втрачаючи налаштувань:

Terminal window
# Optional: drop the canary tag so nothing points at v2
pulumi config rm sniplink:canaryRevision
pulumi config set sniplink:canaryPercent 0
pulumi up

Ревізії, що простоюють з 0 % трафіку й без мінімальної кількості інстансів, нічого не коштують. Запускайте pulumi destroy, лише якщо повністю кидаєте курс; тоді для модуля 6 доведеться спершу заново застосувати модулі 1-5.

  • Ревізія є незмінним знімком образу плюс конфігурації; трафік є окремим списком, який вирішує, які ревізії отримують запити.
  • TRAFFIC_TARGET_ALLOCATION_TYPE_LATEST робить кожен деплой релізом; закріплення цілей за іменем ревізії розділяє ці дві дії.
  • Явні імена ревізій, змінна середовища з версією й трафік, керований конфігом stack, перетворюють кожен етап релізу, включно з відкатом, на однорядкову зміну, яку можна відрев’ювити.
  • Тег дає ревізії стабільний URL canary---, тож її можна тестувати в продакшні при 0 % трафіку.
  • Canary зменшує ризик лише тоді, коли обидві версії можуть обслуговувати тих самих клієнтів: адитивні зміни, нові маршрути для ламаючих, expand-and-contract для перейменувань.