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

Ви увійшли як

Вхід у Curious Dev Learn

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

Модуль 022 годЛаба

Контейнер, який не соромно відправити в прод

Замінюємо стандартний образ на маленький multi-stage build без root і без shell та пушимо його в Artifact Registry.

Нічого не впало. Сервіс відповідає, latency в нормі, але команда безпеки щойно вручила власникам 88 знахідок, які до п’ятниці треба або пропатчити, або обґрунтувати. Майже жодна з них не стосується коду, який вони написали. Усі вони в Perl, Git, SSH-клієнті та Python, які потрапили в прод, бо Dockerfile починався з FROM образу .NET SDK і запускав усередині dotnet run. Опублікований застосунок важить 4 MB. Образ навколо нього важить 917 MB, працює від root і містить shell, якому атакувальник дуже зрадів би.

Цей модуль відповідає на одне питання: що має бути в продакшн-контейнері .NET і як переконатися, що там немає нічого зайвого? Ви подивитеся на образ, який задеплоїли в модулі 1, напишете multi-stage Dockerfile, що збирає маленький образ без root і без shell, запушите його в Artifact Registry і перемкнете на нього сервіс Cloud Run через Pulumi.

Зазирніть в образ, який у вас уже є

Section titled “Зазирніть в образ, який у вас уже є”

У модулі 1 ви зібрали образ через dotnet publish /t:PublishContainer і дозволили SDK ухвалити всі рішення. Перш ніж його замінювати, покажу, якими були ці рішення. Задайте змінні, які знадобляться до кінця уроку:

Terminal window
export PROJECT_ID=your-project-id
export REGION=europe-west1
export IMAGE=$REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api

Образ із модуля 1 пішов напряму з SDK в Artifact Registry, тож у локальному кеші Docker його ще немає. Його повне посилання з тегом m01-<sha> лежить у конфігу stack, тому прочитайте його звідти, а не набирайте вручну:

Terminal window
# the image the service runs right now, e.g. …/sniplink/api:m01-3f9c2ab
export M01_IMAGE=$(cd infra && pulumi config get sniplink:image)
# the SDK pushed this image directly; pull it so Docker can inspect it
docker pull $M01_IMAGE

Тепер спитайте Docker, що всередині:

Terminal window
# size in bytes, the user the process runs as, and the base OS
docker image inspect $M01_IMAGE --format '{{.Size}} bytes, user={{.Config.User}}'
docker image inspect $M01_IMAGE --format '{{json .Config.Env}}'
docker history $M01_IMAGE

Ви маєте побачити приблизно таке:

  • Розмір: трохи більше 200 мегабайт.
  • User: app (або його числовий ID). Починаючи з .NET 8, контейнерний тулінг SDK за замовчуванням запускає застосунок від non-root користувача з базового образу.
  • Env містить APP_UID=1654, ASPNETCORE_HTTP_PORTS=8080 і DOTNET_RUNNING_IN_CONTAINER=true. Вони приходять із базового образу Microsoft, а не з вашого проєкту.
  • History: з десяток шарів базового образу (показані як <missing>, для спуленого образу це нормально) і один шар зверху з вашим опублікованим застосунком.

SDK обрав базою mcr.microsoft.com/dotnet/aspnet:10.0, а для .NET 10 це Ubuntu 24.04 «noble». Це повноцінний дистрибутив: там є bash, apt, dpkg, ICU, дані часових поясів і ще кілька сотень файлів, яких ваш API ніколи не торкається. Перевірте:

Terminal window
# the full image has a shell, so this works
docker run --rm --entrypoint sh $M01_IMAGE -c 'id && ls /usr/bin | wc -l'

Тобто образ із модуля 1 значно кращий за той, що в логу інциденту: він не побудований на SDK і не працює від root. Але кожен бінарник у /usr/bin є пакетом, про який звітуватиме сканер, файлом, за патчинг якого відповідаєте ви, і інструментом, доступний будь-кому, хто отримає виконання коду у вашому контейнері.

Single-stage Dockerfile з інциденту виглядав приблизно так: FROM sdk, скопіювати все, dotnet run. Образ SDK мусить містити компілятори, NuGet, MSBuild і їхні залежності, тому він важить майже гігабайт. Усе це потрібно, щоб зібрати застосунок, і нічого з цього не потрібно, щоб його запустити.

Multi-stage build використовує один образ для збірки й інший, значно менший, для запуску. У фінальний образ потрапляє лише те, що ви явно скопіювали через COPY --from зі stage збірки. Покладіть це в корінь репозиторію, поруч із Sniplink.sln:

Dockerfile
# syntax=docker/dockerfile:1
# ---- build stage: full SDK, runs on the machine doing the build ----
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
WORKDIR /src
# 1. Copy only the project file and restore. This layer is cached
# until the .csproj changes, so code edits don't re-download packages.
COPY src/Sniplink.Api/Sniplink.Api.csproj src/Sniplink.Api/
RUN dotnet restore src/Sniplink.Api/Sniplink.Api.csproj -a $TARGETARCH
# 2. Copy the source and publish without restoring again.
COPY src/Sniplink.Api/ src/Sniplink.Api/
RUN dotnet publish src/Sniplink.Api/Sniplink.Api.csproj \
-a $TARGETARCH \
--configuration Release \
--no-restore \
--output /app/publish \
/p:UseAppHost=false
# ---- runtime stage: chiseled ASP.NET Core runtime, no shell ----
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app/publish .
# Run as the non-root 'app' user defined by the base image (UID 1654).
USER $APP_UID
# Documentation only: the port ASP.NET Core and Cloud Run agree on.
EXPOSE 8080
ENTRYPOINT ["dotnet", "Sniplink.Api.dll"]

Кожен рядок тут не просто так:

  • --platform=$BUILDPLATFORM і -a $TARGETARCH. Cloud Run працює на linux/amd64. Якщо у вас Mac на Apple silicon, звичайний docker build збере arm64-образ, який Cloud Run не зможе запустити. Ці два рядки запускають SDK нативно на вашій машині й крос-компілюють застосунок під цільову архітектуру. Це набагато швидше, ніж емулювати весь SDK. Обидві змінні підставляє Docker; .NET CLI приймає amd64 і arm64 як назви архітектур.
  • Спочатку копіюємо .csproj, потім restore. Docker кешує кожен шар і перевикористовує його, доки не зміняться файли, з яких він зібраний. Посилання на пакети змінюються рідко, а код постійно. Якщо розділити ці кроки, правка коду перезапускає publish, але не restore. Якщо в корені репозиторію є Directory.Build.props, Directory.Packages.props або nuget.config, скопіюйте їх теж до restore, інакше restore їх не побачить.
  • --no-restore. Publish бере результат restore із закешованого шару, а не робить його повторно.
  • /p:UseAppHost=false. Пропускає нативний виконуваний лаунчер; ми запускаємо застосунок через dotnet Sniplink.Api.dll, тож лаунчер був би мертвим вантажем.
  • USER $APP_UID. Про це йдеться в наступному розділі.
  • EXPOSE 8080. Нічого не відкриває. Лише документує, що образ слухає 8080, а це збігається і з ASPNETCORE_HTTP_PORTS у базовому образі, і з дефолтним PORT у Cloud Run. Ваш код із модуля 1 і далі читає PORT і має пріоритет, якщо Cloud Run передасть щось інше.
  • ENTRYPOINT в exec-формі. Форма JSON-масиву запускає dotnet як PID 1 без shell-обгортки, тож він отримує SIGTERM напряму. Це важливо для коректного (graceful) завершення в модулі 4. Shell-форма тут і так не спрацювала б: shell немає.

Зверніть увагу: у фінальному stage немає жодної інструкції RUN. І бути не може: RUN потребує shell, а в chiseled-образі його немає. Усе, що треба підготувати, робиться у stage збірки й копіюється.

Сімейства образів і чому chiseled

Section titled “Сімейства образів і чому chiseled”

Microsoft публікує runtime-образи .NET у кількох сімействах. Вони відрізняються тим, які файли операційної системи лежать під вашим застосунком.

Сімейство Приклад тегу Що всередині Користувач за замовчуванням Коли я його використовую
Full aspnet:10.0, aspnet:10.0-noble Повний userland Ubuntu: bash, apt, ICU, tzdata, CA-сертифікати root (користувач app існує, вмикаєте його самі) Застосунки, яким треба доставити OS-пакети через apt; локальний дебаг
Alpine aspnet:10.0-alpine Alpine Linux із musl libc, shell BusyBox, apk; invariant globalization за замовчуванням root Маленькі образи, коли shell все ж потрібен; стежте за відмінностями musl у нативних бібліотеках
Chiseled aspnet:10.0-noble-chiseled Лише файли, потрібні runtime .NET: libc, OpenSSL, CA-сертифікати. Без shell, без пакетного менеджера, без ICU, без tzdata app (UID 1654) Дефолт для API на ASP.NET Core. Саме його використовує цей курс
Chiseled extra aspnet:10.0-noble-chiseled-extra Chiseled плюс ICU і tzdata app (UID 1654) Застосунки, яким потрібне форматування з урахуванням культури або іменовані часові пояси
Runtime deps runtime-deps:10.0-noble-chiseled Chiseled OS без runtime .NET app (UID 1654) Self-contained або Native AOT застосунки, які несуть runtime із собою

Орієнтовно chiseled-образ ASP.NET Core удвічі менший за full, а chiseled runtime-deps ще на порядок менший. Але розмір важить менше, ніж кількість: у chiseled-образі лише кілька пакетів, тож і сканер має лише кілька пунктів для звіту.

Назва «chiseled» походить від інструмента chisel від Canonical, який встановлює зрізи (slices) пакетів Ubuntu (лише ті файли, що потрібні конкретному навантаженню) замість цілих пакетів. Ви отримуєте оновлення безпеки Ubuntu для цих файлів без решти дистрибутива.

Кожен образ .NET, починаючи з .NET 8, визначає користувача app з UID 1654 і експортує це число як змінну середовища APP_UID. Full та Alpine образи заради сумісності досі за замовчуванням працюють від root; chiseled-образи вже за замовчуванням працюють від app.

Я все одно пишу USER $APP_UID, хоча chiseled-база вже це задає. Властивість безпеки мого образу має бути видно в моєму Dockerfile, а не мовчки успадковуватися з чужого. Якщо колега пізніше замінить базу на full-образ, щоб щось подебажити, користувач лишиться non-root. А числовий ID замість імені дозволяє перевіркам runAsNonRoot у стилі Kubernetes верифікувати його без читання /etc/passwd.

Що дає non-root: опубліковані файли в /app належать root і доступні app лише на читання, тож скомпрометований процес не зможе переписати власні бінарники. Він не може слухати порти нижче 1024, саме тому в .NET 8 дефолтний порт змінили з 80 на 8080. А вихід із контейнера (container escape) починається з непривілейованого акаунта.

Глобалізація і коли потрібен -extra

Section titled “Глобалізація і коли потрібен -extra”

У звичайному chiseled-образі немає ICU, бібліотеки, яку .NET використовує для форматування, сортування та зміни регістру з урахуванням культури. Без ICU .NET мусить працювати в globalization-invariant mode: кожна культура поводиться як invariant culture. Chiseled-база задає DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true, щоб runtime не падав на старті, шукаючи ICU.

І знову я фіксую це рішення в проєкті, а не покладаюся на базовий образ:

src/Sniplink.Api/Sniplink.Api.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- No ICU in the chiseled image: make that a build-time decision. -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<!-- … package references unchanged … -->
</Project>

Так ваші тести й локальні запуски поводяться так само, як прод, чого змінна середовища в базовому образі не гарантує. Sniplink зберігає URL і випадкові slug-и, тож нічого не втрачає.

-extra (і InvariantGlobalization у значенні false) потрібен, коли застосунок:

  • форматує дати, числа чи валюти для конкретних культур або сортує текст за правилами мови;
  • конвертує час через TimeZoneInfo.FindSystemTimeZoneById("Europe/Kyiv"); без tzdata існує лише UTC;
  • залежить від бібліотеки, якій потрібен ICU (деякі драйвери баз даних історично відмовлялися працювати в invariant mode).

Якщо сумніваєтеся, проженіть тести з увімкненим InvariantGlobalization. Падіння все покажуть.

Тепер зберіть образ локально й порівняйте з модулем 1:

Terminal window
docker build -t sniplink-api:local .
docker images | grep -E 'sniplink|api'

Першого разу, коли ви спробуєте docker exec -it <container> bash на chiseled-образі, отримаєте executable file not found in $PATH. Саме так ця фіча й працює. Але дебажити все одно треба, тож ось що я використовую натомість, від найчастішого до найрідшого.

Логи й сам застосунок. Більшість продакшн-питань закривають структуровані логи та endpoint-и, які ви контролюєте. До того ж у Cloud Run немає exec в інстанс незалежно від базового образу, тож ваш план дебагу й так має обходитися без shell. /_lab/info у LabKit є невеликим прикладом цього патерну: процес сам повідомляє факти про себе.

Terminal window
gcloud run services logs read sniplink-api --region $REGION --limit 50

Debug-контейнер поруч із робочим. Локально можна запустити одноразовий контейнер, який ділить із контейнером застосунку простори імен процесів і мережі. Він приносить власний shell та інструменти, а ваш продакшн-образ лишається недоторканим.

Terminal window
docker run -d --name sniplink -p 8080:8080 sniplink-api:local
# busybox brings the shell; --pid and --network join the app's namespaces
docker run --rm -it --pid=container:sniplink --network=container:sniplink busybox sh
# inside: ps, wget -qO- localhost:8080/health, ls /proc/1/root/app

Файлова система застосунку видна в /proc/1/root. Docker Desktop також пропонує docker debug, який робить те саме з багатшим набором інструментів; з Docker Desktop 4.49 він безкоштовний для всіх користувачів.

Debug-таргет збірки. Коли shell справді потрібен усередині тієї самої структури образу, додайте stage, що бере full-образ і той самий опублікований вивід, і збирайте його лише на вимогу:

Dockerfile (optional extra stage)
# docker build --target debug -t sniplink-api:debug .
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS debug
WORKDIR /app
COPY --from=build /app/publish .
USER $APP_UID
ENTRYPOINT ["dotnet", "Sniplink.Api.dll"]

Розмістіть його перед stage final. Звичайний docker build збирає останній stage у файлі, тож прод лишається chiseled. Ніколи не пуште debug-тег у registry, з якого деплоїться ваш сервіс.

Chiseled-контейнер без root підштовхує ставитися до файлової системи як до read-only. Користувач app не може писати в /app. Файлова система Cloud Run доступна на запис, але вона живе в пам’яті, рахується в ліміт пам’яті інстансу й зникає разом з інстансом. Усе важливе має жити в окремому сервісі (про персистентність буде в наступному курсі), а все тимчасове в /tmp.

Перевірити, що Sniplink не залежить від файлової системи з правом запису, можна локально:

Terminal window
docker rm -f sniplink
docker run --rm -p 8080:8080 --read-only --tmpfs /tmp sniplink-api:local

Якщо він стартує і /health повертає 200, застосунок не робить прихованих записів поза /tmp.

Образ вище є framework-dependent: runtime .NET приходить із базового образу, а у виводі publish лише ваші збірки. Є три способи піти далі. Жоден не обов’язковий для лаби, і для більшості API я б з них не починав.

Self-contained. Публікуєте з --self-contained, і runtime їде всередині вашого виводу. Базою тоді стає runtime-deps. Загальний розмір приблизно той самий, бо runtime просто переїхав з одного шару в інший. Виграєте ви незалежність від версії спільного runtime; втрачаєте спільні базові шари між сервісами та звичку отримувати патчі runtime простою перезбіркою на новішому базовому тегу.

Trimming. PublishTrimmed=true вирізає невикористаний код із застосунку та runtime. Працює лише для self-contained застосунків і спирається на статичний аналіз: усе, до чого доходять тільки через reflection, може бути вирізане й упасти в рантаймі. Тример попереджає (IL2026 і компанія), і ці попередження аж ніяк не є необов’язковим читанням.

Native AOT. PublishAot=true компілює застосунок в один нативний виконуваний файл без JIT. Для веб-API на Cloud Run це найцікавіший варіант: він скорочує час старту й споживання пам’яті, а Cloud Run запускає інстанси на вимогу. Але ціна реальна:

  • ASP.NET Core підтримує AOT для minimal API, але не для MVC-контролерів чи Razor. Ви використовуєте WebApplication.CreateSlimBuilder і source-generated JSON (JsonSerializerContext) для кожного типу, який серіалізуєте.
  • Бібліотеки, що покладаються на runtime reflection або генерацію коду, можуть не працювати або потребувати налаштування; перевірте кожну залежність, перш ніж братися.
  • Збірці потрібен нативний тулчейн (clang і компанія) у stage збірки, і крос-компіляція між операційними системами неможлива. Microsoft публікує образи SDK, де цей тулчейн уже встановлено, наприклад mcr.microsoft.com/dotnet/sdk:10.0-noble-aot.
  • Збірка помітно сповільнюється.

Моє правило: починайте з framework-dependent на chiseled. Поміряйте холодні старти в Cloud Run (модуль 4 показує як). Якщо для вашого патерну трафіку вони критичні, а залежності сумісні з AOT, переходьте на Native AOT на runtime-deps:10.0-noble-chiseled. Інакше MinInstanceCount = 1 часто виявляється дешевшим рішенням з погляду інженерного часу, хоч і не грошей.

Тримайте build context маленьким

Section titled “Тримайте build context маленьким”

Коли ви запускаєте docker build ., Docker спершу відправляє збирачу всю директорію, тобто build context. Без .dockerignore туди потрапляють bin/ і obj/ з вашої машини, тека .git і файли stack Pulumi. Це повільно, а теки obj/ ще й відверто шкодять: у них результати restore під вашу ОС, які можуть заплутати dotnet publish всередині Linux. Додайте в корінь репозиторію:

.dockerignore
# build output and IDE state from the host
**/bin/
**/obj/
**/.vs/
**/.vscode/
**/.idea/
**/*.user
**/TestResults/
# not needed to build the image
.git/
.github/
infra/
tests/
**/*.md
# never send these to a builder
**/.env
**/*.pfx
**/secrets/
# the build files themselves
Dockerfile*
.dockerignore

infra/ варто окремої примітки: починаючи з модуля 3, файли stack Pulumi міститимуть зашифровані секрети. Так, вони зашифровані, але в збірці образу їм однаково нічого робити. Тести виключені, бо запускаються поза образом (у модулі 6 Cloud Build запускає dotnet test окремим кроком).

Перезберіть і подивіться на перші рядки виводу; передача контексту тепер має вимірюватися кілобайтами:

Terminal window
docker build --progress=plain -t sniplink-api:local . 2>&1 | grep -i 'transferring context'

Вам потрібен тег, який змінюється з кожною збіркою. Pulumi створює нову ревізію Cloud Run лише тоді, коли змінюється конфігурація, тож якщо перевикористовувати тег на кшталт latest, pulumi up повідомить, що змін немає, і нічого не задеплоїться. Я беру назву модуля плюс короткий хеш коміту:

Terminal window
export TAG=m02-$(git rev-parse --short HEAD)
Terminal window
# Artifact Registry credentials for Docker (already done in module 1)
gcloud auth configure-docker $REGION-docker.pkg.dev
# build for Cloud Run's architecture, whatever your laptop is
docker build --platform linux/amd64 -t $IMAGE:$TAG .
# smoke test locally before pushing
docker run --rm -d --name sniplink -p 8080:8080 -e PORT=8080 $IMAGE:$TAG
curl -s localhost:8080/health
curl -s localhost:8080/_lab/info
docker rm -f sniplink
docker push $IMAGE:$TAG

Локально /_lab/info показує порожні service і revision, бо середовища Cloud Run немає, але user.uid, shellPresent, invariantGlobalization і dotnet уже справжні. Перевірте їх зараз: це найдешевше місце, щоб знайти помилку.

Деплойте краще за digest, а не за тегом. Тег можна перевісити на інший образ, а digest ні. Cloud Run і так резолвить тег у digest під час створення ревізії, але digest у конфігу робить так, що історія git точно каже, що саме працювало:

Terminal window
# after the push, Docker knows the registry digest
export IMAGE_REF=$(docker inspect --format '{{index .RepoDigests 0}}' $IMAGE:$TAG)
echo $IMAGE_REF # europe-west1-docker.pkg.dev/…/sniplink/api@sha256:…

З Cloud Build скопіюйте digest із кінця виводу збірки.

Перемикаємо Cloud Run на новий образ

Section titled “Перемикаємо Cloud Run на новий образ”

Код інфраструктури не змінюється: ще в модулі 1 сервіс Cloud Run читає образ із конфігу stack. Ви змінюєте лише значення.

Terminal window
cd infra
pulumi config set sniplink:image $IMAGE_REF
pulumi preview # expect one update: the service's container image
pulumi up

Preview має показати одне in-place оновлення сервісу Cloud Run. Якщо він також хоче замінити репозиторій Artifact Registry чи IAM-прив’язку, зупиніться й прочитайте diff: щось іще дрейфонуло.

Коли оновлення завершиться, викличте сервіс:

Terminal window
export URL=$(pulumi stack output url)
curl -s $URL/health
curl -s $URL/_lab/info

Ви маєте побачити user.uid 1654, shellPresent false, invariantGlobalization true, версію dotnet, що починається з 10., і нову revision.

Сканування вразливостей в Artifact Registry

Section titled “Сканування вразливостей в Artifact Registry”

У меншому образі менше знахідок, але «менше» не означає «нуль», а нові CVE з’являються в пакетах уже після релізу. Artifact Registry вміє автоматично сканувати образи через Artifact Analysis: увімкніть Container Scanning API, і кожен образ, запушений у репозиторій, скануватиметься на відомі вразливості в OS-пакетах і в пакетах мов програмування, зокрема в NuGet-пакетах .NET-застосунку. Після першого сканування результати оновлюються з появою нових вразливостей для образів, які завантажували (pull) протягом останніх 30 днів; після цього вони застарівають.

Сканування тарифікується за кожен просканований образ, тож перевірте актуальні ціни, перш ніж вмикати його в проєкті, який пушить на кожен коміт. За правилом курсу, увімкнення API теж є інфраструктурою, тож воно йде в код:

infra/Program.cs (optional)
// … inside the stack, next to the other resources
// Optional: on-push vulnerability scanning for Artifact Registry. Billed per image.
var containerScanning = new Gcp.Projects.Service("container-scanning", new()
{
ServiceName = "containerscanning.googleapis.com",
DisableOnDestroy = false, // don't switch the API off for the whole project on destroy
});

Потім запуште образ (сканування застосовується до push-ів після увімкнення API) і прочитайте результати з CLI:

Terminal window
gcloud artifacts docker images describe $IMAGE_REF --show-package-vulnerability

Якщо запушити образ із модуля 1 і образ із модуля 2 в репозиторій з увімкненим скануванням, різниця в кількості знахідок буде найпереконливішим аргументом на користь цього модуля, який я знаю.

Мета: Sniplink працює в Cloud Run з вашого власного multi-stage образу на aspnet:10.0-noble-chiseled, від non-root користувача, з invariant globalization, задекларованою в проєкті.

  1. Додайте Dockerfile і .dockerignore з цього уроку в корінь репозиторію.
  2. Пропишіть <InvariantGlobalization>true</InvariantGlobalization> у Sniplink.Api.csproj.
  3. Зберіть образ через docker build (або gcloud builds submit) і запуште його в $REGION-docker.pkg.dev/$PROJECT_ID/sniplink/api з унікальним тегом.
  4. Встановіть sniplink:image на новий образ (краще digest) і запустіть pulumi up.
  5. Самі перевірте $URL/_lab/info, а потім надішліть URL свого сервісу нижче.

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

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

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

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

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

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

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

  • Токен (базова): POST /_lab/verify повертає валідний Google ID token для audience платформи з вашим ідентифікатором студента, з вашим nonce, від справжнього сервісу Cloud Run у вашому проєкті.
  • Health: GET /health повертає 200, тобто новий образ стартує й обслуговує запити.
  • Non-root: user.uid у /_lab/info не дорівнює 0.
  • Без shell: shellPresent дорівнює false.
  • Invariant globalization: invariantGlobalization дорівнює true.
  • Runtime: dotnet починається з 10..

Останні чотири перевірки самозвітні: працюючий процес сам повідомляє платформі про себе, а сам образ платформа не бачить. Наполегливий студент може пропатчити LabKit, щоб той казав що завгодно, і навіть чесний LabKit бачить лише працюючий процес, а не розмір чи походження образу. Для навчального сертифіката це прийнятно, і я краще скажу вам про це прямо, ніж удаватиму, що це не так. Опційний рівень Verified, який з’явиться пізніше, перевірятиме суворо: отримавши від вас доступ на читання, платформа прочитає маніфест образу з Artifact Registry, порівняє базові шари з опублікованим chiseled-образом, перевірить налаштованого користувача й розмір образу та підтвердить, що ревізія Cloud Run запускає саме цей digest.

Stack не видаляйте: у модулі 3 до того самого сервісу додаються секрет і сервісна ідентичність.

Образ із модуля 1 більше не використовується. Сховище Artifact Registry коштує копійки, але тримати його немає сенсу. M01_IMAGE це змінна, яку ви експортували на початку уроку; sniplink:image тепер вказує на образ із модуля 2, тож не читайте його знову з конфігу:

Terminal window
gcloud artifacts docker images delete $M01_IMAGE --delete-tags --quiet
docker image prune -f

Якщо робите в курсі тривалу паузу, краще знищіть stack. Знищення видаляє репозиторій і його образи, тож коли повернетеся, запустіть pulumi up (він заново створить репозиторій; сервіс падатиме, доки образу немає), знову запуште образ командами з модуля 2, встановіть sniplink:image на новий digest і ще раз запустіть pulumi up:

Terminal window
cd infra
pulumi destroy
  • Як подивитися розмір, шари, користувача й оточення образу через docker image inspect і docker history, і що PublishContainer обирає за вас.
  • Як multi-stage Dockerfile не пускає SDK у прод і як копіювання файлу проєкту першим робить restore кешованим.
  • Сімейства образів .NET, чому chiseled є гарним дефолтом для API, що дає non-root користувач app (UID 1654) і коли замість InvariantGlobalization потрібен -extra.
  • Як дебажити без shell і де self-contained, trimming і Native AOT доречні для веб-API.
  • Як пушити в Artifact Registry через Docker або Cloud Build, деплоїти за digest через конфіг Pulumi і вмикати сканування вразливостей.