Релізи без страху
Спершу викатіть нову версію на URL з тегом, переводьте трафік кроками, які ви контролюєте через Pulumi config, і робіть відкат за секунди без перезбирання.
Що ламається
Section titled “Що ламається”Хтось перейменував 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. Відкотитися означає знову спрямувати на неї трафік, і це займає секунди.
Подивіться, що у вас є зараз:
export PROJECT_ID=your-project-idexport REGION=europe-west1
# All revisions of the service, newest firstgcloud 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.
Трафік як конфіг stack
Section titled “Трафік як конфіг 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 через пів року. У цьому і весь сенс робити це в коді, а не повзунком трафіку в консолі.
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, };});variable "release" { type = string }variable "app_version" { type = string }variable "stable_revision" { type = string }variable "canary_revision" { type = string default = null}variable "canary_percent" { type = number default = 0}
resource "google_cloud_run_v2_service" "sniplink_api" { name = "sniplink-api" location = var.region deletion_protection = false ingress = "INGRESS_TRAFFIC_ALL"
template { revision = "sniplink-api-${var.release}" service_account = google_service_account.runtime.email max_instance_request_concurrency = 10 # ... scaling, volumes from modules 3 and 4
containers { image = var.image env { name = "LABKIT_STUDENT_ID" value = var.student_id } env { name = "APP_VERSION" value = var.app_version } # ... probes, mounts } }
traffic { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" revision = var.stable_revision percent = 100 - var.canary_percent }
dynamic "traffic" { for_each = var.canary_revision == null ? [] : [var.canary_revision] content { type = "TRAFFIC_TARGET_ALLOCATION_TYPE_REVISION" revision = traffic.value percent = var.canary_percent tag = "canary" } }}
output "canary_url" { value = one([ for s in google_cloud_run_v2_service.sniplink_api.traffic_statuses : s.uri if s.tag == "canary" ])}Кілька рядків заслуговують на пояснення:
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, поточна версія має отримати ім’я. У застосунку тут нічого не змінюється: ви лише перейменовуєте те, що працює, і закріплюєте на ньому трафік.
cd infra
pulumi config set sniplink:release v1pulumi config set sniplink:version 1.0.0pulumi config set sniplink:stableRevision sniplink-api-v1pulumi config rm sniplink:canaryRevision # no-op if not setpulumi config set sniplink:canaryPercent 0
pulumi upPreview показує одне оновлення сервісу: template.revision змінюється з порожнього на sniplink-api-v1, з’являється змінна APP_VERSION, а traffics змінюється з «latest 100» на «sniplink-api-v1 100». Оскільки template змінився, Cloud Run створює нову ревізію, а оскільки список трафіку називає саме її, вона отримує весь трафік у тому ж оновленні.
Перевірте:
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. Клієнти, які про нього не знають, його ігнорують; ті, що знають, можуть його задати. Нічого з наявного не перейменовано, не видалено й не зроблено обов’язковим.
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> зі стартового проєкту, де немає місця для терміну дії. Сховайте його за невеликим сховищем; воно й далі в пам’яті, й далі окреме для кожного інстансу:
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) лишається як було.
// …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, використавши версію як тег образу:
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; нова ревізія отримує тег, щоб до неї можна було звернутися напряму.
pulumi config set sniplink:image $REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api:2.0.0pulumi config set sniplink:release v2pulumi config set sniplink:version 2.0.0pulumi config set sniplink:canaryRevision sniplink-api-v2pulumi config set sniplink:canaryPercent 0# stableRevision stays sniplink-api-v1
pulumi upУважно прочитайте preview, бо саме тут модель «клацає»: template змінюється (новий образ, нове ім’я ревізії, нова версія), а список трафіку отримує другий запис із percent: 0 і tag: canary. Перший запис, sniplink-api-v1 на 100, не змінюється. Увесь продакшн-трафік увесь цей час іде на v1.
URL з тегом
Section titled “URL з тегом”Тег дає ревізії власний URL, незалежно від її відсотка трафіку. Формат: тег, три дефіси, далі звичайний хост сервісу:
https://sniplink-api-123456789012.europe-west1.run.app # main URLhttps://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 вище. Складати жоден із них вручну не потрібно:
# From the stack (recommended)pulumi stack output canaryUrl
# From Cloud Run directly (read-only): every traffic target with its URLgcloud 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, продакшн-секретом і продакшн-мережевим шляхом), поки жоден користувач її не бачить.
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-incurl -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 responsecurl -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.
Етап c: 10 %, потім 100 %
Section titled “Етап c: 10 %, потім 100 %”Коли ревізія з тегом виглядає правильно, дайте їй частку реального трафіку:
pulumi config set sniplink:canaryPercent 10pulumi upPreview показує рівно одну зміну: 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 у лабі це кілька хвилин; для реального сервісу я хочу бачити щонайменше одну пікову годину.
# Errors from the canary only, last 30 minutesgcloud 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 зникає:
pulumi config set sniplink:stableRevision sniplink-api-v2pulumi config rm sniplink:canaryRevisionpulumi config set sniplink:canaryPercent 0pulumi 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.
- Назвіть поточний реліз
sniplink-api-v1зAPP_VERSION, що починається з1.(наприклад,1.0.0), і закріпіть на ньому 100 % трафіку (етап a). - Реалізуйте адитивну зміну з
expiresAt, зберіть і запуште образ v2. - Задеплойте його як
sniplink-api-v2зAPP_VERSION, що починається з2., на 0 % і з тегомcanary(етап b). - За бажанням пройдіть етап c: 10 %, потім відкат на 0 %. Один раз відрепетирувати відкат, поки нічого не горить, варто цих п’яти хвилин.
- Переконайтеся, що
canaryPercentдорівнює 0, коли натискаєте «Перевірити» (див. нижче). - Вставте
pulumi stack output urlяк URL сервісу іpulumi stack output canaryUrlяк canary URL.
Перевірте лабу
Що перевіряють перевірки:
- Базова перевірка, двічі.
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.
Прибирання
Section titled “Прибирання”Залиште stack. Модуль 6 будує CI/CD-пайплайн поверх саме цього сервісу. Пайплайн зберігає цю модель (стабільна ревізія, відсоток canary й тег canary у конфігу stack) і замінює лише вручну набраний release на суфікс ревізії, який кожен білд отримує зі свого коміту.
Якщо ви ставите курс на паузу надовго, можна прибрати зайве, не втрачаючи налаштувань:
# Optional: drop the canary tag so nothing points at v2pulumi config rm sniplink:canaryRevisionpulumi config set sniplink:canaryPercent 0pulumi upРевізії, що простоюють з 0 % трафіку й без мінімальної кількості інстансів, нічого не коштують. Запускайте pulumi destroy, лише якщо повністю кидаєте курс; тоді для модуля 6 доведеться спершу заново застосувати модулі 1-5.
Що ви вивчили
Section titled “Що ви вивчили”- Ревізія є незмінним знімком образу плюс конфігурації; трафік є окремим списком, який вирішує, які ревізії отримують запити.
TRAFFIC_TARGET_ALLOCATION_TYPE_LATESTробить кожен деплой релізом; закріплення цілей за іменем ревізії розділяє ці дві дії.- Явні імена ревізій, змінна середовища з версією й трафік, керований конфігом stack, перетворюють кожен етап релізу, включно з відкатом, на однорядкову зміну, яку можна відрев’ювити.
- Тег дає ревізії стабільний URL
canary---, тож її можна тестувати в продакшні при 0 % трафіку. - Canary зменшує ризик лише тоді, коли обидві версії можуть обслуговувати тих самих клієнтів: адитивні зміни, нові маршрути для ламаючих, expand-and-contract для перейменувань.
Що почитати далі
Section titled “Що почитати далі”- Rollbacks, gradual rollouts, and traffic migration (документація Cloud Run)
- Manage revisions (документація Cloud Run)
- cloudrunv2.Service (Pulumi Registry)
- Cloud Deploy documentation