Ваш перший деплой як код
Полагодьте сервіс, який не може стартувати на Cloud Run, зберіть його образ без Dockerfile і задеплойте за допомогою Pulumi C#.
Що ламається
Section titled “Що ламається”На вашому ноутбуці Sniplink працює чудово. dotnet run, curl localhost:5000, посилання створюються, редіректи працюють. Ви збираєте образ, деплоїте його на Cloud Run, і перша ревізія так і не стає готовою. Лог навіть каже, що застосунок запустився: Now listening on: http://localhost:5000. Тобто застосунок слухає. Просто не там, куди до нього звертаються.
Цей модуль відповідає на одне питання: чого платформа на кшталт Cloud Run очікує від вашого контейнера і як виконати ці очікування в коді та в інфраструктурі, нічого не клацаючи в консолі? Наприкінці у вас буде Sniplink на публічному URL run.app, розгорнутий з Pulumi C# проєкту у вашому репозиторії й перевірений платформою курсу ззовні.
Що таке Cloud Run і чого він очікує
Section titled “Що таке Cloud Run і чого він очікує”Cloud Run є найпростішим серйозним способом запустити контейнер у Google Cloud. Ви даєте йому образ, він дає вам HTTPS URL. Він запускає інстанси, коли приходять запити, масштабується вшир, коли їх багато, і до нуля, коли їх немає. Ви платите за CPU і пам’ять, використані під час обробки запитів (у режимі тарифікації за замовчуванням), плюс плату за кожен запит, а free tier покриває значно більше, ніж потрібно для цього курсу.
Жодних VM, нод чи балансувальників навантаження, якими вам треба керувати. Ціна такої зручності полягає в контракті контейнера, тобто в короткому списку правил, яких має дотримуватися ваш контейнер, щоб платформа могла його запустити. Для цього модуля важливі такі:
- Слухати на
0.0.0.0на порту зі змінної середовищаPORT. Cloud Run встановлюєPORT(8080, якщо ви не налаштували інший порт у сервісі) і надсилає трафік саме туди, на мережевий інтерфейс контейнера. - Не мати стану. Будь-який інстанс можуть вбити чи замінити будь-коли, а запити розподіляються між інстансами. In-memory сховище Sniplink навмисно це порушує; персистентність є темою наступного курсу, а в цьому ми тримаємо один невеликий сервіс, де для лаб це не має значення.
- Швидко стартувати. Інстанс має почати слухати в межах startup timeout, інакше ревізію позначать як невдалу. Саме ця помилка і є в інциденті.
- Обробляти
SIGTERM. Коли Cloud Run зупиняє інстанс, він надсилаєSIGTERMі чекає короткий grace period. ASP.NET Core робить базові речі за вас; деталі будуть у модулі 4.
Cloud Run також встановлює кілька змінних, які вам ще трапляться: K_SERVICE (ім’я сервісу), K_REVISION (ім’я ревізії) і K_CONFIGURATION. Ваш код може їх читати; встановлювати їх самостійно не варто.
Подивіться на Program.cs зі стартового проєкту, тримаючи контракт у голові. Усе гаразд аж до останнього рядка.
using System.Collections.Concurrent;
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();
// In-memory store on purpose. Persistence is the next course.var links = new ConcurrentDictionary<string, string>();
app.MapPost("/api/links", (CreateLink request) =>{ // … validate the URL, generate a slug, store it});
app.MapGet("/{slug}", (string slug) => links.TryGetValue(slug, out var url) ? Results.Redirect(url) : Results.NotFound());
// … GET /api/links/{slug}
// For local convenienceapp.Run("http://localhost:5000");
record CreateLink(string Url);app.Run("http://localhost:5000") робить дві речі. Він жорстко фіксує порт 5000 і прив’язується до localhost, тобто loopback-інтерфейсу. Будь-якої з цих двох речей окремо достатньо, щоб упасти на Cloud Run.
Відтворіть баг локально
Section titled “Відтворіть баг локально”Перш ніж щось лагодити, змусьте це впасти на своїй машині. Баг, який ви відтворюєте за десять секунд, можна впевнено виправити; баг, який відтворюється лише через деплой, коштує вам кава-брейку за кожну спробу.
Dockerfile поки що не потрібен. .NET SDK вміє зібрати образ контейнера прямо з вашого проєкту за допомогою таргету PublishContainer. Якщо реєстр не налаштовано, він завантажує образ у ваш локальний Docker daemon:
# Build an image into the local Docker daemondotnet publish src/Sniplink.Api -c Release /t:PublishContainer \ -p:ContainerRepository=sniplink-api \ -p:ContainerImageTag=local
# Run it the way Cloud Run does: PORT set, traffic arriving on 8080docker run --rm -e PORT=8080 -p 8080:8080 sniplink-api:localУ логах контейнера буде щось таке (точний текст попередження відрізняється між версіями .NET, а деякі не виводять його взагалі):
warn: Microsoft.AspNetCore.Server.Kestrel[0] Overriding address(es) 'http://*:8080'. Binding to endpoints defined via the code instead.info: Microsoft.Hosting.Lifetime[14] Now listening on: http://localhost:5000У другому терміналі:
curl -i http://localhost:8080/api/links/anythingЗалежно від вашого налаштування Docker ви отримаєте connection reset або порожню відповідь. У логах застосунку нічого, бо жоден запит так і не дійшов до Kestrel.
Перша половина пояснення стосується порту. Базовий образ уже сказав ASP.NET Core слухати на 8080 через ASPNETCORE_HTTP_PORTS, а ваш захардкоджений URL це перевизначив. Про це трохи згодом.
Друга половина стосується слова localhost. Усередині контейнера localhost означає loopback-інтерфейс самого контейнера, а не вашого ноутбука. Коли Docker прокидає -p 8080:8080, трафік приходить на мережевий інтерфейс контейнера (eth0), а не на loopback. Сервер, прив’язаний до 127.0.0.1, його просто не бачить. Можна довести, що це окрема від порту проблема: запустіть docker run --rm -e PORT=5000 -p 8080:5000 sniplink-api:local, і запит однаково не пройде, хоча порт тепер збігається. Cloud Run працює так само. Його трафік приходить ззовні контейнера, тож сервер має слухати на всіх інтерфейсах, а саме це й означає 0.0.0.0.
Виправлення: читаємо PORT, слухаємо на всіх інтерфейсах
Section titled “Виправлення: читаємо PORT, слухаємо на всіх інтерфейсах”Виправлення в тому, щоб перестати вгадувати й прочитати контракт. Замініть підкладений рядок:
// Cloud Run tells us which port to use. Locally, default to the same 8080.var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";app.Run($"http://0.0.0.0:{port}");Перезберіть образ і запустіть ту саму команду docker run. Тепер у лозі Now listening on: http://0.0.0.0:8080, а curl повертає 404 для невідомого slug, і це правильна відповідь. Якщо запустити з -e PORT=5000 -p 8080:5000, теж працює, бо код іде за PORT, а не припускає його.
Чому б просто не видалити рядок
Section titled “Чому б просто не видалити рядок”Тут чесна частина. Якщо видалити app.Run("http://localhost:5000") і написати app.Run(), застосунок на Cloud Run теж запрацює. Починаючи з .NET 8, офіційні образи ASP.NET Core встановлюють ASPNETCORE_HTTP_PORTS=8080, і Kestrel слухає на всіх інтерфейсах на цьому порту, якщо в коді URL не задано. Порт за замовчуванням у Cloud Run теж 8080. Два значення за замовчуванням просто випадково збіглися.
Я все одно читаю PORT явно, з трьох причин:
- Порт налаштовується в сервісі. Порт контейнера є частиною визначення сервісу Cloud Run. Якщо ви чи колега встановите в інфраструктурі 9000, Cloud Run виставить
PORT=9000і надсилатиме трафік туди.ASPNETCORE_HTTP_PORTSпро цю зміну не знає; ваш код, що читаєPORT, знає. PORTобіцяє сам Cloud Run. Це частина задокументованого контракту.ASPNETCORE_HTTP_PORTSє властивістю базового образу, який ви заміните в модулі 2, і будь-якого образу, який хтось збере після вас.- Залежність стає видимою. Рядок, що читає
PORT, каже наступному читачеві: «цей застосунок очікує, що порт обере платформа». Відсутній рядок не каже нічого.
Те саме можна зробити через builder.WebHost.UseUrls(...) або змапивши PORT в ASPNETCORE_URLS у конфігурації. Підійде будь-який варіант; важливо мати одне явне джерело істини.
Додайте health endpoint
Section titled “Додайте health endpoint”Далі дайте сервісу дешевий endpoint, який відповідає на питання «чи цей процес живий і обслуговує HTTP». Його ви пишете самі, а не берете з бібліотеки, бо рішення про те, що означає «здоровий», теж є частиною навички. Поки що відповідь проста: якщо запит дійшов до хендлера, процес живий.
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));Не піддавайтеся спокусі перевіряти тут залежності. Health endpoint, який ходить у базу даних, перетворює повільну базу на «усі інстанси нездорові». Модуль 4 розділить його на /health/live і /health/ready та під’єднає до проб Cloud Run. Сьогодні /health лаба перевіряє першим після identity token.
Збираємо й пушимо без Dockerfile
Section titled “Збираємо й пушимо без Dockerfile”Модуль 2 присвячений написанню нормального Dockerfile, то навіщо пропускати його зараз? Бо Dockerfile це ще одна річ, яку треба вивчити й налагодити, а цей модуль про шлях деплою: реєстр, сервіс, ідентичність, URL. Підтримка контейнерів у SDK створює пристойний образ без жодного файлу: вона обирає базовий образ mcr.microsoft.com/dotnet/aspnet, що відповідає вашому target framework, публікує в нього застосунок і запускає його від non-root користувача app. У модулі 2 ви відкриєте цей образ і подивитеся, що саме було обрано за вас, зокрема ті частини, які захочеться змінити.
Образ потрапляє в Artifact Registry, реєстр Google для образів контейнерів і пакетів. Адреса образу має вигляд REGION-docker.pkg.dev/PROJECT_ID/REPOSITORY/IMAGE:TAG. У цьому курсі це europe-west1-docker.pkg.dev/PROJECT_ID/sniplink/api:TAG.
Задайте змінні один раз на весь урок:
export PROJECT_ID=your-course-project-idexport REGION=europe-west1Docker і .NET SDK потребують облікових даних, щоб пушити в Artifact Registry. gcloud може працювати як Docker credential helper для хоста реєстру, тож жодні паролі чи ключі не потрапляють у файли:
# Adds a credHelpers entry for the regional host to ~/.docker/config.jsongcloud auth configure-docker ${REGION}-docker.pkg.devКонтейнерний тулінг SDK читає той самий Docker config, тож теж використовує облікові дані gcloud. Щоб запушити, ви передаєте PublishContainer три властивості:
ContainerRegistry: хост реєстру,REGION-docker.pkg.dev.ContainerRepository: шлях під хостом,PROJECT_ID/sniplink/api.ContainerImageTag: тег. Я використовую номер модуля плюс короткий SHA git-коміту (m01-3f9c2ab), тож кожен образ вказує на код, з якого його зібрано, і з першого погляду видно, який модуль його зібрав.
TAG=m01-$(git rev-parse --short HEAD)
dotnet publish src/Sniplink.Api -c Release /t:PublishContainer \ -p:ContainerRegistry=${REGION}-docker.pkg.dev \ -p:ContainerRepository=${PROJECT_ID}/sniplink/api \ -p:ContainerImageTag=${TAG} \ -p:ContainerRuntimeIdentifier=linux-x64 # Cloud Run runs x86-64; matters on Apple siliconПоки що не запускайте це. Команда впаде, бо репозиторію sniplink ще не існує. Створити його і є першим завданням вашого інфраструктурного коду.
Інфраструктура як код з Pulumi
Section titled “Інфраструктура як код з Pulumi”Усе далі відбувається в Pulumi C# програмі в infra/. Ви вже залогінилися у свій GCS state backend у модулі 0. У стартовому проєкті ще немає Pulumi-проєкту, тож створіть його. pulumi new також створює stack dev, зашифрований KMS-ключем pulumi/state, який зробив bootstrap-скрипт:
mkdir infra && cd infrapulumi new csharp --name sniplink --stack dev \ --secrets-provider="gcpkms://projects/${PROJECT_ID}/locations/${REGION}/keyRings/pulumi/cryptoKeys/state"dotnet add package Pulumi.Gcpdev є єдиним stack-ом, який Sniplink використовує в цьому курсі: у модулях 1-5 ви деплоїте його зі свого ноутбука, а з модуля 6 той самий stack деплоїть Cloud Build. У команді ви додали б поруч stack prod; для одного студента й одного сервісу другий stack лише подвоїв би прибирання.
Проєкт і stack описують два невеликі файли. Pulumi.yaml задає ім’я проєкту; це ім’я також стає простором імен для ваших власних ключів конфігурації, тому налаштування образу називається sniplink:image.
name: sniplinkruntime: dotnetdescription: Sniplink on Google Cloud RunPulumi.dev.yaml містить налаштування stack dev. Перші два рядки записав pulumi new, і вони вказують на KMS-ключ з модуля 0. Ключі gcp: читає сам провайдер, тож кожен ресурс потрапляє у ваш проєкт і регіон без повторення цих значень.
secretsprovider: gcpkms://projects/PROJECT_ID/locations/europe-west1/keyRings/pulumi/cryptoKeys/state# … encryptedkey: written by pulumi new, leave as isconfig: gcp:project: PROJECT_ID gcp:region: europe-west1 # … sniplink:image is added after the first push, see belowЗадавайте значення через CLI, а не вручну, щоб друкарські помилки падали гучно:
pulumi config set gcp:project ${PROJECT_ID}pulumi config set gcp:region ${REGION}Тепер три ресурси, по одному.
Репозиторій в Artifact Registry
Section titled “Репозиторій в Artifact Registry”Репозиторій формату Docker у вашому регіоні. ID репозиторію sniplink стає частиною шляху кожного образу.
var repo = new Gcp.ArtifactRegistry.Repository("sniplink", new(){ RepositoryId = "sniplink", Location = region, Format = "DOCKER", Description = "Sniplink container images",});resource "google_artifact_registry_repository" "sniplink" { repository_id = "sniplink" location = var.region format = "DOCKER" description = "Sniplink container images"}Сервіс Cloud Run
Section titled “Сервіс Cloud Run”Сервіс Cloud Run v2 з іменем sniplink-api, що запускає ваш образ. Кілька значень заслуговують на окреме речення:
DeletionProtection = false: свіжі версії провайдера за замовчуванням захищають сервіси Cloud Run від видалення. У навчальному проєкті вам потрібно, щобpulumi destroyпрацював; у продакшені я залишив би захист увімкненим.Ingress = "INGRESS_TRAFFIC_ALL": приймати трафік з інтернету. Це значення за замовчуванням; явний запис робить вибір видимим на рев’ю.ContainerPort = 8080: порт, на який Cloud Run надсилає трафік і який записує вPORT. Змініть його тут, і код підлаштується, бо читаєPORT.- Сервіс-акаунт не задано, тож сервіс працює від імені default compute сервіс-акаунта проєкту. Це відома проблема, і її виправленню присвячено весь модуль 3.
var service = new Gcp.CloudRunV2.Service("sniplink-api", new(){ Name = "sniplink-api", Location = region, DeletionProtection = false, Ingress = "INGRESS_TRAFFIC_ALL", Template = new Gcp.CloudRunV2.Inputs.ServiceTemplateArgs { Containers = { new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerArgs { Image = image, Ports = new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerPortsArgs { ContainerPort = 8080, }, }, }, },});resource "google_cloud_run_v2_service" "sniplink_api" { name = "sniplink-api" location = var.region deletion_protection = false ingress = "INGRESS_TRAFFIC_ALL"
template { containers { image = var.image ports { container_port = 8080 } } }}Публічний доступ
Section titled “Публічний доступ”За замовчуванням сервіс Cloud Run автентифікований: кожен запит потребує Google identity token від принципала з roles/run.invoker, інакше отримує 403. Для внутрішніх сервісів це правильне значення за замовчуванням. Sniplink є публічним скорочувачем посилань, і платформа курсу має достукатися до нього без облікових даних, тож ви видаєте roles/run.invoker для allUsers, спеціального учасника, що означає «будь-хто в інтернеті».
Деякі організації це блокують. Якщо ваш проєкт живе під організацією компанії, політика domain restricted sharing (iam.allowedPolicyMemberDomains) зазвичай забороняє прив’язки allUsers, і pulumi up падає з помилкою політики. Це ще одна причина, чому модуль 0 радив окремий особистий проєкт лише для курсу. Не намагайтеся обійти політику компанії в проєкті компанії.
new Gcp.CloudRunV2.ServiceIamMember("sniplink-api-public", new(){ Name = service.Name, Location = service.Location, Role = "roles/run.invoker", Member = "allUsers",});resource "google_cloud_run_v2_service_iam_member" "public" { name = google_cloud_run_v2_service.sniplink_api.name location = google_cloud_run_v2_service.sniplink_api.location role = "roles/run.invoker" member = "allUsers"}Проблема порядку
Section titled “Проблема порядку”У цих трьох ресурсах захована проблема курки та яйця. Репозиторій має існувати до того, як ви запушите образ. Образ має існувати до того, як сервіс зможе стартувати, бо Cloud Run витягує його під час створення першої ревізії. А сам образ збирається поза Pulumi. Тож один pulumi up не може зробити все в правильному порядку.
Є кілька виходів:
pulumi up --targetспочатку з URN репозиторію, а після пушу повнийpulumi up. Це працює, але доводиться шукати URN, а часткові оновлення є звичкою, яку я б не хотів, щоб ви набули.- Збирати образ усередині Pulumi через Docker-провайдер. Це додає повільну локальну збірку до кожного
pulumi upі ховає збірку від CI-пайплайна, який ви напишете в модулі 6. - Зробити сервіс залежним від конфігурації. Створювати сервіс лише тоді, коли задано
sniplink:image. Першийpulumi upстворює репозиторій; ви пушите; задаєте образ; другийpulumi upстворює сервіс.
Я використовую третій. Кожен крок є звичайним повним pulumi up, програма описує реальну залежність («немає образу, немає сервісу») у коді, і та сама схема переноситься в CI, де пайплайн пушить образ, а потім передає посилання на нього в pulumi up як конфігурацію. Terraform-версія робить те саме через count.
Файл повністю
Section titled “Файл повністю”Ось повна програма. Вона читає конфігурацію stack, створює репозиторій, а сервіс разом із публічним доступом лише тоді, коли образ задано. Вона експортує шлях образу, куди пушити, і, щойно сервіс існує, його URL. Також вона передає контейнеру ваш ідентифікатор студента як LABKIT_STUDENT_ID; розділ про LabKit нижче пояснює, навіщо це і звідки береться значення.
using System.Collections.Generic;using Pulumi;using Gcp = Pulumi.Gcp;
return await Deployment.RunAsync(() =>{ var gcpConfig = new Config("gcp"); var project = gcpConfig.Require("project"); var region = gcpConfig.Require("region");
var config = new Config("sniplink"); var image = config.Get("image"); // null until the first image is pushed var studentId = config.Require("studentId");
var repo = new Gcp.ArtifactRegistry.Repository("sniplink", new() { RepositoryId = "sniplink", Location = region, Format = "DOCKER", Description = "Sniplink container images", });
var outputs = new Dictionary<string, object?> { ["imageRepository"] = Output.Format( $"{region}-docker.pkg.dev/{project}/{repo.RepositoryId}/api"), };
if (string.IsNullOrWhiteSpace(image)) { Log.Info("sniplink:image is not set: created the repository only. Push an image, set it, run pulumi up again."); return outputs; }
var service = new Gcp.CloudRunV2.Service("sniplink-api", new() { Name = "sniplink-api", Location = region, DeletionProtection = false, Ingress = "INGRESS_TRAFFIC_ALL", Template = new Gcp.CloudRunV2.Inputs.ServiceTemplateArgs { Containers = { new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerArgs { Image = image, Ports = new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerPortsArgs { ContainerPort = 8080, }, Envs = { new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerEnvArgs { Name = "LABKIT_STUDENT_ID", Value = studentId, }, }, }, }, }, }, new CustomResourceOptions { DependsOn = { repo } });
new Gcp.CloudRunV2.ServiceIamMember("sniplink-api-public", new() { Name = service.Name, Location = service.Location, Role = "roles/run.invoker", Member = "allUsers", });
outputs["url"] = service.Uri; return outputs;});terraform { required_providers { google = { source = "hashicorp/google" } }}
variable "project" { type = string }variable "region" { type = string }variable "image" { type = string default = "" # empty until the first image is pushed}variable "student_id" { type = string }
provider "google" { project = var.project region = var.region}
resource "google_artifact_registry_repository" "sniplink" { repository_id = "sniplink" location = var.region format = "DOCKER" description = "Sniplink container images"}
resource "google_cloud_run_v2_service" "sniplink_api" { count = var.image == "" ? 0 : 1 name = "sniplink-api" location = var.region deletion_protection = false ingress = "INGRESS_TRAFFIC_ALL"
template { containers { image = var.image ports { container_port = 8080 } env { name = "LABKIT_STUDENT_ID" value = var.student_id } } }
depends_on = [google_artifact_registry_repository.sniplink]}
resource "google_cloud_run_v2_service_iam_member" "public" { count = var.image == "" ? 0 : 1 name = google_cloud_run_v2_service.sniplink_api[0].name location = google_cloud_run_v2_service.sniplink_api[0].location role = "roles/run.invoker" member = "allUsers"}
output "image_repository" { value = "${var.region}-docker.pkg.dev/${var.project}/${google_artifact_registry_repository.sniplink.repository_id}/api"}
output "url" { value = var.image == "" ? null : google_cloud_run_v2_service.sniplink_api[0].uri}Вкладка Terraform тут для того, щоб ви могли прочитати той самий дизайн мовою інструменту, який вимагає більшість вакансій. Якщо захочете його запустити, тримайте його в окремій теці infra-tf/ поруч з infra/, з файлом terraform.tfvars, що задає project, region і student_id; Terraform-вкладки в наступних модулях розширюють файли в цій теці. Конфігурацію backend пропущено; курс працює на Pulumi-версії.
Додаємо LabKit і розбираємося з ID token
Section titled “Додаємо LabKit і розбираємося з ID token”Платформа курсу перевіряє вашу лабу, звертаючись до URL вашого сервісу. Щоб переконатися, що за URL стоїть справжній сервіс Cloud Run у вашому проєкті, а не копія чужих відповідей, їй потрібен доказ, який може створити лише Google. Ця обв’язка однакова для всіх студентів і не є тим, чого вчить модуль, тому вона живе в невеликому open-source пакеті CuriousDev.LabKit. Правило з модуля 0 діє: LabKit автоматизує обв’язку, але ніколи не саму навичку. Ваш /health endpoint, прив’язка до порту й інфраструктура залишаються вашими.
dotnet add src/Sniplink.Api package CuriousDev.LabKit --version "1.*"1.* бере найновіший реліз 1.x і ніколи не перескакує на нову major-версію, яка могла б змінити контракт.
Ось повний Program.cs після цього модуля:
using System.Collections.Concurrent;using CuriousDev.LabKit;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddLabKit(); // reads its env vars, registers evidence providers
var app = builder.Build();
// In-memory store on purpose. Persistence is the next course.var links = new ConcurrentDictionary<string, string>();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
// … POST /api/links, GET /{slug}, GET /api/links/{slug} from the starter, unchanged
app.MapLabKit(); // maps /_lab/* endpoints
// Cloud Run tells us which port to use. Locally, default to the same 8080.var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";app.Run($"http://0.0.0.0:{port}");
record CreateLink(string Url);MapLabKit додає кілька endpoint-ів під /_lab/. У цьому модулі важливий лише один: POST /_lab/verify. Платформа надсилає тіло з ID лаби та випадковим nonce. Тоді LabKit просить у metadata server (внутрішнього endpoint-а, доступного лише всередині обчислювальних середовищ Google Cloud) підписаний Google ID token, audience якого містить ваш ідентифікатор студента (нижче це STUDENT_ID; звідки він береться, показує наступний розділ):
GET http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity ?audience=https://learn.vladtimchenko.dev/s/STUDENT_ID&format=fullMetadata-Flavor: GoogleВін повертає токен разом з отриманим nonce, K_SERVICE, K_REVISION, вашим ідентифікатором студента в полі studentId, власною версією в полі labkitVersion і об’єктом evidence (у цьому модулі порожнім, у наступних заповненим). Audience дорівнює https://learn.vladtimchenko.dev/s/, за яким іде значення LABKIT_STUDENT_ID, тож ідентифікатор студента опиняється в тій частині, яку підписує Google. Якщо змінна порожня, LabKit бере просто https://learn.vladtimchenko.dev, і платформа відхиляє такий токен із підказкою її задати.
Цей endpoint працює лише в Cloud Run. Запустіть застосунок на ноутбуці, і POST /_lab/verify відповість 503 з кодом Problem Details not_on_cloud_run, бо питати немає в кого: metadata server відсутній. Так і має бути, це не баг.
Ваш ідентифікатор студента
Section titled “Ваш ідентифікатор студента”Токен доводить, що відповів сервіс Cloud Run у якомусь проєкті. Він не доводить, що проєкт ваш: URL run.app публічний, і будь-хто може вставити на сторінку лаби чужий URL. Тому платформі потрібен ще один факт, який може туди покласти лише власник проєкту. Цей факт є вашим ідентифікатором студента, заданим на сервісі як змінна середовища LABKIT_STUDENT_ID. Прочитати URL може будь-хто; змінити змінні середовища може лише той, хто може деплоїти у ваш проєкт.
Ваш ідентифікатор студента має вигляд cd_ і ще 10 символів. Увійдіть, і ви знайдете його у віджеті лаби внизу цієї сторінки та на своїй панелі за адресою learn.vladtimchenko.dev/uk/me/. Він не є секретом: він лише пов’язує деплой із вашим акаунтом, а ваш власний сервіс повертає його кожному, хто викликає /_lab/verify. Тож він іде у звичайний конфіг stack, закомічений разом з рештою Pulumi.dev.yaml. Замініть STUDENT_ID на свій ідентифікатор:
cd infrapulumi config set sniplink:studentId STUDENT_IDcd ..Програма вище читає його через config.Require("studentId"), тож stack без нього падає ще на preview, а не деплоїть сервіс, який не зможе пройти лабу, і передає його контейнеру. У Terraform це змінна student_id з terraform.tfvars.
Envs ={ new Gcp.CloudRunV2.Inputs.ServiceTemplateContainerEnvArgs { Name = "LABKIT_STUDENT_ID", Value = studentId, },},env { name = "LABKIT_STUDENT_ID" value = var.student_id}Платформа перевіряє ідентифікатор двічі. studentId у відповіді має дорівнювати вашому, а audience токена має закінчуватися вашим ідентифікатором. Друга частина важлива, бо /_lab/verify публічний: без неї хтось міг би переслати свіжий токен вашого сервісу через власний сервіс поруч зі своїм ідентифікатором. Коли ваш ідентифікатор є всередині підписаного audience, ваш сервіс може випускати лише токени, які зараховуються вам. Лише коли обидві частини збігаються, платформа прив’язує проєкт до вашого акаунта.
Що всередині токена
Section titled “Що всередині токена”ID token є JWT, тобто заголовок, JSON payload і підпис, зроблений приватним ключем Google. Payload для вашого сервісу виглядає приблизно так:
{ "iss": "https://accounts.google.com", "aud": "https://learn.vladtimchenko.dev/s/STUDENT_ID", "azp": "112233445566778899000", "sub": "112233445566778899000", "email": "123456789012-compute@developer.gserviceaccount.com", "email_verified": true, "iat": 1773052800, "exp": 1773056400}issі підпис: токен видав Google. Платформа перевіряє підпис за публічними сертифікатами Google.aud: для кого призначено токен (для платформи курсу, а в ній для вашого ідентифікатора студента). Приймати його має лише платформа, і лише для вас.emailіsub: сервіс-акаунт, від імені якого працює код. Зараз це default compute акаунт, чий email починається з номера проєкту, тож проєкт ідентифікується без того, щоб ви щось вводили.iatіexp: коли токен видано і коли він спливає (через годину). Платформа відхиляє токени, старші за п’ять хвилин.
З format=full деякі середовища додають додаткові claims про інстанс. LabKit передає їх далі, але платформа від них не залежить.
Чому це доводить, що ваш сервіс справжній
Section titled “Чому це доводить, що ваш сервіс справжній”На ноутбуці цей токен не отримати. Без ключа сервіс-акаунта (а цей курс ніколи його не створює) єдиний спосіб отримати підписаний Google токен для сервіс-акаунта вашого проєкту полягає в тому, щоб бути кодом, що працює від імені цього акаунта на інфраструктурі Google, або принципалом із конкретним IAM-дозволом випускати токени для нього. Додайте решту перевірки: платформа сама отримала відповідь, наживо, з URL *.run.app, протягом останніх кількох секунд, з nonce, який щойно згенерувала. Цей ланцюжок означає «щойно відповів сервіс Cloud Run у цьому проєкті».
Щоб бути точним щодо меж: токен доводить, який сервіс-акаунт і проєкт відповіли, а URL доводить, що це був Cloud Run. Він не доводить, який код в образі. Саме тому наступні лаби додають перевірки поведінки, а не лише ідентичності. Щойно ваш ідентифікатор студента збігся, платформа також прив’язує проєкт до вашого акаунта під час першої успішної перевірки, тож один проєкт не може зарахуватися двом студентам: проєкт, уже прив’язаний до іншого акаунта, не проходить перевірку токена.
Чому він не розкриває нічого чутливого
Section titled “Чому він не розкриває нічого чутливого”Токен містить email сервіс-акаунта, числовий ID і таймстемпи. Нічого з цього не є секретом; номер проєкту і так видно у вашому публічному URL. Токен не можна використати для викликів Google API, які вимагають OAuth access tokens, а не ID tokens. Його audience складається з платформи курсу та вашого ідентифікатора студента, тож будь-який інший сервіс, що коректно перевіряє aud, його відхилить, платформа прийме його лише для вас, а спливає він протягом години. LabKit ніколи не повертає змінні середовища, конфігурацію чи секрети з жодного endpoint-а. Це правило діє на весь курс.
Деплой
Section titled “Деплой”Тепер складаємо все разом. З кореня репозиторію:
-
Створіть репозиторій. Коли образ не задано, Pulumi створює лише Artifact Registry.
Terminal window cd infrapulumi upcd .. -
Зберіть і запуште образ з виправленим
Program.csі LabKit.Terminal window TAG=m01-$(git rev-parse --short HEAD)dotnet publish src/Sniplink.Api -c Release /t:PublishContainer \-p:ContainerRegistry=${REGION}-docker.pkg.dev \-p:ContainerRepository=${PROJECT_ID}/sniplink/api \-p:ContainerImageTag=${TAG} \-p:ContainerRuntimeIdentifier=linux-x64 -
Вкажіть stack на образ і задеплойте сервіс.
Terminal window cd infrapulumi config set sniplink:image ${REGION}-docker.pkg.dev/${PROJECT_ID}/sniplink/api:${TAG}pulumi up -
Спробуйте.
Terminal window URL=$(pulumi stack output url)curl -i ${URL}/healthcurl -i -X POST ${URL}/api/links -H "Content-Type: application/json" -d '{"url":"https://example.com"}'curl -i ${URL}/SLUG_FROM_PREVIOUS_RESPONSE
Для кожної наступної зміни цикл складається з кроків 2 і 3: новий коміт, новий тег, pulumi config set, pulumi up. Комітьте Pulumi.dev.yaml разом з кодом; тег образу в ньому тепер частина вашої історії.
Читаємо логи
Section titled “Читаємо логи”Коли ревізія падає, відповідь майже завжди в логах. Cloud Run надсилає все, що ваш застосунок пише в stdout і stderr, у Cloud Logging разом із повідомленнями платформи на кшталт того, що в інциденті.
З термінала:
gcloud run services logs read sniplink-api --region ${REGION} --limit 50У консолі відкрийте Logging > Logs Explorer і використайте цей запит. Читати консоль можна; правило в тому, що ви ніколи нічого там не змінюєте.
resource.type="cloud_run_revision"resource.labels.service_name="sniplink-api"Спробуйте один раз навмисно: поверніть підкладений рядок у гілці, зберіть з новим тегом, pulumi up і прочитайте лог. Ви побачите Now listening on: http://localhost:5000, а за ним помилку старту з інциденту. Pulumi повідомить про невдалу ревізію, а попередня здорова ревізія продовжить обслуговувати трафік, бо Cloud Run переводить трафік лише на ревізію, яка стартувала. Потім відкотіть зміну й задеплойте знову.
Мета: Sniplink працює на Cloud Run у вашому навчальному проєкті, розгорнутий з вашої Pulumi-програми, і відповідає платформі ззовні.
Program.csчитаєPORTі слухає на0.0.0.0; підкладеного рядка більше немає.GET /healthповертає 200, і написали його ви.- LabKit додано через
AddLabKitіMapLabKit. - На сервісі задано
LABKIT_STUDENT_IDз вашим ідентифікатором студента, взятим із конфігу stacksniplink:studentId. - Образ зібрано через
dotnet publish /t:PublishContainerі збережено в репозиторії Artifact Registrysniplink. - Репозиторій, сервіс
sniplink-apiі публічна invoker-прив’язка створені черезpulumi up, а не в консолі. - Вставте нижче output
urlвашого stack.
Перша перевірка може тривати приблизно до 20 секунд. Коли сервіс ніхто не викликає, він масштабується до нуля, тож перший запит платформи зазвичай запускає новий інстанс (холодний старт). Платформа на нього чекає; прогрівати сервіс чи натискати «Перевірити» двічі не потрібно.
Перевірте лабу
Що роблять перевірки:
- token: викликає
POST /_lab/verifyзі свіжим nonce, потім перевіряє підпис ID token, audience (він має закінчуватися вашим ідентифікатором студента), вік, повернений nonce і поверненийstudentId. Лише коли ідентифікатор студента збігається, ваш проєкт прив’язується до вашого акаунта; проєкт, уже прив’язаний до іншого акаунта, на цьому кроці не проходить. - health:
GET /healthмає повернути 200. - create-link і redirect: платформа створює посилання через
POST /api/links, потім запитуєGET /плюс повернений slug, не переходячи за редіректами, і очікує 302 саме на той URL, який надіслала. Це поведінка вашого застосунку, перевірена end to end. - service-account: токен містить
emailсервіс-акаунта. Модуль 3 посилить цю перевірку, щоб відхиляти default акаунт.
Жодна перевірка в цій лабі не є самозвітною: кожна або підписана Google, або спостерігається платформою через HTTP. Лаба не перевіряє, що ваша інфраструктура описана в Pulumi; це на вашій совісті, а рівень Verified перевірить це пізніше.
Прибирання
Section titled “Прибирання”Залиште stack. Модуль 2 збирає кращий образ для того самого репозиторію й деплоїть його в той самий сервіс, тож розбирати нічого не треба. Cloud Run масштабується до нуля в простої, а кілька невеликих образів в Artifact Registry коштують щонайбільше кілька центів на місяць.
Якщо ставите курс на паузу надовго, знищте все й потім створіть заново тим самим двокроковим деплоєм:
cd infrapulumi destroyЦе видаляє сервіс, публічну прив’язку й репозиторій з усіма образами. State bucket і KMS-ключ з модуля 0 залишаються.
Що ви вивчили
Section titled “Що ви вивчили”- Контракт контейнера Cloud Run: слухати на
0.0.0.0:$PORT, не мати стану, швидко стартувати, оброблятиSIGTERM, і чомуlocalhostусередині контейнера є пасткою. - Чому явне читання
PORTкраще, ніж покладатися наASPNETCORE_HTTP_PORTS, хоча обидва за замовчуванням дають 8080. - Як зібрати й запушити образ без Dockerfile за допомогою
dotnet publish /t:PublishContainerіgcloudяк credential helper. - Як описати репозиторій Artifact Registry, сервіс Cloud Run v2 і публічний доступ у Pulumi C#, і як упорядкувати деплой, коли образ збирається поза Pulumi.
- Що містить Google ID token, чому він доводить, де працює ваш код, нічого чутливого не розкриваючи, і чому ваш ідентифікатор студента в
LABKIT_STUDENT_IDдоводить, що проєкт ваш.