Съдържание на курса

Урок 10 — Предаване на образа

От · основател и главен изпълнителен директор на Hábil ·

Петък е, 5:20 следобед. В понеделник в 9:00 започва курсът по Kubernetes на екипа ти и инструкторът иска всеки да зареди в своя клъстер същия образ на revisor, онзи, който ти вече си тествал. Във вторник изпрати на колегата си Томас командата docker run curso-docker-revisor:1.6.0 и при него проработи. В сряда откри една малка грешка, изгради образа наново и го публикува отново със същия таг, 1.6.0, защото „версията е същата, само поправих една подробност“. Днес, в петък, Томас ти пише: „при мен отчетът излиза различен от твоя“. И двамата имате образ, който се казва абсолютно еднакво. Проверявате и никой от двама ви не може да каже кой от двата е правилният. И това не е всичко: човекът, който се грижи за сигурността в екипа, задава три въпроса, на които не можеш да отговориш със сигурност. Какво носи вътре този образ? С какво е изграден и откъде е дошла всяка част? Има ли компонент с известна уязвимост? Часът е 5:34. Томас работи с друг процесор от твоя, така че дори не си сигурен, че образът, който изгради на своя компютър, става за неговия. Въпросът вече не е как се изгражда образ, а как се предава образ така, че който го получи, да знае точно кой е той, какво носи, откъде идва и на какви компютри работи. Този урок съществува, за да можеш в понеделник вместо „вече ти го изпратих, мисля, че е правилният“ да кажеш: „ето неговия digest, ето списъка му на софтуерните компоненти, ето днешното сканиране и има двоичен файл за всеки процесор, заедно с доказателство, че този за amd64 стартира“.

Време: 2 × 45 мин

Какво изграждаш: предаването на образа на revisor, версия 1.6.0, в пет хода. Локален регистър (registry), който слуша само на 127.0.0.1. Dockerfile, който компилира един и същ двоичен файл за два процесора (linux/amd64 и linux/arm64) без емулация. Публикуване на този образ заедно с неговия списък на софтуерните компоненти (SBOM) и неговия произход (provenance), които можеш да прочетеш с една команда. Сканиране за уязвимости с Trivy, направено, без да даваш на Trivy сокета на Docker. И мостът към следващия курс: образът, зареден в клъстер kind с kind load docker-image, и под (pod), който стартира, без да се опитва да изтегля каквото и да било.

Какво научаваш: какво е регистър и как се състави референцията към образ; разликата между тага, който може да се премести, и digest, който не може; какво е индекс на образи и защо един и същ таг може да носи няколко процесора; трите начина за изграждане за няколко платформи и защо тук се използва кръстосаната компилация; какво са атестациите и как се чете SBOM и произход (режими min и max и какво разкрива max); какво може и какво не може да се очаква от „идентично“ преизграждане; как да изтеглиш образ по digest и да го стартираш със засилена защита; как се сканира с Trivy, какво е праг и защо UNKNOWN също се брои за изходния код; как да приемеш една находка писмено или да я решиш, като обновиш базата; и как да закараш образа в клъстер kind без регистър.

Откъде идваш. От Урок 9 носиш цялата система в един compose.yaml и състоянието от Урок 8: образ под двайсет мегабайта, без shell, без root, който пази отчета си в /datos — папка, която принадлежи на потребителя 65532. От Урок 7 — подсилената защита, с която се стартира (--read-only, --cap-drop ALL, no-new-privileges, --init). От Урок 5 — навика да фиксираш базата по версия и по digest: днес ще видиш какво струва този навик и как се плаща. От Урок 2 — да публикуваш портове само на 127.0.0.1 и да не даваш сокета на Docker на контейнер: този навик определя как сканираш в този урок. В папката ти има revisor/ със своя Dockerfile, своя .dockerignore и secretos/, а до нея — servicio-prueba/.

Какво не прави този урок. Не публикува нищо в публичен регистър: нито в Docker Hub, нито другаде. Всичко се случва в регистър, който стартираш ти на своя компютър, достъпен само от твоя компютър, и който се изтрива накрая. Не изгражда регистър за продукция: за това са нужни TLS и контрол на достъпа, а документацията на самия регистър го изисква (раздел 10.1.1 я цитира). Не подписва образи: криптографският подпис отговаря на въпроса „кой го е публикувал?“, а урокът се ограничава до въпросите „кой е?“, „какво носи?“ и „как е направен?“. Не обещава, че сканираният образ е сигурен образ: сканирането проверява онова, което базите му данни знаят днес, а раздел 10.10 обяснява какво значи „0“ и какво не значи. Не променя compose.yaml от предишния урок, който продължава да изгражда 1.5.0: 1.6.0 от този урок е образът, който се предава на следващия курс, и единствената разлика от 1.5.0 е Dockerfile, който компилира за два процесора. И не разгръща нищо в Kubernetes: последният ход само довежда образа до входа на клъстера и проверява, че подът стартира; останалото е курсът по Kubernetes с kind на Hábil.

За изпълненията. Всички изходи са реални и са получени на 21 октомври 2026 г. Ето какво трябва да знаеш за произхода им, за да ги сравниш със своите:

  • Машината. Mac с Colima, която стартира виртуална машина с Ubuntu 24.04.4 (същата основа като Linux Mint 22, референтната система на курса), ядро 6.8 и процесор arm64. Скриптовете за регистъра, изграждането и сканирането бяха пуснати вътре във виртуалната машина, с Docker 29.2.1 (клиент и демон), Buildx v0.31.1 и BuildKit v0.27.1. Стъпката с kind (раздел 10.11) беше пусната на Mac, където са kind v0.33.0 и kubectl v1.37.1, срещу същия демон.
  • Версиите на курса. Курсът беше планиран с Docker Engine 29.8.2 (измерена на 7 октомври), а в деня на изпълненията най-новата публикувана беше 29.9.2, от 20 октомври (бележки към версиите). Днешна инсталация следователно носи по-нови Buildx и BuildKit от тези в изпълненията: ако текстът на някоя грешка или версията на някой инструмент се различава, сравни ги по смисъла и провери своите с docker version и docker buildx version. Скенерът е Trivy 0.76.0, публикуван на 14 октомври 2026 г. (бележки към версията); регистърът е официалният образ registry:3.1.2.
  • Хранилището за образи. Docker на тази машина използва хранилището на containerd (docker info казва Storage Driver: overlayfs с driver-type: io.containerd.snapshotter.v1), за което раздел 10.1.4 обяснява защо има значение и как да го провериш на твоя компютър.
  • Емулацията. На тази машина компилирането с Go, емулиран за amd64, не проработи (раздел 10.4 показва грешката и какво означава), докато стартирането на вече компилирания двоичен файл за amd64 проработи (раздел 10.9.2). На твоя Mint с процесор amd64 чуждата платформа би била arm64 и може да ти даде друг резултат.
  • Имената. За да не пипат твоето, скриптовете използват помощни имена (регистърът curso-docker-registro-fig10, образът curso-docker-revisor:fig10, томът curso-docker-trivy-cache-fig10, клъстерът curso-docker-puente-fig10), изграждат със собствен таг и при излизане изтриват по име, никога по идентификатор; онези, които публикуват порт, проверяват преди стартиране, че е свободен, и ако не е (например защото вече имаш свой регистър), спират, вместо да изпращат към регистър, който не е техен: fig10_02.sh проверява 5000 и 8080; fig10_04.sh, fig10_08.sh и fig10_09.sh проверяват само 5000, който е единственият, който публикуват; fig10_00.sh, fig10_05.sh, fig10_06.sh и fig10_07.sh не публикуват никакъв фиксиран порт и не проверяват нито един. Това изпълнение ги пусна освен това с префикса curso-docker-l10- и портовете 18100 и 18101, защото на същата машина работеха други уроци; функция nombres, видима във всеки скрипт, изписва отново този префикс като curso-docker- и портовете като 5000 и 8080. Със стойностите по подразбиране нищо не се променя. В твоята папка регистърът ще се казва curso-docker-registro, а образът — curso-docker-revisor:1.6.0.
  • Онова, което се променя от едно изпълнение до друго, и се казва там, където се появява: digest стойностите (зависят от часа на изграждане), времената (една секунда, шейсет и пет: зависят от това какво е имало в кеша), размерите на файловете в десети от мегабайта, датите на базата данни на Trivy, версиите на инструментите, които подписват SBOM (syft и BuildKit, в полето creators), броят на пакетите в SBOM и точните съобщения на неуспешната емулация.

Програмите са в programas/10-entregar-la-imagen/, всяка със своя изход до нея, а estado/ пази файловете на проекта такива, каквито остават накрая.

След урока ще можеш да

  • Стартираш локален регистър само на 127.0.0.1, да публикуваш в него образ и да изброиш съдържанието му с curl срещу неговото API.
  • Обясниш, с твое собствено измерване, защо тагът не идентифицира образ, а digest го идентифицира, и да изтеглиш и стартираш образ по digest.
  • Превърнеш Dockerfile от Урок 8 в такъв, който компилира за linux/amd64 и linux/arm64 без емулация, и да провериш за кой процесор е останал всеки двоичен файл, като прочетеш байтовете му.
  • Публикуваш индекс от две платформи със SBOM и произход в режим max и да прочетеш с docker buildx imagetools inspect пакетите, които съдържа, с какво е изграден и какви данни за изграждащия остават разкрити.
  • Сканираш образа с Trivy от файл на docker save, без да монтираш сокета на Docker, и да разчетеш резултата с неговия праг и изходен код.
  • Вземеш решение за една находка: да я приемеш писмено в .trivyignore, с причина и дата, или да я решиш, като обновиш digest на базата, и да провериш резултата.
  • Заредиш образа в клъстер kind с kind load docker-image и да обясниш защо подът стартира с imagePullPolicy: Never.

Защо, преди как

Досега образът живееше там, където го изгради: в хранилището за образи на твоя Docker. Докато е така, „образът“ е въпрос между теб и твоя компютър. Да го предадеш означава да го извадиш оттам, и щом излезе, се появяват четири проблема, които преди не съществуваха.

Първият е името, което лъже. Сцената от началото не е рядка небрежност: тя е пряка последица от това, че curso-docker-revisor:1.6.0 е таг, табелка, залепена върху образ, а табелката може да се отлепи и да се залепи върху друг. Който получи името, не получава гаранция за нищо. Трябва ни начин да се позовем на точно този образ, а не на „онзи, който в момента носи тази табелка“. Този начин съществува, нарича се digest и ще го измериш в раздел 10.3.

Вторият е процесорът. Образът съдържа вече компилиран двоичен файл, а двоичен файл, компилиран за arm64 (Mac-ът от сцената и много сървъри в облака), не работи на amd64 (Mint на колегата ти и повечето сървъри). Образът от Урок 8 беше изграден за процесора на онзи, който го изгражда, и за нищо друго. Да го предадеш на друг компютър изисква да можеш да кажеш „ето по един за всеки процесор, под едно и също име“. А изграждането му два пъти има свой собствен риск: ако едното от двете изграждания върви емулирано, може да е мъчително бавно или, както ще видиш, изобщо да не работи.

Третият е черната кутия. Образът е файл, който някой ти подава и който ще изпълниш с права на твоя компютър и по-нататък в споделен клъстер. Преди да го изпълниш, искаш да знаеш какво носи вътре (списък на софтуерните компоненти), откъде е дошла всяка част и с какви инструкции е сглобен (произход) и дали някоя от частите му има публично известна уязвимост (сканиране). Не е нужно да не вярваш на когото и да било, за да поискаш тези три неща: да ги поискаш е нормалният начин на работа, когато нещо ще работи там, където има значение.

Четвъртият е входът към клъстера. В следващия курс revisor трябва да стигне до клъстер. Клъстерът не вижда хранилището за образи на твоя Docker: има свое. Трябва да се реши как образът стига дотам, а в локална лаборатория отговорът е един ред (kind load docker-image), с един капан, който ще предизвикаш нарочно, за да го прочетеш.

Че третият проблем не е преувеличение от учебник, показва един публичен случай. На 1 април 2021 г. клиент на Codecov, услуга за покритие на тестове, забеляза, че скриптът, който изтегляше от компанията, за да качва резултатите си, нямаше същата контролна сума (shasum) като публикуваната в GitHub. Подаде сигнал и компанията откри, че някой е променил този скрипт без разрешение още от 31 януари: променения ред изпращаше към чужд сървър цялата среда на машините за непрекъсната интеграция, където работеше, заедно с всички идентификационни данни, които имаше там. Компанията препоръча, наред с другото, да се сменят идентификационните данни на всеки, който го е използвал, без да валидира сумата (Codecov, „Bash Uploader Security Update“). Не беше образ на контейнер, беше скрипт, но механизмът, който го разкри, е точно този от този урок: да сравниш отпечатъка на онова, което си получил, с отпечатъка на онова, което е публикувано. Който сравняваше сумата с публикуваната в GitHub, можеше да открие промяната; клиентът, който го направи, подаде сигнал на 1 април, два месеца след като промените започнаха, а засегнатите потребители получиха известие на 15 април. Digest служи точно като тази сума, с едно условие, което случаят показва: отпечатъкът трябва да пристигне по друг канал, различен от този на изтегленото. Сумата на Codecov беше в GitHub, а скриптът се сервираше от друго място. С образите е същото: digest го предаваш ти на Томас по твоя канал, а не го взема той от същия регистър, който му сервира образа.

Този урок решава четирите с инструменти, които вече имаш. Почти всичко прави docker buildx, който идва с инсталацията от Урок 1. Регистърът е още една програма и затова се стартира в контейнер. Единствената нова програма е Trivy и тя също работи в контейнер, с навика от Урок 2: не ѝ се дава сокетът на Docker. С тази цел пред очи, ето какво ще имаш в края на раздел 10.9: публикувания образ, изтеглен обратно по неговия digest, както би го направила машина, която не го е изграждала (скриптът изтрива преди това локалните копия, останали от изграждането, и го проверява), работещ със засилена защита:

$ curl -s http://127.0.0.1:8080/salud
{"estado":"vivo","version":"1.6.0"}
$ docker inspect --format '{{.Config.Image}}' curso-docker-revisor-fig10 | sed -E 's/@sha256:([0-9a-f]{12}).*/@sha256:\1…/'
localhost:5000/curso-docker-revisor@sha256:59e431fa44c7…

(Това е краят на fig10_02.sh. Контейнерът е създаден с референцията …@sha256:…: вече не с таг.) А това е целта на курса, която се пада на този урок, шестата от README: публикуваният образ се референцира по digest, минава през сканирането на Trivy с прага, който си фиксирал, и се зарежда в клъстер kind без нито една грешка при изтегляне.

Понятията

10.1 Какво е регистър и как се чете името на един образ

10.1.1 Регистърът е сървър за образи

Регистърът (на английски registry) е услуга, която пази образи и ги предава на онзи, който ги поиска. Използвал си го, без да се замисляш: всеки път, когато FROM golang:1.27.3-trixie или docker pull донесеше нещо от интернет, регистър е отговарял. Услугата говори стандартен HTTP протокол и затова този урок може да стартира собствена с един контейнер и да ѝ говори с curl. Проектът, който я реализира, с отворен код, се казва Distribution, а официалният му образ се казва registry. Документацията му го представя с команда от един ред за стартиране (docker run -d -p 5000:5000 --restart=always --name registry registry:3) и с предупреждение, което си струва да се прочете цялото: първите примери са „подходящи само за тестове“, а „регистър, готов за продукция, трябва да е защитен с TLS и, в идеалния случай, да използва някакъв механизъм за контрол на достъпа“ (Distribution, „Deploy a registry server“). Затова в този урок регистърът живее на 127.0.0.1 и се изтрива накрая. Ако някой ден публикуваш към други машини, ще ти трябва TLS, а това е тема от инфраструктурата, която този курс не покрива.

10.1.2 Частите на една референция

Референцията към образ е текстът, който казваш на Docker, за да посочиш един образ. Има до четири части и почти всяка грешка от типа „не намира образа ми“ е една неправилно написана част:

localhost:5000 / curso-docker-revisor : 1.6.0
   registro        repositorio         etiqueta

localhost:5000 / curso-docker-revisor @ sha256:59e431fa44c7…
   registro        repositorio         digest
  • Регистърът е къде се намира: име на машина с незадължителен порт. Ако не напишеш нищо, Docker предполага Docker Hub. В този урок винаги е localhost:5000.
  • Хранилището (repository) е името на образа вътре в регистъра: curso-docker-revisor. Едно хранилище пази всички версии на един образ.
  • Тагът е четимо име на версия: 1.6.0. Той е, както казва документацията на Kubernetes, нещо, което може да се промени: „таговете могат да се преместят, за да сочат към други образи, но digest стойностите са фиксирани“ (Kubernetes, „Images“).
  • Digest е отпечатък на съдържанието, във формат sha256:, следван от шейсет и четири шестнайсетични знака. Същата страница го определя така: „digest стойностите са хешове на съдържанието на образа и са неизменими“.

Когато една референция носи едновременно таг и digest, страницата на Kubernetes е изрична кой управлява: „за изтегляне ще се използва само digest“. Тагът остава като коментар за хората. Това е причината по-нататък, в манифест или в compose.yaml, където е важно да се знае какво се изпълнява, да е удобно да пишеш repositorio:1.6.0@sha256:…: ти четеш версията, а машината проверява съдържанието.

10.1.3 Какво наистина пази един регистър

Преди да стартираш регистъра, добре е да знаеш какво ще му изпратиш, защото това променя начина, по който се четат изходите в урока. Регистърът не пази „образи“ на парче; пази манифести и blob-ове. Blob е анонимен файл, идентифициран с неговия digest (компресиран слой или конфигурационният файл на образа). Манифестът е малък документ във формат JSON, който изброява blob-овете, образуващи един образ. А над манифестите може да има индекс (image index): според спецификацията на Open Container Initiative това е „манифест от по-високо ниво, който сочи към манифести на конкретни образи“, със задължително поле manifests, което е „списък от манифести за конкретни платформи“, и всеки запис може да носи обект platform с архитектурата (architecture, с имената на GOARCH в Go) и операционната система (os, с тези на GOOS) (OCI, „Image Index“). Казано с думи от всекидневието: тагът сочи към индекс; индексът казва „за amd64 виж този манифест, за arm64 виж онзи“; всеки манифест изброява своите слоеве. Когато направиш docker pull на компютър с amd64, Docker чете индекса, избира записа, който съответства на неговия процесор, и сваля само него. Тази структура ще видиш отпечатана, наистина, в раздел 10.5.

А в индекса има и трети вид запис, който не е образ, а документ за един образ: атестациите. Те се обясняват в 10.6.

10.1.4 Преди да започнеш: хранилището за образи на твоя Docker

Почти всичко по-нататък (качването на индекс от две платформи с атестации, запазването на една платформа с docker save --platform, показването на дървото с docker image ls --tree) зависи от една подробност на твоята инсталация: какво хранилище за образи използва твоят Docker. Docker 29 има две: класическото, с драйвери като overlay2, и това на containerd. Документацията казва, че хранилището на containerd е хранилището по подразбиране на Docker Engine 29.0 и следващите при нови инсталации и че ако идваш от по-стара версия, демонът ти „продължава да използва наследените драйвери (overlay2), докато не активираш“ това на containerd; сред предимствата му изброява локалното пазене на образи за няколко платформи и работата с образи, които носят атестации (Docker, „containerd image store“). Провери го така:

docker info -f '{{ .DriverStatus }}'

На машината от изпълненията отговори [[driver-type io.containerd.snapshotter.v1]] (това е блок 1 на fig10_00.sh, с неговия изход до него), което е онова, което документацията казва, че се появява при активно хранилище на containerd. Ако на твоя компютър този ред не се появи (ще видиш нещо с overlay2), същата страница указва да го активираш, като добавиш {"features": {"containerd-snapshotter": true}} към /etc/docker/daemon.json и рестартираш демона с sudo systemctl restart docker. Едно честно предупреждение: този урок не е изпробвал командите си с класическото хранилище, така че ако останеш с него, стъпките от раздели 10.5 и 10.9 може да се провалят или да дадат друг изход; активирай това на containerd, преди да продължиш.

10.2 Локален регистър, само на 127.0.0.1

10.2.1 Стартиране

Това е контейнер като всеки друг, с навика от Урок 2: портът се публикува само на 127.0.0.1, за да не може никой друг в мрежата ти да му изпраща нищо, нито да му иска каквото и да било. Първата част на fig10_02.sh прави точно това, чака да отговори и го пита какви хранилища има:

$ ./fig10_02.sh
== 1. Un registro local, publicado solo en 127.0.0.1
$ docker run -d --name curso-docker-registro-fig10 -p 127.0.0.1:5000:5000 registry:3.1.2 >/dev/null && echo registro arriba
registro arriba
$ curl -s http://127.0.0.1:5000/v2/_catalog
{"repositories":[]}
$ docker image inspect registry:3.1.2 --format '{{json .Config.Volumes}}'
{"/var/lib/registry":{}}

Прочети го ред по ред. -p 127.0.0.1:5000:5000 казва „порт 5000 на моята машина, само през адрес 127.0.0.1, води към порт 5000 на контейнера“. Образът е фиксиран на registry:3.1.2, конкретна версия, а не registry:3, нито latest, по същата причина като всеки образ в този курс: след шест месеца плаващият таг би донесъл нещо друго. >/dev/null скрива дългия идентификатор, който отпечатва docker run -d, а echo потвърждава, че командата е приключила добре. След това /v2/_catalog е точка от стандартното API на регистрите, която изброява хранилищата, а /v2/ самостоятелно е начинът да попиташ „жив ли си и говориш ли протокола?“ (скриптовете го използват, за да изчакат стартирането). Отговорът {"repositories":[]} е този на току-що роден регистър: JSON е валиден, масивът е празен. Няма нищо, защото още не си му изпратил нищо.

Една подробност за чистота, която си струва да се знае: образът registry декларира том в /var/lib/registry (може да се провери с docker image inspect registry:3.1.2 --format '{{json .Config.Volumes}}', която отговаря {"/var/lib/registry":{}}). Тъй като в Урок 8 не го именувахме, Docker създава анонимен, а един самотен docker rm го оставя осиротял на диска. Затова скриптовете изтриват регистъра с docker rm -f -v: -v отнася анонимния том заедно с контейнера. Това е същият вид боклук, който Урок 2 те научи да не оставяш.

10.2.2 Публикуване без TLS и защо тук може

Сигурно си забелязал, че изпращането към localhost:5000 не иска сертификати, нито парола. Онова, което е измерено, е точно това: docker push от следващия раздел работи срещу localhost:5000 без нищо да се настройва. Защо може? Защото документацията на демона казва, че локалните регистри, „чийто IP адрес попада в обхвата 127.0.0.0/8, се маркират автоматично като несигурни“ (тоест допускат се по HTTP без TLS), и добавя, че „не се препоръчва да се разчита на това, защото може да се промени в бъдеще“ (Docker, справка за dockerd). Това е изключение за лаборатория, а не покана да копираш навика: документацията на регистъра казва, че без TLS това се прави само „за тестове“ и че за да е регистърът достъпен от други машини, първо трябва да се защити с TLS. Регистър, който слуша на 0.0.0.0 без TLS и без удостоверяване, е отворена врата за всеки в мрежата ти да промени онова, което сваляш сървърите ти. В този курс остава на 127.0.0.1.

10.3 Таг срещу digest: измерването на сцената

10.3.1 Да предизвикаме петъчния проблем

Сцената от началото може да се възпроизведе за две минути и е добре да го направиш със собствените си ръце, защото това е от онези идеи, на които се вярва много повече, когато се измерят. Скриптът fig10_02.sh изгражда образа два пъти със същия таг и различни версии, изпраща го и двата пъти и накрая пита регистъра какво носи всяка референция. За да работи това, ти трябва Dockerfile-ът от този урок, който се обяснява в 10.4; засега е достатъчно да знаеш, че изгражда същия образ от Урок 8 и че приема --build-arg VERSION=…, както винаги. Редовете, които имат значение:

== 2. Construir la 1.6.0 y publicarla con la etiqueta fig10
$ docker build -q --build-arg VERSION=1.6.0 -t localhost:5000/curso-docker-revisor:fig10 . >/dev/null && docker push -q localhost:5000/curso-docker-revisor:fig10
localhost:5000/curso-docker-revisor:fig10
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{json .Manifest.Digest}}'; echo
"sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c"
$ curl -s http://127.0.0.1:5000/v2/_catalog; curl -s http://127.0.0.1:5000/v2/curso-docker-revisor/tags/list
{"repositories":["curso-docker-revisor"]}
{"name":"curso-docker-revisor","tags":["fig10"]}

== 3. Otra construcción (VERSION=1.6.1) con la MISMA etiqueta
$ docker build -q --build-arg VERSION=1.6.1 -t localhost:5000/curso-docker-revisor:fig10 . >/dev/null && docker push -q localhost:5000/curso-docker-revisor:fig10
localhost:5000/curso-docker-revisor:fig10
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{json .Manifest.Digest}}'; echo
"sha256:f60d25f0809179fefc1966ea14609f73dd6caf3db196e40db52d7b55dc7f42a1"
$ curl -s http://127.0.0.1:5000/v2/curso-docker-revisor/tags/list
{"name":"curso-docker-revisor","tags":["fig10"]}

Да вървим по части. В блок 2 docker build -q изгражда тихо (-q спестява десетките редове на процеса) и връща идентификатора на образа, който скриптът изхвърля с >/dev/null; ако build мине добре, идва docker push -q, който изпраща образа към регистъра и, с -q, отпечатва само изпратената референция. Обърни внимание на -t localhost:5000/curso-docker-revisor:fig10: за да изпратиш към твоя регистър, името на образа трябва да започва с адреса на регистъра. Това е правилото, което обяснява почти всички „push access denied“ и „repository does not exist“ при начинаещите: без префикса Docker предполага, че искаш да изпратиш към Docker Hub. След това imagetools inspect с --format '{{json .Manifest.Digest}}' пита регистъра (не твоя локален Docker) какъв е digest на онова, което стои зад този таг: sha256:59e431fa…. А списъкът с тагове на хранилището, tags/list, казва само един: fig10.

В блок 3 се повтаря с VERSION=1.6.1. Виж изхода: списъкът с тагове продължава да е един, fig10, но digest вече не е 59e431fa…, а f60d25f0…. Тагът се премести от един образ на друг, без предупреждение, без грешка, без никакво съобщение, което да алармира някого. Регистърът направи точно онова, което му беше поискано. Загубена е гаранцията, която ти мислеше, че имаш, когато казваше „1.6.0“.

10.3.2 Да попитаме за всяка референция

Сега най-важната проверка: какво получава онзи, който иска по таг, и какво онзи, който иска по digest? Блок 4 разглежда с docker image ls какво локално копие е оставило изграждането, изтрива го, поглежда пак (за да остане само онова, което казва регистърът) и изтегля двете:

== 4. Lo que trae cada referencia
(la copia local que dejó la construcción)
$ docker image ls localhost:5000/curso-docker-revisor --format '{{.Repository}}:{{.Tag}}' | grep . || echo '(ninguna)'
localhost:5000/curso-docker-revisor:fig10
(y después de borrarla)
$ docker image ls localhost:5000/curso-docker-revisor --format '{{.Repository}}:{{.Tag}}' | grep . || echo '(ninguna)'
(ninguna)
$ docker pull -q localhost:5000/curso-docker-revisor:fig10 && docker run --rm localhost:5000/curso-docker-revisor:fig10 version
localhost:5000/curso-docker-revisor:fig10
1.6.1
$ docker pull -q localhost:5000/curso-docker-revisor@sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c && docker run --rm localhost:5000/curso-docker-revisor@sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c version
localhost:5000/curso-docker-revisor@sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c
1.6.0

Образът носи подкомандата version от Урок 3: тя отпечатва версията, която му е била вградена при компилиране. Който поиска :fig10, получи 1.6.1, последното изпратено. Който поиска digest на първото изграждане, получи 1.6.0, което тази референция предава, докато съществува в регистъра. И двата са здрави, никой не е „зле“: те са два различни образа, а тагът е табелка, която е минала от единия към другия. С това петъчната сцена има обяснение и има поправка: ако ти и Томас бяхте използвали референцията с digest или ако беше публикувал всяка поправка с нов таг (1.6.1), нямаше да има какво да се обсъжда.

Три нюанса, за да не извадиш излишни изводи. Първо: digest не е „по-сигурен“ сам по себе си. Онова, което дава, е неизменимост на референцията: каквото поискаш по digest, или го получаваш идентично, или не го получаваш. Не казва нищо за това дали образът има слабости; това проверява сканирането. Второ: който публикува, може да изтрие един digest от регистъра; тогава pull се проваля, вместо да донесе нещо друго, което е точно желаното поведение. Трето: digest на един индекс е различен от този на всеки манифест на платформа, който съдържа (в това упражнение imagetools inspect показа този на образ с една платформа; двете нива се виждат в 10.5). Дотук работеше само с една платформа; в 10.5 ще видиш двете нива.

Практическото правило, което излиза оттук, се казва с един ред: тагът е за да си говорят хората; digest е за да проверяват машините. Публикувай нов таг за всяко изграждане, което ще предадеш, и не използвай повторно вече публикувана версия. А когато една машина трябва да изпълни точно онова, което си тествал, дай ѝ digest. Документацията на Docker за добрите практики го казва същото за базите: фиксирането на образа по digest дава гаранцията, че „винаги ще използваш същата версия на образа, дори този, който го публикува, да замени тага с нов образ“ (Docker, „Building best practices“, раздел за фиксираните бази). Това направи Урок 5 с базата на revisor, а днес го прилагаш към самия образ на revisor.

10.4 Един Dockerfile за два процесора

10.4.1 Проблемът: двоичният файл е за един процесор

revisor е написан на Go и се компилира до статичен двоичен файл. Какво значи „статичен“, вече знаеш (Урок 5): носи всичко, от което се нуждае, и не иска системни библиотеки. Но все пак е машинен код за един процесор. Две семейства доминират днес: amd64 (наричано още x86-64, това на повечето сървъри и на компютрите с Linux Mint) и arm64 (наричано още AArch64, това на Mac с чип на Apple и на много сървъри в облака). Docker ги нарича linux/amd64 и linux/arm64: операционна система, наклонена черта, архитектура. Името на архитектурата е същото, което използва Go, и това е причината следващият Dockerfile да може да подаде име от Docker направо на компилатора.

За да изградиш образ, който върши работа и за двата, документацията на Docker изброява три стратегии (Docker, „Multi-platform builds“): емулация с QEMU; изграждащ механизъм с няколко собствени възела (по една машина от всяка архитектура); и кръстосана компилация (cross-compilation) с етапи. За първата същата страница предупреждава, че емулацията „може да е много по-бавна от собствените изграждания“, особено при задачи с интензивни изчисления като компилирането, и препоръчва собствени възли или кръстосана компилация „ако е възможно“. С компилатор като този на Go третата е възможна и е най-добрата. Сега ще измериш защо.

10.4.2 Да изпробваме емулацията (и да видим, че не ти върши работа)

Скриптът fig10_03.sh взема Dockerfile от Урок 8, без да го пипа, и иска от Docker образ за linux/amd64 от машина arm64. Docker приема: в docker buildx inspect linux/amd64 се появява сред платформите, които знае да изпълнява, защото носи QEMU, за да симулира този процесор. Ето какво се случи на тази машина:

$ ./fig10_03.sh
== 1. Este equipo y lo que su constructor sabe correr
$ uname -m
aarch64
$ docker buildx inspect | grep -E '^Platforms:'
Platforms:        linux/arm64, linux/amd64, linux/amd64/v2, linux/386

== 2. El Dockerfile de la Lección 8, para linux/amd64: todo corre emulado (QEMU)
$ docker buildx build --no-cache --platform linux/amd64 --build-arg VERSION=1.6.0 -o type=local,dest=salida-emulada emulada > emulada.log 2>&1; echo "construcción: código $?"
construcción: código 1
(tardó 0 s)
$ { grep -m1 -E 'fatal error|panic:|SIGSEGV|qemu:' emulada.log || echo '(ningún renglón de error fatal)'; } | sed -E 's/^#[0-9]+ ([0-9.]+ )?//' | cut -c1-110
fatal error: fault
$ grep -E '^ERROR' emulada.log
ERROR: failed to build: failed to solve: process "/bin/sh -c go test ./..." did not complete successfully: exit code: 2
(el mismo intento, dos veces más, para ver si el fallo es siempre el mismo)
$ for n in 2 3; do docker buildx build --no-cache --platform linux/amd64 --build-arg VERSION=1.6.0 -o type=local,dest=salida-emulada emulada > emulada-$n.log 2>&1; echo "intento $n: código $?"; { grep -m1 -E 'fatal error|panic:|SIGSEGV|qemu:' emulada-$n.log || echo '(ningún renglón de error fatal)'; } | sed -E 's/^#[0-9]+ ([0-9.]+ )?//' | cut -c1-110; done
intento 2: código 1
fatal error: found pointer to free object
intento 3: código 1
fatal error: fault

Прочети го внимателно, защото съдържа два урока. uname -m казва aarch64, дългото име на arm64: това е машина arm64, а списъкът с платформи потвърждава, че нейният изграждащ механизъм знае да изпълнява четири, три от тях чужди за процесора ѝ (amd64, amd64/v2 и 386), благодарение на емулацията. Командата за изграждане носи --no-cache (за да не се използва повторно нищо от предишно изпълнение) и -o type=local,dest=salida-emulada, която вместо да създаде образ оставя крайните файлове в папка: тук служи да провериш резултата, без да задръстваш хранилището за образи. Иска се --platform linux/amd64.

Резултатът: изграждането се проваля, с код 1, почти веднага (скриптът отбеляза 0 s). Съобщението е fatal error на самата програма на Go, а последният ред казва къде се е задействало: в стъпката go test ./..., тази от етапа за тестове от Урок 5, която при провал с exit code: 2 спира всичко. За да се разбере дали провалът е постоянен, или случайност, скриптът повтаря опита още два пъти. Излязоха различни съобщения в трите опита на това изпълнение (fault, found pointer to free object и пак fault), а в предишни изпълнения на същия скрипт видях още две, found bad pointer in Go heap и wirep: already in go, които не останаха запазени във файловете. Всички са съобщения, които отпечатва събирачът на памет или планировчикът на Go, когато нещо, което очаква от машината, не се случва: не са грешка в твоя Dockerfile, нито в кода на revisor. Към емулацията сочи това, че същият Dockerfile, със същия код, се компилира добре на собствения процесор и с кръстосана компилация (10.4.3). Измерен е провалът, а не причината му, и това не е гаранция, че ще се провали и при теб: на твоя Mint с amd64, който емулира arm64, поведението може да е друго, а също и с други версии на QEMU. Което можеш да вземеш за правило, е онова, което казва документацията: емулацията е последната възможност, не първата, а различен провал при всеки опит е белегът на емулация, която не успява да възпроизведе добре процесора. Казано като измерено условие и без обещания: на Mac с чип на Apple, с Colima, емулираната компилация на Go за amd64 се провали и в трите опита на запазеното изпълнение, и в предишните изпълнения на този същия скрипт; ако ти се случи нещо подобно, не настоявай и не се опитвай да поправяш емулацията: компилирай на собствения процесор и остави Go да генерира двоичния файл на другата платформа (кръстосана компилация), което е следващото и което е измерено с успех (код 0, 65 секунди, по един двоичен файл за платформа). Документацията на Docker споменава и друг изход, изграждащ механизъм със собствени възли от всяка архитектура, който този урок не е изпробвал.

10.4.3 Кръстосаната компилация: три промени

Идеята е проста. Компилирането (което е тежката работа) върви на собствения процесор на онзи, който изгражда, и само резултатът се маркира за друг процесор. За това Dockerfile се променя на три места. Преди да го замениш, запази този от Урок 8 с cp Dockerfile Dockerfile.leccion8. Това е целият файл, fig10_01.Dockerfile, който в твоята папка се казва Dockerfile:

# syntax=docker/dockerfile:1
# fig10_01.Dockerfile — Lección 10: la imagen que se entrega: la de la Lección 8,
# construida para varias plataformas SIN emulación. Go compila para otra
# arquitectura por sí mismo, así que las etapas de compilación corren en la
# plataforma de quien construye ($BUILDPLATFORM) y solo cambia el destino del binario.

FROM --platform=$BUILDPLATFORM golang:1.27.3-trixie@sha256:604106102d33b1084e37ca08adf98ac0813624ae8595bd0d7f3965ecebded09c AS compilacion
WORKDIR /src
COPY go.mod ./
COPY cmd/ cmd/
COPY internal/ internal/

FROM compilacion AS pruebas
RUN --mount=type=cache,id=curso-docker-go-build,target=/root/.cache/go-build \
    go test ./...

FROM pruebas AS binario
# ARG existe solo mientras se construye. Aquí se usa para grabar la versión
# dentro del binario; /salud la devuelve.
ARG VERSION=desarrollo
# Las pone BuildKit solo: la plataforma para la que se está construyendo.
ARG TARGETOS TARGETARCH
# Un secreto de CONSTRUCCIÓN (por ejemplo, credenciales para módulos privados)
# se monta solo durante este RUN y no queda en ninguna capa. Este curso no usa
# módulos privados: el montaje es opcional (required=false) y queda como patrón.
RUN --mount=type=cache,id=curso-docker-go-build,target=/root/.cache/go-build \
    --mount=type=secret,id=netrc,target=/root/.netrc,required=false \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/revisor ./cmd/revisor \
 && mkdir -p /out/datos

FROM gcr.io/distroless/static-debian13:nonroot@sha256:516757106de35205efb70b91ba03f6f1298424a0678c603255cf2852a7000087 AS final
ARG VERSION=desarrollo
LABEL org.opencontainers.image.title="revisor" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://www.habil.mx/es/cursos/docker/"
# ENV solo para valores por omisión que NO son secretos.
ENV REVISOR_PUERTO=8080 \
    REVISOR_REPORTE=/datos/reporte.json
COPY --from=binario /out/revisor /revisor
# La carpeta de datos, del usuario sin privilegios. Un volumen con nombre que se
# monte aquí por primera vez copia este dueño.
COPY --from=binario --chown=65532:65532 /out/datos /datos
USER 65532:65532
EXPOSE 8080
# La salud la revisa el propio binario: la imagen no tiene shell ni curl.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD ["/revisor", "healthcheck"]
# Señal de apagado explícita (es la de omisión; escribirla documenta la intención).
STOPSIGNAL SIGTERM
ENTRYPOINT ["/revisor"]

Това е Dockerfile от Урок 8 с три реда код, които са различни (плюс един нов коментар, който ги обяснява, и коментарите в заглавната част); ако сравниш двата файла с diff, кодът се променя само тук:

  1. FROM --platform=$BUILDPLATFORM golang:…. BUILDPLATFORM е променлива, която BuildKit дефинира сам: платформата на машината, която изгражда (тук linux/arm64). С тази опция етапът на компилация върви на собствения процесор, независимо за коя платформа е крайният образ. Документацията я назовава заедно с TARGETPLATFORM, TARGETOS и TARGETARCH като предварително дефинирани аргументи на изграждането (Docker, „Multi-platform builds“).
  2. ARG TARGETOS TARGETARCH. Декларира, вътре в етапа binario, че искаш да използваш операционната система и архитектурата на целта. BuildKit ги задава сам (linux и amd64 или linux и arm64, според платформата, която се сглобява); декларирането им с ARG е онова, което ги прави видими в RUN.
  3. GOOS=$TARGETOS GOARCH=$TARGETARCH пред go build. Това са променливите, с които компилаторът на Go решава за коя система и кой процесор да генерира двоичния файл, а имената на архитектурите в Docker (amd64, arm64) съвпадат с тези на Go, както и тези от спецификацията на индекса в раздел 10.1.3. С CGO_ENABLED=0 (което вече го имаше) двоичният файл е статичен и не се нуждае от компилатор на C за другия процесор.

И нещо, което не се променя, нарочно: етапът final (FROM gcr.io/distroless/…) няма --platform. Този етап трябва да е от платформата на целта, защото е образът, който ще се предава; BuildKit сам избира правилния вариант на distroless базата за всяка платформа и тази база е публикувана и за двете. Двата етапа на компилация (compilacion и pruebas) вървят на собствения процесор само веднъж, на процесора на онзи, който изгражда. Това има граница, която е добре да се каже: тестовете на Go валидират кода на този процесор; не изпълняват двоичния файл на другата платформа. За услуга на чист Go е разумно, но двоичният файл за amd64 се проверява наистина само като се изпълни на amd64. В 10.9.2 го изпълняваш.

10.4.4 Измерване на кръстосаната компилация

Блок 3 на fig10_03.sh изгражда с този Dockerfile за двете платформи едновременно:

== 3. fig10_01.Dockerfile, para las DOS plataformas: compila cruzado
$ docker buildx build --no-cache --platform linux/amd64,linux/arm64 --build-arg VERSION=1.6.0 -o type=local,dest=salida-cruzada cruzada > cruzada.log 2>&1; echo "construcción: código $?"
construcción: código 0
(tardó 65 s)
$ grep -oE '\[linux/[a-z0-9>-]+ (pruebas|binario) [0-9]/[0-9]\]' cruzada.log | sort -u
[linux/arm64 binario 1/1]
[linux/arm64 pruebas 1/1]
[linux/arm64->amd64 binario 1/1]
$ ls salida-cruzada
linux_amd64
linux_arm64
$ maquina salida-cruzada/linux_amd64/revisor; maquina salida-cruzada/linux_arm64/revisor
salida-cruzada/linux_amd64/revisor: 3e 00 = x86-64 (amd64)
salida-cruzada/linux_arm64/revisor: b7 00 = AArch64 (arm64)

Изходният код е 0: проработи, и то с --no-cache, без хитрини. Отне 65 секунди, защото компилира от нулата (с кеша на Go от предишните уроци би било много по-малко; раздел 10.5 го прави за секунди). Най-интересното е в grep. Редовете, които BuildKit отпечатва при изпълнението на всяка стъпка, носят в квадратни скоби платформата, на която се изпълнява стъпката, и онова, което се вижда, е: [linux/arm64 pruebas 1/1], тестовете са вървели на arm64, собствената. [linux/arm64 binario 1/1], двоичният файл за arm64 е компилиран на arm64. И [linux/arm64->amd64 binario 1/1]: двоичният файл за amd64 е компилиран на arm64 (стрелката казва „оттук натам“). Нито една стъпка от изграждането не е изпълнена на amd64, дори емулирано. Това е доказателството, че грешката от предишния раздел не се наложи да се решава с поправяне на QEMU, а с заобикалянето му.

Последната част на изхода е проверката, която има значение: наистина ли всеки файл е за процесора, за който твърди? ls показва по една папка за платформа. А функцията maquina на скрипта чете байтовете: заглавната част на изпълним файл на Linux (формат ELF) пази в байтове 18 и 19 полето e_machine, което идентифицира процесора на целта, а od -An -tx1 -j18 -N2 ги отпечатва в шестнайсетичен вид. 3e 00 е знакът на x86-64, а b7 00 — на AArch64. С други думи, не се доверяваш на името на папката: погледна вътрешността на файла. (Ако искаш пълната таблица с кодове, тя е в спецификацията на формата ELF; двата, които използваш тук, могат да се проверят на всяка машина с командата file на твоя Mint, която освен това ги изписва с имена.)

Кога тази техника не стига. Кръстосаната компилация работи, защото Go умее да генерира код за друга архитектура без ничия помощ. Ако услугата ти зависеше от библиотека на C (с CGO_ENABLED=1) или ако стъпката на изграждане трябваше да изпълнява програми на процесора на целта (например инсталиране на пакети с apt вътре в база amd64), кръстосаната компилация не стига и би се върнал към емулацията или към собствените възли. Това не е случаят на revisor и е една от причините курсът да избере услуга, написана на Go.

10.5 Изграждане за две платформи и публикуване на индекса

10.5.1 Пълната команда

Вече имаш Dockerfile и регистър. Остава командата, която изгражда за двете платформи, прикача списъка на софтуерните компоненти и произхода и изпраща всичко. Дълга е; чете се на парчета:

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --build-arg VERSION=1.6.0 \
  --sbom=true \
  --provenance=mode=max \
  -t localhost:5000/curso-docker-revisor:1.6.0 \
  --push .
  • docker buildx build е съвременният изграждащ механизъм на Docker (BuildKit). Днес docker build е псевдоним на docker buildx build (Docker, „docker build“), така че двете вършат едно и също; за да публикуваш няколко платформи, са нужни BuildKit и цел, която пази индекси (регистър с --push или хранилището на containerd).
  • --platform linux/amd64,linux/arm64 иска по един образ за платформа, разделени със запетая.
  • --build-arg VERSION=1.6.0 е същият аргумент от предишните уроци; остава записан в двоичния файл и в етикета org.opencontainers.image.version.
  • --sbom=true добавя списъка на софтуерните компоненти (раздел 10.6), а --provenance=mode=max — произхода в най-подробния му режим (раздел 10.7).
  • -t …:1.6.0 е референцията, която започва с адреса на твоя регистър, както в 10.3.
  • --push е разликата, която трябва да се разбере. Когато изграждаш за една платформа, образът пада в локалното ти хранилище. Образ за няколко платформи е индекс, а класическото хранилище на Docker не умее да пази индекси, така че исторически buildx изискваше да се изпращат направо към регистър. С хранилището на containerd (това на тази машина, виж „За изпълненията“) вече могат да се пазят локално образи за няколко платформи и с атестации (Docker, „containerd image store“), но командата за предаване върши онова, което искаш, така или иначе: изгражда и публикува с една стъпка. Едно измерено предупреждение: с това хранилище --push оставя образа и на твоя компютър (раздел 10.9.1 го показва с docker image ls), така че това локално копие съществува и е добре да не го бъркаш с публикуваното.

Документацията на Docker описва атестациите така: онези, които прикачваш с --sbom и --provenance, остават „като манифест в индекса на образа“, а данните им се пазят „като JSON blob-ове“ (Docker, „Build attestations“). Ще го видиш със собствените си очи след няколко реда.

Блок 2 на скрипта fig10_04.sh я изпълнява и извлича от журнала на изграждането редовете, в които BuildKit казва какво е записал:

$ ./fig10_04.sh
== 1. Registro local
$ docker run -d --name curso-docker-registro-fig10 -p 127.0.0.1:5000:5000 registry:3.1.2 >/dev/null && echo registro arriba
registro arriba

== 2. Construir para dos plataformas, con SBOM y procedencia max, y publicar
$ docker buildx build --platform linux/amd64,linux/arm64 --build-arg VERSION=1.6.0 --sbom=true --provenance=mode=max -t localhost:5000/curso-docker-revisor:fig10 --push . > ../construccion.log 2>&1; echo "construcción: código $?"
construcción: código 0
(tardó 1 s)
$ grep -E 'exporting (manifest|attestation manifest|manifest list) sha256' ../construccion.log | sed -E 's/^#[0-9]+ //; s/ done$//; s/ [0-9.]+s$//' | sort -u
exporting attestation manifest sha256:d062f35ed5df81b21edeea621c65531f19c4d4075a27418745ba9b08c2080311
exporting attestation manifest sha256:d6939a710e122a030ac13e0b68b8aabc87da4d2f5a2eb4214a692e239613f6f0
exporting manifest list sha256:a4246c5fa0b2cfdfedeed2a5a25cfd6869079d488ba7550e6990939b89420631
exporting manifest sha256:b99bf1ad954b261a0574f3128d8beb91a0f839524eefceb0816c1d3e5266180c
exporting manifest sha256:959af91ef514e97c5bba49bb0a2b31000808ce7c1e15e441487a1659162dd73c

(Тук тагът е fig10 вместо 1.6.0, заради помощните имена.) Кодът е 0 и отне една секунда: вече не компилира от нулата, защото кешът на Go и слоевете от предишното изграждане бяха използвани повторно; раздел 10.4 измери 65-те секунди на студеното изграждане. grep търси редовете, в които BuildKit съобщава какво е експортирал, и излизат пет, които се четат така: два манифеста (по един за платформа, b99bf1ad… и 959af91e…), два манифеста на атестации (също по един за платформа) и един manifest list, което е старото име на индекса и което има digest a4246c5f…. Редът, който ти се пада да запомниш, е последният, индексът: това е digest, който представя целия „1.6.0“, всички платформи и всички атестации.

10.5.2 Четене на индекса

Сега блок 3 пита регистъра как е останал тагът. Това е най-важният резултат на раздела:

== 3. El índice que quedó en el registro
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10
Name:      localhost:5000/curso-docker-revisor:fig10
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:a4246c5fa0b2cfdfedeed2a5a25cfd6869079d488ba7550e6990939b89420631
           
Manifests: 
  Name:        localhost:5000/curso-docker-revisor:fig10@sha256:b99bf1ad954b261a0574f3128d8beb91a0f839524eefceb0816c1d3e5266180c
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/amd64
               
  Name:        localhost:5000/curso-docker-revisor:fig10@sha256:959af91ef514e97c5bba49bb0a2b31000808ce7c1e15e441487a1659162dd73c
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    linux/arm64
               
  Name:        localhost:5000/curso-docker-revisor:fig10@sha256:d6939a710e122a030ac13e0b68b8aabc87da4d2f5a2eb4214a692e239613f6f0
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown
  Annotations: 
    vnd.docker.reference.digest: sha256:b99bf1ad954b261a0574f3128d8beb91a0f839524eefceb0816c1d3e5266180c
    vnd.docker.reference.type:   attestation-manifest
               
  Name:        localhost:5000/curso-docker-revisor:fig10@sha256:d062f35ed5df81b21edeea621c65531f19c4d4075a27418745ba9b08c2080311
  MediaType:   application/vnd.oci.image.manifest.v1+json
  Platform:    unknown/unknown
  Annotations: 
    vnd.docker.reference.type:   attestation-manifest
    vnd.docker.reference.digest: sha256:959af91ef514e97c5bba49bb0a2b31000808ce7c1e15e441487a1659162dd73c

Ред по ред. docker buildx imagetools inspect е инструментът, с който се вижда, в регистъра, какво стои зад една референция; документацията му го описва като показване на „подробностите за един образ в регистъра“ (Docker, buildx imagetools inspect). Това е отдалечено четене: не изисква образът да е на твоя компютър.

  • MediaType: application/vnd.oci.image.index.v1+json казва, че онова, което стои зад тага, е индекс във формат OCI (този от 10.1.3), а не самостоятелен образ.
  • Digest: sha256:a4246c5f… е digest на индекса: същото число от реда exporting manifest list в предишния раздел. Каквото се напише след @, за да се идентифицира „1.6.0“, е това.
  • След това Manifests изброява четири записа, а не два. Първите два са образите: Platform: linux/amd64 с неговия digest b99bf1ad… и Platform: linux/arm64 с 959af91e…. Всеки е манифест на образ (…image.manifest.v1+json) със своите слоеве. Това са digest стойностите, които Docker гледа, когато на компютър amd64 разрешава тага: избира записа, чиято платформа съвпада с тази на компютъра (на amd64 се оказва първият).
  • Другите два имат Platform: unknown/unknown. Не са образи, които някой може да стартира: това са атестациите. Анотацията vnd.docker.reference.type: attestation-manifest го казва, а vnd.docker.reference.digest посочва кой образ описват. Забележи, че d6939a71… сочи към b99bf1ad… (този за amd64), а d062f35e… сочи към 959af91e… (този за arm64): всяка платформа има свой собствен документ. Тази анотация е нишката, която свързва един документ с неговия образ, и тя позволява на imagetools inspect --format '{{index .SBOM "linux/amd64"}}' да намери правилния SBOM.

Следствие, което е добре да забележиш още сега, за да го разбереш в 10.9: атестациите пътуват вътре в индекса, не са отделен файл. Който изтегли образа по digest на индекса, получава, или може да поиска, неговите атестации; който работи с отделен манифест на платформа, не ги вижда.

10.6 Списъкът на софтуерните компоненти (SBOM)

10.6.1 Какво е и за какво служи

Списък на софтуерните компоненти (Software Bill of Materials, SBOM) е, според документацията на Docker, „списъкът със софтуерни артефакти, които един образ съдържа или които са използвани за изграждането му“ (Docker, „Build attestations“). Това е еквивалентът на списъка със съставки на една опаковка. Стойността му се появява в деня, когато излезе публична уязвимост в някоя библиотека: без списък въпросът „използва ли онова, което работи при мен, тази библиотека?“ те принуждава да отвориш всеки образ; със списък това е търсене. И е същият списък, който скенерите използват, за да решат какво да проверят.

Форматът, който --sbom=true записва в този Docker, е SPDX, „отворен стандарт за обмен на информация от списъци на софтуерните компоненти“ (SPDX, „Overview“). Двата реда в края на следващия изход го потвърждават: версията на формата и кои инструменти са записали документа.

10.6.2 Четене

Блок 4 на fig10_04.sh пита регистъра за SBOM на linux/amd64 и го обхожда с шаблон на Go (--format '{{…}}'):

== 4. La lista de materiales (SBOM) de linux/amd64
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{range (index .SBOM "linux/amd64").SPDX.packages}}{{.name}} {{.versionInfo}}{{println}}{{end}}' | sort
base-files 13.8+deb13u7
ca-certificates 20250419
github.com/habil/revisor UNKNOWN
media-types 13.0.0
netbase 6.5
sbom <no value>
stdlib go1.27.3
tzdata 2026c-0+deb13u1
tzdata-legacy 2026c-0+deb13u1
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{(index .SBOM "linux/amd64").SPDX.spdxVersion}} {{(index .SBOM "linux/amd64").SPDX.creationInfo.creators}}'; echo
SPDX-2.3 [Organization: Anchore, Inc Tool: syft-v1.51.0 Tool: buildkit-v0.27.1]

Шаблонът: .SBOM е картата на SBOM по платформи; index .SBOM "linux/amd64" взема този на тази платформа (документацията на командата обяснява, че index служи именно за избор на платформа при образи за няколко платформи); .SPDX.packages е списъкът с пакети на документа SPDX; а {{.name}} {{.versionInfo}} отпечатва името и версията на всеки, по един пакет на ред. | sort ги подрежда. Те са девет реда и всеки разказва история за образа:

  • base-files, media-types, netbase, tzdata, tzdata-legacy и ca-certificates са пакетите на Debian, които носи distroless базата. Някои версии имат суфикс +deb13u7, който е ревизията на този пакет в Debian 13. tzdata е базата данни на часовите зони (2026c); ca-certificates е списъкът с доверени удостоверяващи органи, който позволява на програма да проверява HTTPS сертификати (revisor се нуждае от него, за да проверява услуги по https). Без shell и без мениджър на пакети, малкото, което има в образа, е това. Обърни внимание на версията на ca-certificates: 20250419. Ще се върнеш към нея в 10.10.
  • stdlib go1.27.3 е стандартната библиотека на Go, с която е компилиран двоичният файл. Не е пакет на Debian, а онова, което езикът оставя вътре във всяка програма, която генерира: затова версията на компилатора (тази от етапа golang:1.27.3-trixie) се появява в списъка и затова един скенер може да каже „този образ носи Go 1.27.3“, без да го изпълнява.
  • github.com/habil/revisor UNKNOWN е самата програма: нейният модул на Go, с версия UNKNOWN (скенерът не е намерил версия за четене; уроците не е проучвал защо).
  • sbom <no value> е странен ред и си струва да се каже на глас: не изглежда като пакет на образа: това е елемент без версия, който генераторът добавя към документа (урокът не е проучвал произхода му), а шаблонът отпечатва <no value>, когато полето липсва. Оставяме го в изхода нарочно, за да не те стресне, когато го видиш.

А последната команда отговаря на въпроса „кой е написал това?“: формат SPDX-2.3, направен от Syft (на Anchore, версия 1.51.0) чрез BuildKit v0.27.1. Не го е написал твоят Dockerfile, нито някой го е набирал: BuildKit пуска скенер на съдържание вътре в изграждането (в произхода от следващия раздел ще го видиш като още един „материал“, buildkit-syft-scanner), чете готовия образ и отбелязва онова, което разпознава. Това има граница, която е добре да се знае: SBOM, генериран по този начин, описва онова, което скенерът разпознава, а той разпознава системни пакети и модули на познати езици. Файл, който си копирал на ръка в образа, без никой мениджър на пакети да го регистрира, не се появява. Списъкът е добър опис, но не е доказателство, че няма нищо друго.

10.7 Произходът: с какво е изграден

10.7.1 Какво е

SBOM казва какво има вътре. Произходът (provenance) казва как е направено. Стандартът SLSA го определя като „проверима информация за софтуерни артефакти, която описва къде, кога и как е произведено нещо“ (SLSA, „Provenance“). За един образ на Docker това включва: кой изграждащ механизъм го е сглобил, за коя платформа, какви базови образи е използвал (с техния digest), какви аргументи е получил и в колко часа е започнал и завършил.

10.7.2 Четене

Блок 5 иска три неща от произхода на linux/amd64:

== 5. La procedencia de linux/amd64
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format 'tipo: {{(index .Provenance "linux/amd64").SLSA.buildType}}{{println}}construida EN: {{(index .Provenance "linux/amd64").SLSA.invocation.environment.platform}}'; echo
tipo: https://mobyproject.org/buildkit@v1
construida EN: linux/arm64
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{range (index .Provenance "linux/amd64").SLSA.materials}}{{.uri}}{{println}}{{end}}' | cut -c1-100
pkg:docker/docker/buildkit-syft-scanner@stable-1?platform=linux%2Farm64
pkg:docker/docker/dockerfile@1
pkg:docker/golang@1.27.3-trixie?digest=sha256:604106102d33b1084e37ca08adf98ac0813624ae8595bd0d7f3965
pkg:docker/gcr.io/distroless/static-debian13@nonroot?digest=sha256:516757106de35205efb70b91ba03f6f12
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{json (index .Provenance "linux/amd64").SLSA.invocation.parameters.args}}'; echo
{
  "build-arg:VERSION": "1.6.0",
  "cmdline": "docker/dockerfile:1",
  "source": "docker/dockerfile:1"
}
  • tipo: https://mobyproject.org/buildkit@v1 е типът на изграждането: „това го е сглобил BuildKit, във формата на произход, който BuildKit дефинира“. Това е само идентификатор.
  • construida EN: linux/arm64 е второ свидетелство за кръстосаната компилация: записал го е BuildKit, не ти. Това е декларация на изграждащия механизъм, без подпис: потвърждава онова, което вече измери, но не замества независима проверка. Поиска произхода на образа linux/amd64 и документът казва, че е изграден на linux/arm64. Това е точно онова, което измери раздел 10.4: образът за amd64 е изграден на машина arm64, без емулиране.
  • Материалите са всичко, което е влязло в изграждането, с техния идентификатор на пакет (pkg:docker/…, във формат purl, стандарт за именуване на пакети) и, когато има, техния digest. Появяват се четири: скенерът за SBOM (buildkit-syft-scanner), frontend-ът на Dockerfile (dockerfile@1, което е онова, което искаше първият ред # syntax=docker/dockerfile:1), образът за компилация (golang@1.27.3-trixie, с digest 604106…, който фиксира Урок 5) и крайната база (gcr.io/distroless/static-debian13@nonroot, с 516757…). cut -c1-100 в командата съкращават редовете, за да се събират; пълният digest е в документа. Тези два digest са тези от твоя Dockerfile: произходът е второ четене, записано от BuildKit, че онова, което е използвано, е онова, което е фиксирано (без подпис: всеки с право да изпраща към регистъра би могъл да прикачи друго).
  • Аргументите (args) са онова, което изграждащият механизъм е получил: build-arg:VERSION: 1.6.0, командата на frontend-а и неговият източник.

Едно уточнение за имената на полетата, които току-що прочете (invocation, materials, metadata): Docker записва по подразбиране произхода във версия 0.2 на схемата SLSA, а 1.0 се активира с version=v1 (например --provenance=mode=max,version=v1) (Docker, „SLSA provenance“). С version=v1 полетата сменят мястото си и шаблоните в този раздел не биха работили така, както са; този урок не я е измервал.

10.7.3 min срещу max: предупреждението

Сложихме --provenance=mode=max. Има два режима и разликата не е въпрос на вкус. Документацията казва, че min е онзи, който се добавя по подразбиране към образите, че включва „времеви марки, frontend-а, материалите, хранилището и ревизията на източника, платформата на изграждане и възпроизводимостта“ и че „стойностите на аргументите на изграждането, идентичностите на тайните и богатите метаданни на слоевете не се включват в mode=min“, затова „е безопасен за всички изграждания“. Режимът max „включва всичко от min“ и добавя вътрешната дефиниция на изграждането (LLB), копие на Dockerfile, кодирано в base64, и карти на източника, които свързват стъпки със слоеве. И носи предупреждение, което трябва да се прочете цяло: max разкрива стойностите на аргументите на изграждането; ако подаваш идентификационни данни като --build-arg, изграждането трябва да се пренапише така, че да използва монтирания на тайни, защото „монтиранията на тайни не изтичат извън изграждането и никога не се включват в атестациите за произход“ (Docker, „SLSA provenance“).

Това е от вида предупреждения, на които се вярва повече, когато се видят. Блок 6 изгражда образа за arm64 два пъти, по веднъж във всеки режим, като му подава нарочно аргумент, който Dockerfile дори не декларира: NOTA=curso-docker-nota-de-mentira. Името казва, че е симулиран: това е измислен текст за упражнението и е онова, което би представлявала всяка стойност, подадена по погрешка в --build-arg. Двете изграждания са идентични освен режима на произход (скриптът ги повтаря в цикъл с mode=min и mode=max):

docker buildx build -q --platform linux/arm64 \
  --build-arg VERSION=1.6.0 --build-arg NOTA=curso-docker-nota-de-mentira \
  --provenance=mode=max -t localhost:5000/curso-docker-revisor:fig10-max --push .

След това пита регистъра какво е запазила всяка:

== 6. Modo min contra modo max, con un argumento que el Dockerfile ni declara
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10-min --format '{{json .Provenance.SLSA.invocation.parameters.args}}'; echo
{
  "cmdline": "docker/dockerfile:1",
  "source": "docker/dockerfile:1"
}
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10-min --format '{{json .Provenance.SLSA}}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("campos:", sorted(d)); print("secretos:", d["invocation"]["parameters"].get("secrets", "(no aparecen)"))'
campos: ['buildType', 'builder', 'invocation', 'materials', 'metadata']
secretos: (no aparecen)
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10-max --format '{{json .Provenance.SLSA.invocation.parameters.args}}'; echo
{
  "build-arg:NOTA": "curso-docker-nota-de-mentira",
  "build-arg:VERSION": "1.6.0",
  "cmdline": "docker/dockerfile:1",
  "source": "docker/dockerfile:1"
}
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10-max --format '{{json .Provenance.SLSA}}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("campos:", sorted(d)); print("secretos:", d["invocation"]["parameters"].get("secrets", "(no aparecen)"))'
campos: ['buildConfig', 'buildType', 'builder', 'invocation', 'materials', 'metadata']
secretos: [{'id': 'netrc', 'optional': True}]
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10-max --format '{{json .Provenance.SLSA}}' | python3 -c 'import json,sys,base64; d=json.load(sys.stdin); i=d["metadata"]["https://mobyproject.org/buildkit@v1#metadata"]["source"]["infos"][0]; print(i["filename"]); print("\n".join(base64.b64decode(i["data"]).decode().splitlines()[:3]))'
Dockerfile
# syntax=docker/dockerfile:1
# fig10_01.Dockerfile — Lección 10: la imagen que se entrega: la de la Lección 8,
# construida para varias plataformas SIN emulación. Go compila para otra

Какво се вижда, ако сравним:

  1. Аргументите. В min има само cmdline и source: нито VERSION, нито NOTA. В max се появяват и двата с техните стойности, включително „симулираната бележка“. Това е онова, което описва предупреждението: каквото някой подаде през --build-arg, остава записано, четливо, в документ, който пътува с образа и който може да прочете всеки, който може да изтегли образа. Ако тази бележка беше токен, токенът щеше да е там.
  2. Полетата. min има пет полета (buildType, builder, invocation, materials, metadata); max добавя buildConfig, което е вътрешната дефиниция на стъпките.
  3. Тайните. В min: (no aparecen): няма дори идентичността им. В max се появява [{'id': 'netrc', 'optional': True}]: името на тайната за изграждане (--mount=type=secret,id=netrc) и фактът, че е незадължителна (required=false, спомни си Dockerfile), но не и съдържанието ѝ. Това е поведението, което обещава документацията: монтирането на тайни не изнася стойността; оставя обаче следа, че изграждането е поискало тайна с такова име.
  4. Целият Dockerfile. Последната команда декодира копието в base64, което max пази, и показва първите му редове: това е Dockerfile, който е използван, с коментарите му, достъпен за всеки. Ако твоят Dockerfile имаше коментар с нещо, което не бива да се знае, щеше да излезе тук.

Решението, което следва оттук и което курсът възприема: публикува се с mode=max, защото пълният произход е онова, което прави предаването полезно, и източникът се поддържа чист: никаква тайна не минава през ARG, нито през ENV, нито през --build-arg (навикът от Урок 6), а когато изграждането се нуждае от такава, влиза през --mount=type=secret. Така max не разкрива тайни, но публикува целия Dockerfile и стойностите на всички --build-arg: прегледай и двете, преди да публикуваш. Ако работиш в екип, където не можеш да го гарантираш, min е безопасният режим и самата документация го казва. Онова, което не е приемливо, е да използваш max, без да знаеш, че съществува тази разлика.

10.8 „Идентичен“ не е „същият“: какво се променя при преизграждане

Естествен въпрос след като говорихме за digest стойности: ако изградя два пъти една и съща версия, със същия Dockerfile и същия код, получавам ли същия digest? Би очаквал, че да. Онова, което се измерва, е друго, и е добре да се знае, преди да обещаеш на когото и да било „възпроизводимост“. Блок 7 преизгражда идентично (с кеш) и сравнява:

== 7. Otra construcción idéntica: ¿qué digest cambia?
$ docker buildx build -q --platform linux/amd64,linux/arm64 --build-arg VERSION=1.6.0 --sbom=true --provenance=mode=max -t localhost:5000/curso-docker-revisor:fig10 --push . >/dev/null 2>&1; echo "construcción: código $?"
construcción: código 0
antes:   índice sha256:a4246c5fa0b2cfdfedeed2a5a25cfd6869079d488ba7550e6990939b89420631
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{json .Manifest.Digest}}'; echo
"sha256:04c19cbec864dd3ff9ccf5d71d676244acecd31ae1e1d9c363006bfac903c571"
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --raw | python3 -c 'import json,sys; [print(m["platform"]["architecture"], m["digest"]) for m in json.load(sys.stdin)["manifests"] if m["platform"]["os"]=="linux"]'
amd64 sha256:b99bf1ad954b261a0574f3128d8beb91a0f839524eefceb0816c1d3e5266180c
arm64 sha256:959af91ef514e97c5bba49bb0a2b31000808ce7c1e15e441487a1659162dd73c
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --format '{{(index .Provenance "linux/amd64").SLSA.metadata.buildStartedOn}}'; echo
2026-10-21T18:18:15.739444508-06:00

Digest на индекса се промени: от a4246c5f… на 04c19cbe…. Но тези на всяка платформа (с --raw, суровия JSON на индекса, филтриран до записите за Linux) са същите: b99bf1ad… за amd64 и 959af91e… за arm64. Защо се е променил този на индекса? Защото индексът включва, сред записите си, атестациите, а произходът носи вътре часа, в който е започнало изграждането (последната команда го показва: buildStartedOn). Друг час, друг документ за произход, друг digest на атестацията, друг индекс. Самите образи не са се променили нито с един бит. Практическо следствие: две предавания на едно и също нещо могат да имат различни индекси и ако фиксираш по digest на индекса, фиксираш и този час. Правилно е (идентифицира това предаване), но не е онова, което много хора си представят, като четат „digest е отпечатък на съдържанието“.

Блок 8 повтаря изграждането с --no-cache, което налага да се изпълни наново всяка стъпка:

== 8. Y otra más, ahora SIN caché (--no-cache): ¿siguen iguales los de cada plataforma?
$ docker buildx build -q --no-cache --platform linux/amd64,linux/arm64 --build-arg VERSION=1.6.0 --sbom=true --provenance=mode=max -t localhost:5000/curso-docker-revisor:fig10 --push . >/dev/null 2>&1; echo "construcción: código $?"
construcción: código 0
$ docker buildx imagetools inspect localhost:5000/curso-docker-revisor:fig10 --raw | python3 -c 'import json,sys; [print(m["platform"]["architecture"], m["digest"]) for m in json.load(sys.stdin)["manifests"] if m["platform"]["os"]=="linux"]'
amd64 sha256:37dd082fe87a0b2779067a9098dab82e3c7d53e9924fa668d41638d8602dccfd
arm64 sha256:d9875545824d329af2bc6c206f52acc80145926a0cff399591bc1b4d6876e0c3

Сега се промениха и тези на всяка платформа (37dd082f… и d9875545…). Без кеш нищо не се използва повторно и получилият се образ има друг digest, макар кодът, Dockerfile и версията да са същите. Измерен е фактът; причината не е проучена (най-вероятно е някаква времева марка, запазена в конфигурацията или в слоевете, но този урок не го е проверил и не бива да го четеш като установено). Практическият извод е трезв: на никого не се обещава, че преизграждането връща същия digest. Обещава се обратното и по-полезното: този digest, веднъж публикуван, идентифицира този образ, докато съществува в регистъра. Ако източникът трябва да е възпроизводим до бита, това е друга тема (тази на възпроизводимите изграждания), която този урок нито решава, нито обещава.

10.9 Изтегляне по digest и стартиране както трябва

10.9.1 Както на компютър, който никога не го е изграждал

Публикуването не върши работа, ако не провериш, че образът може да се получи. Честната проверка се прави така, сякаш си Томас: машина, която няма образа, която познава само регистъра и един digest. (Едно предупреждение, за да не объркаш числата: digest стойностите на fig10_02.sh, на една платформа, и тези на fig10_04.sh, индекс от две, са на различни образи и затова не съвпадат.) В блок 9 на fig10_04.sh, преди да свали каквото и да е, скриптът изтрива локалните копия, оставени от изграждането (с хранилището на containerd, дори с --push, образът остава и на твоя компютър), и го проверява с docker image ls преди и след, така че да съществува само онова, което казва регистърът. След това се изтегля по digest:

== 9. Traerla por digest, no por etiqueta
(copias locales de localhost:5000/curso-docker-revisor que dejó la construcción)
$ docker image ls localhost:5000/curso-docker-revisor --format '{{.Repository}}:{{.Tag}}' | grep . || echo '(ninguna)'
localhost:5000/curso-docker-revisor:fig10
localhost:5000/curso-docker-revisor:fig10-min
localhost:5000/curso-docker-revisor:fig10-max
(y después de borrarlas)
$ docker image ls localhost:5000/curso-docker-revisor --format '{{.Repository}}:{{.Tag}}' | grep . || echo '(ninguna)'
(ninguna)
$ docker pull -q localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52
localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52
$ docker run --rm localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 version
1.6.0
$ docker image inspect --format '{{json .RepoDigests}}' localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52; echo
["localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52"]

(Digest-ът на този блок е този на индекса, останал в регистъра след последното изграждане на скрипта; променя се при всяко изпълнение.) docker pull -q с @sha256:… свали само онова, което съответства на този процесор. docker run … version отпечатва 1.6.0: версията, вградена при компилиране. А RepoDigests е полето на един локален образ, което пази от кой регистър и с какъв digest е дошъл: това е доказателството, че локалното копие не е собствено изграждане, а изтегляне, и е онова, което позволява месеци по-късно да се попита „това, което работи, откъде е дошло?“.

Добре е да се каже какво се очаква да се случи тук, дори изходът да не го отпечатва. Спецификацията за разпространение на Open Container Initiative, документът, който определя как клиентите говорят с регистрите, казва, че клиентите „ТРЯБВА ДА проверяват дали тялото на отговора съвпада с поискания digest“ (в оригинала „SHOULD verify that the response body matches the requested digest“; OCI, „Distribution Specification“). Това е препоръка към клиентите и този урок не я е измервал с нарочно променен образ; което имаш, е практическото правило: да поискаш по digest е да поискаш нещо, което може да се провери, а референция с digest, който не съвпада, трябва да е провал, а не „друг образ“.

10.9.2 Какво се свали и какво не

Блок 10 показва нещо, което изненадва първия път: от двете платформи на индекса твоят компютър свали едната.

== 10. Lo que bajó a este equipo: solo su plataforma
$ docker image ls --tree localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 2>/dev/null | sed -E 's/@sha256:([0-9a-f]{12})[0-9a-f]+/@sha256:\1…/'
IMAGE                                                                                                              ID             DISK USAGE   CONTENT SIZE   EXTRA
localhost:5000/curso-docker-revisor@sha256:6956d5334dc8…   6956d5334dc8       16.8MB         4.34MB        
├─ linux/amd64                                                                                                     37dd082fe87a           0B             0B        
└─ linux/arm64                                                                                                     d9875545824d       16.2MB         3.68MB        

$ docker save --platform linux/arm64 -o ../propia.tar localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 && echo 'guardada: linux/arm64'
guardada: linux/arm64
$ docker save --platform linux/amd64 -o ../otra.tar localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52
Error response from daemon: no suitable export target found: image with reference localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 was found but does not provide the specified platform (linux/amd64)
[código de salida: 1]

И блок 11, който изтегля другата платформа и опитва да я стартира:

== 11. Traer la OTRA plataforma, guardarla y probar a correrla (linux/amd64)
$ docker pull -q --platform linux/amd64 localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52
localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52
$ docker save --platform linux/amd64 -o ../otra.tar localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 && echo 'guardada: linux/amd64'
guardada: linux/amd64
$ docker run --rm --platform linux/amd64 localhost:5000/curso-docker-revisor@sha256:6956d5334dc8f4cabfce3b0a0b6bc11270c47bd8ea889078ad646b4a4d546c52 version 2>&1 | head -3
1.6.0

docker image ls --tree е изгледът, който показва индекса и неговите платформи. Чете се така: най-горе, референцията с digest на индекса; отдолу, по един ред за платформа със собствен идентификатор. Колоната DISK USAGE дава ключа: linux/arm64 заема 16.2 MB на диска (образът на revisor: същият размер, който измериха уроци 8 и 9, 16.2 MB), а linux/amd64 заема 0B. Той е в списъка, защото индексът го споменава, но слоевете му не са свалени. Това е ефективно (никой не сваля онова, което няма да изпълнява) и има практическо следствие, което последната команда предизвиква нарочно: docker save --platform linux/arm64 работи, а docker save --platform linux/amd64 се проваля с no suitable export target found … does not provide the specified platform. Документацията на docker image save казва точно това: --platform запазва само посочените платформи и „възниква грешка, ако дадената платформа не присъства в локалното хранилище за образи“ (Docker, docker image save). Поуката от грешката: ако ти трябва файлът на другата платформа, първо трябва да я изтеглиш; запазването не я сваля. Блок 11 го прави и между другото отговаря на въпроса, оставен отворен от раздел 10.4: двоичният файл на другата платформа наистина ли стартира?

Блок 11 от изхода по-горе се чете така. docker pull -q --platform linux/amd64 …@sha256:… сваля слоевете на липсващата платформа (референцията е същата; --platform избира записа на индекса). С нея в хранилището същият docker save --platform linux/amd64, който се беше провалил, сега отговаря guardada: linux/amd64. А последната команда, docker run --rm --platform linux/amd64 … version, отпечатва 1.6.0: двоичният файл за amd64 стартира и отговори. Трябва да се каже при какви условия е измерено, за да не се обещава повече от дължимото: тази машина е arm64, така че този двоичен файл работи емулиран от QEMU; това е доказателство, че файлът е валиден изпълним файл за amd64, който стартира, а не доказателство как се държи на истински amd64, а стартирането на малък статичен двоичен файл е много по-малко взискателно от компилирането с Go (което беше онова, което емулацията не издържа в 10.4.2).

Ако твоят компютър е amd64 (като твоя Mint), това е проверката, която се пада на теб и тя е тази, която наистина има значение: същата команда, docker run --rm --platform linux/amd64 localhost:5000/curso-docker-revisor@sha256:… version, сега работи на собствения процесор. Ако твоят компютър е arm64, платформата „на другия“ е linux/amd64 и изходът по-горе е този, който трябва да получиш. И в двата случая очакваният резултат е 1.6.0.

Това има значение веднага, защото сканирането в следващия раздел се прави върху docker save и сканира една платформа. Индекс от две платформи се сканира на два прохода. В този урок скриптът сканира тази на твоя собствен процесор и това ограничение се казва на глас в 10.10.

10.9.3 Стартиране със засилена защита: отново монтирай /datos

Образът от Урок 8 пази отчета си в /datos и от Урок 7 се стартира с --read-only: файловата система на контейнера е само за четене. Това има следствие, което вече измери в Урок 8 и което тук се вижда отново, с образа, изтеглен по digest, както би го получил друг човек: ако не монтираш нищо в /datos, здравият revisor не може да запази отчета си. Блок 5 на fig10_02.sh стартира образа с всички мерки и без том:

== 5. La de digest, endurecida como en la Lección 8: sin nada en /datos
$ docker run -d --name curso-docker-revisor-fig10 -p 127.0.0.1:8080:8080 --read-only --cap-drop ALL --security-opt no-new-privileges --init -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' localhost:5000/curso-docker-revisor@sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c >/dev/null && echo 'contenedor arriba'
contenedor arriba
$ curl -s http://127.0.0.1:8080/salud
{"estado":"vivo","version":"1.6.0"}
$ curl -s -w ' [%{http_code}]\n' http://127.0.0.1:8080/api/estados | sed -E 's/reporte-[0-9]+/reporte-<n>/'
{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: read-only file system"}
 [500]

Първата команда създава контейнера с мерките от уроци 7 и 8 (--read-only, --cap-drop ALL, --security-opt no-new-privileges, --init) и референцията по digest; >/dev/null && echo само скрива идентификатора и потвърждава, че е приключило добре. След това /salud отговаря vivo и 1.6.0: процесът е стартирал и образът е онзи, който си поискал. После /api/estados отговаря 500 със съобщение, което се чете отдясно наляво, както в уроци 6 и 8: read-only file system го казва ядрото (там не може да се пише), open /datos/.reporte-<n>.tmp казва кой файл (временният, който revisor пише, преди да го преименува), а no pude guardar el reporte го добавя revisor, който предпочита да отговори 500, вместо да се преструва, че всичко е минало добре. Това не е дефект на предадения образ: това е файловата система само за четене, която работи както е поискано. Поправката е обичайната, да монтираш нещо, в което може да се пише, в /datos:

== 6. La misma, con un volumen en /datos (un contenedor no recibe un volumen después: se borra y se crea otro)
$ docker rm -f curso-docker-revisor-fig10 >/dev/null && echo 'contenedor anterior borrado'
contenedor anterior borrado
$ docker run -d --name curso-docker-revisor-fig10 -p 127.0.0.1:8080:8080 --read-only --cap-drop ALL --security-opt no-new-privileges --init -v curso-docker-reportes-fig10:/datos -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' localhost:5000/curso-docker-revisor@sha256:59e431fa44c713637b6d1459ee762bb266764eb80941d7e9aec9109f6bf22b0c >/dev/null && echo 'contenedor arriba'
contenedor arriba
$ curl -s -o /dev/null -w '[%{http_code}]\n' http://127.0.0.1:8080/api/estados
[200]
$ docker inspect --format '{{.Config.Image}}' curso-docker-revisor-fig10 | sed -E 's/@sha256:([0-9a-f]{12}).*/@sha256:\1…/'
localhost:5000/curso-docker-revisor@sha256:59e431fa44c7…

Един контейнер не може да получи том, след като е създаден: затова блок 6 започва с изтриване на предишния (docker rm -f) и създава друг със същата рецепта плюс -v curso-docker-reportes-fig10:/datos (помощният том на скрипта; в твоята папка: --name curso-docker-revisor и -v curso-docker-reportes:/datos, томът от Урок 8). Този път /api/estados отговаря 200. А последният ред е заключението на онова, което обещаваше разделът „Защо, преди как“: docker inspect казва, че контейнерът е създаден с референция по digest. Нищо в историята му не зависи от това някой таг да продължава да сочи там, където е сочел. Не забравяй, че новият том наследява собственика 65532 на папката, която образът носи (Урок 8); затова тук не е нужен нито --user, нито chmod.

Една забележка за -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud': това е услугата, която revisor проверява. Тук е той самият, към собствения му /salud, така че проверката да не зависи от никакъв друг контейнер. Това е начин да се изпробва образът изолирано, същият, който използва подът на kind в раздел 10.11.

10.10 Сканиране на онова, което се предава, с Trivy

10.10.1 Какво прави един скенер и какво не

Скенерът за уязвимости взема списъка на онова, което има вътре в един образ (системни пакети, модули на езици, самият двоичен файл на Go), и го сравнява с бази данни на известни слабости. Всяка публична слабост има идентификатор (номер CVE или този на дистрибуцията, като DSA на Debian), степен на сериозност и понякога поправена версия. Trivy е скенер с отворен код, направен от Aqua Security, който работи като програма от командния ред, а тук го използваш от официалния му образ aquasec/trivy:0.76.0, публикуван на 14 октомври 2026 г. (бележки към версия 0.76.0).

Две предупреждения от самото начало. Първото: скенерът отговаря на въпроса „какви известни проблеми има това днес?“. Не отговаря на „сигурно ли е?“. Резултат от нула находки означава „никаква слабост от тези, които познава моята база данни, днес, в пакетите, които разпознах“; утре може да престане да е нула, без образът да се е променил, защото ще бъде публикувана нова слабост. Второто: качеството на резултата зависи от свежестта на базата данни, и затова блок 6 на fig10_05.sh я измерва.

10.10.2 Грешката: да поискаш образа по неговото име

За това упражнение тръгни от локален образ с просто име: блок 1 на fig10_05.sh го изгражда с docker build -q --build-arg VERSION=1.6.0 -t curso-docker-revisor:fig10 . (в твоята папка, curso-docker-revisor:1.6.0), а docker image ls потвърждава неговите 16.2 MB. Първият инстинкт е късата команда: trivy image mi-imagen. Тъй като Trivy работи в контейнер, а образът е в твоя Docker, не го намира. Излиза дълга грешка с ценен урок. Блок 2 на fig10_05.sh я предизвиква и я филтрира:

== 2. El error: pedirle a Trivy la imagen por su nombre, sin socket
$ docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache aquasec/trivy:0.76.0 image --quiet curso-docker-revisor:fig10 2>&1 | grep -oE 'unable to find the specified image.*|^\s+\* [a-z]+ error: .*' | cut -c1-120
unable to find the specified image "curso-docker-revisor:fig10" in ["docker" "containerd" "podman" "remote"]: 4 erro
	* docker error: unable to inspect the image (curso-docker-revisor:fig10): failed to connect to the docker API at un
	* containerd error: containerd socket not found: /run/containerd/containerd.sock
	* podman error: unable to initialize Podman client: no podman socket found: stat podman/podman.sock: no such file or di
	* remote error: GET https://index.docker.io/v2/library/curso-docker-revisor/manifests/fig10: UNAUTHORIZED: authenti
[código de salida: 1]

Четене. Командата монтира том (-v curso-docker-trivy-cache-fig10:/root/.cache), в който Trivy пази своята база данни, за да не я сваля при всяко изпълнение, и стартира image --quiet curso-docker-revisor:fig10. grep -oE запазва само реда на основната грешка и четирите неуспешни опита; cut скъсява всеки до 120 знака. Съобщението казва, че Trivy е потърсил образа на четири места, в този ред: docker (говорейки с демона през неговия сокет), containerd, podman и remote (регистър). Това е същият ред, който документира Trivy: търси „първо в локалния механизъм на Docker, след това в containerd, podman и накрая в регистър на контейнери“ (Trivy, „Container Image“). На всяко място се провали: сокетът на Docker не е вътре в контейнера (failed to connect to the docker API), няма containerd, нито podman, а като последна възможност се опита да свали curso-docker-revisor от Docker Hub, където не съществува (UNAUTHORIZED).

„Урочната“ поправка на първата грешка е да монтираш сокета на Docker вътре в контейнера на Trivy (-v /var/run/docker.sock:/var/run/docker.sock). Не го прави. Това е точно онова, което Урок 2 те научи да не правиш: който има сокета на Docker, управлява Docker и следователно управлява твоя компютър с права на root. Да го дадеш на контейнер, за да „прочете“ един образ, е да дадеш ключа за всичко на програма, която освен това свали данни от интернет при всяко изпълнение. Правилният път дава същия резултат, без да даваш каквото и да било: запази образа във файл и дай на Trivy файла.

10.10.3 Запазване и сканиране на файла

Trivy приема запазени образи: документацията му го казва, с примера за docker save, следван от trivy image --input archivo.tar (Trivy, „Container Image“), а справката на командата описва --input като „път до входен файл вместо име на образ“ (Trivy, trivy image). Блок 3 прави и двете неща:

== 3. Guardar la imagen en un archivo y escanear el archivo
$ docker save --platform linux/arm64 -o revisor-1.6.0.tar curso-docker-revisor:fig10 && du -h revisor-1.6.0.tar | cut -f1
3.6M
$ docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --severity HIGH,CRITICAL --exit-code 1 2>&1 | sin_fechas | sed 's#/trabajo/#./#'

Report Summary

┌──────────────────────────────────────────┬──────────┬─────────────────┬─────────┐
│                  Target                  │   Type   │ Vulnerabilities │ Secrets │
├──────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        0        │    -    │
├──────────────────────────────────────────┼──────────┼─────────────────┼─────────┤
│ revisor                                  │ gobinary │        0        │    -    │
└──────────────────────────────────────────┴──────────┴─────────────────┴─────────┘
Legend:
- '-': Not scanned
- '0': Clean (no security findings detected)

Първата команда запазва образа във файл .tar от 3.6 MB, само на твоята платформа (--platform linux/arm64; на твоя Mint би било linux/amd64). 3.6 MB е компресираният образ; 16.2 MB, които показваше docker image ls, са некомпресираният. Втората команда, по части:

  • docker run --rm стартира Trivy и го изтрива при приключване; -v curso-docker-trivy-cache-fig10:/root/.cache е кешът на базата данни (в твоята папка -v curso-docker-trivy-cache:/root/.cache), в том с име, за да оцелява между изпълненията и да може да се изтрие накрая.
  • -v "$PWD":/trabajo:ro монтира текущата ти папка в /trabajo, само за четене (:ro): Trivy може да чете .tar, но не и да пише в твоята папка. Това е принципът за минимални привилегии, приложен към скенера.
  • image --quiet --input /trabajo/revisor-1.6.0.tar иска да сканира файла, без лентата за напредък.
  • --severity HIGH,CRITICAL фиксира прага: съобщава само за сериозните и критичните слабости. Документацията за филтриране го илюстрира точно с тази опция (Trivy, „Filtering“).
  • --exit-code 1 казва на Trivy какъв изходен код да върне „когато открие проблеми със сигурността“ (така го описва справката на командата). По подразбиране излиза с 0, дори да намери неща (блок 4b го измерва); с 1 един скрипт или pipeline може да се провали, ако има находки. Това е номерът, който превръща един отчет в контролна точка (gate).
  • sin_fechas и sed са само за представяне в скрипта: първият премахва съобщенията с времева марка, които Trivy отпечатва в началото на реда (за да може изходът да се сравнява между изпълненията), а вторият съкращава /trabajo/ до ./. Не ти трябват, когато изпълняваш командата ти.

Таблицата се чете отгоре надолу. Report Summary е заглавието; всеки ред е сканирана цел. Първата е ./revisor-1.6.0.tar (debian 13.7), от тип debian: системните пакети на базата, с 0 находки. Втората е revisor, от тип gobinary: Trivy разпозна, че във файла има двоичен файл на Go, прочете модулите, вписани в него, и ги провери също, с 0 находки. Колоната Secrets казва -. Trivy търси уязвимости и тайни по подразбиране (справката на trivy image дава vuln,secret като стойност на --scanners), но за тайните записва само находки, така че документацията уточнява, че - в тази колона значи „без находки“ и не прави разлика между „не е търсено“ и „търсено е и не е имало нищо“ (Trivy, „Reporting“). Още една причина да не четеш нулата като гаранция. А 0 в легендата казва Clean (no security findings detected): „чисто, без открити находки за сигурност“. С прага HIGH,CRITICAL изходният код беше 0 и затова скриптът не отпечатва [código de salida: …]. Обърни внимание на думата от легендата: detected (открити). Това е нюансът на предишното предупреждение.

10.10.4 Прагът решава какво виждаш, а UNKNOWN също се брои

Сега същият образ, без --severity, със същия --exit-code 1. Блок 4 го сканира с всички степени на сериозност и филтрира резултата, за да остави таблиците:

== 4. El mismo archivo, con todas las severidades y el mismo --exit-code 1
$ docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --exit-code 1 2>&1 | sin_fechas | sed 's#/trabajo/#./#' | grep -E 'Total|│' 
│                  Target                  │   Type   │ Vulnerabilities │ Secrets │
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        1        │    -    │
│ revisor                                  │ gobinary │        0        │    -    │
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
│     Library     │ Vulnerability │ Severity │ Status │ Installed Version │  Fixed Version   │               Title               │
│ ca-certificates │ DSA-6572-1    │ UNKNOWN  │ fixed  │ 20250419          │ 20250419+deb13u1 │ ca-certificates - security update │
[código de salida: 1]

Появява се една находка, която предишният праг скриваше. Трябва да се прочете всяка колона на последната таблица: Library е пакетът (ca-certificates, този от списъка на софтуерните компоненти в 10.6); Vulnerability е идентификаторът (DSA-6572-1, Debian Security Advisory, съобщение за сигурност на Debian); Severity е UNKNOWN, неизвестна; Status е fixed, поправена; Installed Version е 20250419, тази, която има образът; Fixed Version е 20250419+deb13u1, версията на Debian, която вече поправя слабостта; а Title е заглавието на съобщението. Един ред обобщава: Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0). Категориите са пет и така ги изброява Trivy в обобщението си: UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL (Trivy, „Reporting“). Четири вървят от по-малка към по-голяма сериозност; UNKNOWN не е „по-малко сериозна от LOW“: тя е „некласифицирана“. Документацията на Trivy казва, че се използва, когато никой източник не е дал степен на сериозност на находката („ако никой доставчик няма степен на сериозност, ще се използва UNKNOWN“; Trivy, „Vulnerability“). Затова не се отхвърля, без да се погледне.

Три урока в този блок:

  1. Прагът е твое решение, а не подробност. С HIGH,CRITICAL образът „минава“; с всички степени има една находка. Нито едното не е лъжа, но отговарят на различни въпроси. Избери прага, преди да погледнеш резултата, и го запиши (в pipeline, в README на проекта), за да не го нагласи после никой, за да излезе зелено.
  2. UNKNOWN се брои. Находката е със сериозност UNKNOWN и въпреки това --exit-code 1 върна 1 (редът [código de salida: 1]). С други думи: без --severity дори онова, което скенерът не знае да класифицира, чупи твоята контролна точка. Ако предпочиташ неизвестното да не се проваля, заяви го с --severity, изброявайки степените, които наистина те интересуват. Онова, което не е добре, е да не го знаеш.
  3. Находка fixed е такава, по която може да се действа. Състоянието fixed и колоната Fixed Version казват, че има поправена версия. Поправката не е да я забравиш, нито да изключиш скенера: тя е да обновиш пакета. Как се обновява пакет на distroless база, която си фиксирал по digest, е темата на раздел 10.12.

И контрастът, който обяснява за какво служи --exit-code. Блок 4b изпълнява същото сканиране без тази опция и иска изходния код на самия Trivy с echo "código de Trivy: $?":

== 4b. Lo mismo, sin --exit-code: ¿con qué código sale Trivy por omisión?
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código'
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
código de Trivy: 0

Същата находка (Total: 1) и код 0. Без --exit-code Trivy информира, но не се проваля: скрипт, който само го пуска, би продължил напред със сериозна находка, без да разбере. Измерено е, не е предположено: сканиране в pipeline без --exit-code е украшение, което казва „добре“, дори да има находки. Това е същият вид капан, който познаваш от предишните уроци: изходният код е онова, което чете машината, и трябва да се провери, че наистина се променя, когато трябва.

10.10.5 --ignore-unfixed: интуиция, която излиза грешна

Има опция, която изглежда като лесния изход: --ignore-unfixed, която справката на командата описва като „показване само на поправените уязвимости“ (display only fixed vulnerabilities, Trivy, trivy image) и която страницата за филтри представя като начина да „пропуснеш всички уязвимости без поправка“ (skip all unfixed vulnerabilities, Trivy, „Filtering“). Интуицията казва: „да игнорираме онова, което няма поправка, за да се проваля само онова, което мога да поправя“. Блок 5 я изпробва и името трябва да се прочете добре:

== 5. ¿Y si se ignora lo que no tiene arreglo?
$ docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --ignore-unfixed --exit-code 1 2>&1 | sin_fechas | grep -E '^Total'
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
[código de salida: 1]

Същата находка остава и кодът остава 1. И това не е грешка на Trivy: --ignore-unfixed скрива онова, което няма поправена версия, а тази находка я има (fixed, 20250419+deb13u1). Опцията прави онова, което казва; което не прави, е да реши проблема ти, защото твоят проблем е от онези, които имат поправка. Добър пример защо една опция се изпробва, вместо да се предполага какво прави по името си.

10.10.6 От кога е онова, което знае?

Последният блок отговаря на въпрос, който трябва да съпътства всяко сканиране: срещу коя дата е сравнявано?

== 6. ¿De cuándo es la base de datos de vulnerabilidades?
$ docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache aquasec/trivy:0.76.0 version 2>&1 | sin_fechas
Version: 0.76.0
Vulnerability DB:
  Version: 2
  UpdatedAt: 2026-10-21 19:18:22.876313144 +0000 UTC
  NextUpdate: 2026-10-22 19:18:22.876312904 +0000 UTC
  DownloadedAt: 2026-10-22 00:17:14.540681058 +0000 UTC

trivy version отпечатва версията на програмата и данните на нейната база: кога е била публикувана последната версия (UpdatedAt: 21 октомври, 19:18 UTC), кога се очаква следващата (NextUpdate: един ден по-късно) и кога е свалена в твоя кеш (DownloadedAt). Трите реда в края са онези, които пазиш до резултата от сканирането: „0 находки“ без дата на базата данни е стойност без възраст, а стойностите без възраст се развалят.

Две граници на сканирането, направено по този начин, които урокът вече показа и сега се събират. Първата: сканира една платформа, тази на твоя компютър; amd64 на индекса не е проверена. За истинско предаване docker save --platform и сканирането се повтарят за всяка публикувана платформа. Втората: скенерът проверява онова, което има вътре в образа; не проверява твоя Dockerfile, нито правата ти за изпълнение (това го покриха docker build --check в Урок 3 и мерките от уроци 7 и 8). Това е един слой на защитата, не самата защита.

10.11 Мостът към Kubernetes: kind load docker-image

10.11.1 Защо клъстерът не вижда образа ти

Следващият курс, този за Kubernetes с kind, взема образа, с който излизаш оттук. kind (Kubernetes IN Docker) стартира клъстер на Kubernetes вътре в контейнери на Docker: всеки „възел“ на клъстера е контейнер. Това има следствие, което изненадва: макар възелът да работи в твоя Docker, има собствено хранилище за образи вътре. Онова, което си изградил с docker build, живее в хранилището на твоя Docker, а възелът не го вижда. Под, който поиска curso-docker-revisor:1.6.0, би се опитал да го изтегли от регистър, а този образ е само на твоя компютър.

Има два начина да се реши: да публикуваш образа в регистър, до който клъстерът може да стигне, или да го заредиш директно във възлите. В локална лаборатория вторият е краткият и е онзи, който използва този урок. Документацията на kind го представя така: „можеш да заредиш един или повече образи в своя клъстер kind“ с kind load docker-image mi-app:latest и ако клъстерът има име, трябва да се посочи с --name (kind, „Quick Start“). Същата страница носи предупреждение с две условия, които са точно онова, което ще видиш: „политиката за изтегляне по подразбиране на Kubernetes е IfNotPresent, освен ако тагът е :latest или не е записан“; за да работят заредените образи, както очакваш, не използвай тага :latest и/или укажи imagePullPolicy: IfNotPresent или imagePullPolicy: Never в контейнера си.

Политиката Never е тази, която използва проверката. Документацията на Kubernetes я определя така: „kubelet не се опитва да изтегля образа. Ако образът вече присъства локално, kubelet се опитва да стартира контейнера; ако не, стартирането се проваля“ (Kubernetes, „Images“). За проверка е идеалната политика, защото помага да не се скриват проблеми с транспорта: ако образът не е стигнал до възела, подът не стартира и го казва, вместо да се опита да го изтегли от интернет и да се бави (или, по-лошо, да изтегли друг образ със същото име). Една граница: ако възелът вече имаше стар образ със същия таг, Never би го стартирал, без да предупреди; затова при проверки се използва нов клъстер или нова версия.

10.11.2 Инсталиране на kind и kubectl

Досега всичко беше направено с Docker. Тази стъпка изисква още две програми, които не си инсталирал в предишните уроци: kind и kubectl. Ако компютърът ти е Linux Mint на курса, инсталирай ги с официалните ръководства, с фиксирана версия (тези на курса, измерени на 21 октомври 2026 г.). kind се сваля като двоичен файл (kind, „Quick Start“); kubectl, от dl.k8s.io, с проверка на неговата сума sha256 (Kubernetes, „Install and Set Up kubectl on Linux“):

# kind v0.33.0 (elige la línea de tu procesador; `uname -m` dice x86_64 o aarch64)
[ "$(uname -m)" = x86_64 ]  && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-amd64
[ "$(uname -m)" = aarch64 ] && curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.33.0/kind-linux-arm64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind

# kubectl v1.37.1
ARQ=amd64; [ "$(uname -m)" = aarch64 ] && ARQ=arm64
curl -LO "https://dl.k8s.io/release/v1.37.1/bin/linux/$ARQ/kubectl"
curl -LO "https://dl.k8s.io/release/v1.37.1/bin/linux/$ARQ/kubectl.sha256"
echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl

Първите два реда за kind са от официалното ръководство: всеки се изпълнява само ако процесорът ти е този, който казва, така че на твоя компютър се изпълнява точно един. Редът със sha256sum --check е този, който защитава свалянето: трябва да отговори kubectl: OK; ако отговори FAILED, изтрий файла и го свали отново. sudo install -o root -g root -m 0755 копира програмата в /usr/local/bin със собственик root и право за изпълнение, което е начинът, указан от ръководството. Последователността от сваляне и проверка беше изпробвана във виртуалната машина на изпълненията (Ubuntu 24.04, arm64), със скрипта fig10_00.sh (блокове 2 и 3, с техния изход до тях), без инсталирането в /usr/local/bin, за да не се променя системата, и отговори:

$ ./kind version
kind v0.33.0 go1.26.7 linux/arm64
$ echo "$(cat kubectl.sha256)  kubectl" | sha256sum --check
kubectl: OK
$ ./kubectl version --client
Client Version: v1.37.1
Kustomize Version: v5.8.1

(Версията на Go, с която е компилиран kind, се променя според платформата, на която го изпълняваш: на Mac от изпълненията каза go1.27.0 darwin/arm64.) Провери инсталацията си с kind version и kubectl version --client.

10.11.3 Клъстерът и образът

Скриптът fig10_06.sh се изпълнява на Mac (не във виртуалната машина), защото там са kind и kubectl; те говорят със същия демон на Docker. Има една подробност, която е добре да се обясни, преди да се прочете изходът: използва собствен и временен конфигурационен файл на kubectl (export KUBECONFIG=… вътре в скрипта). Така клъстерът, който създава, не променя твоя контекст на kubectl, нито файла ти ~/.kube/config. На твоя компютър, следвайки урока, искаш обратното: kind да смени контекста ти към новия клъстер, и затова командите по-долу нямат KUBECONFIG. Все пак, и това е навик, който си струва от днес, преди всяка команда на kubectl провери в кой клъстер си с kubectl config current-context: един kubectl delete в погрешния контекст е от грешките, които не се поправят.

Първите раздели на скрипта:

$ ./fig10_06.sh
== 1. Versiones
$ kind version
kind v0.33.0 go1.27.0 darwin/arm64
$ kubectl version --client | head -1
Client Version: v1.37.1

== 2. La imagen, para la plataforma de este equipo
$ docker build -q --build-arg VERSION=1.6.0 -t curso-docker-revisor:fig10 . >/dev/null && docker image ls curso-docker-revisor:fig10 --format '{{.Repository}}:{{.Tag}} {{.Size}}'
curso-docker-revisor:fig10 16.2MB

== 3. Un clúster kind de un nodo
$ kind create cluster --name curso-docker-puente-fig10 --wait 120s 2>&1 | grep -E '^Creating|Ready after|Set kubectl context' | sed -E 's/Ready after [0-9]+s/Ready after Ns/; s/ +[^ -~]+//g'
Creating cluster "curso-docker-puente-fig10" ...
 Ready after Ns
Set kubectl context to "kind-curso-docker-puente-fig10"
$ kubectl config current-context
kind-curso-docker-puente-fig10

Версиите са тези на курса: kind v0.33.0, който по подразбиране използва образа на възела kindest/node:v1.37.0, тоест Kubernetes 1.37.0 (kind, бележки към v0.33.0), и kubectl v1.37.1. След това docker build изгражда образа за платформата на този компютър (тук arm64). В твоята папка командата е docker build --build-arg VERSION=1.6.0 -t curso-docker-revisor:1.6.0 ., която оставя образа в твоя Docker с името, което ще използва следващият курс; скриптът използва помощния таг fig10. docker build самостоятелно изгражда за собствената платформа и оставя резултата в локалното хранилище, което е точно онова, което kind load ще прочете. Тежи 16.2 MB. kind create cluster --name … --wait 120s създава клъстер с един възел и чака до две минути да е готов; grep и sed на скрипта оставят само трите реда, които имат значение (останалото са индикатори за напредък със символи), а реалното време (Ready after Ns) се скрива, защото се променя от изпълнение до изпълнение. kind завърши с фиксиране на контекста: kubectl config current-context отговаря kind-curso-docker-puente-fig10. Контекстът, който kind създава, се казва kind- плюс името на клъстера (така излезе в изпълнението), така че префиксът на курса (curso-docker-) се наследява до името на твоя контекст.

10.11.4 Предизвикай грешката преди решението

Първо конкретното: поискай пода преди да заредиш образа и прочети какво казва. Това е блок 4:

== 4. El error: el pod ANTES de cargar la imagen
$ kubectl run revisor --image=curso-docker-revisor:fig10 --image-pull-policy=Never --port=8080 --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud'
pod/revisor created
$ kubectl get pod revisor -o custom-columns=NOMBRE:.metadata.name,ESTADO:.status.phase,RAZON:.status.containerStatuses[0].state.waiting.reason
NOMBRE    ESTADO    RAZON
revisor   Pending   ErrImageNeverPull
$ kubectl get events --field-selector involvedObject.name=revisor,reason=ErrImageNeverPull -o custom-columns=MENSAJE:.message | head -2 | cut -c1-110
MENSAJE
Container image "curso-docker-revisor:fig10" is not present with pull policy of Never
$ kubectl delete pod revisor --wait=true
pod "revisor" deleted from default namespace

kubectl run revisor --image=… --image-pull-policy=Never --port=8080 --env=… създава под с един контейнер. --image-pull-policy=Never е политиката отгоре; --port=8080 декларира порта, който слуша; --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud' е същата променлива от предишните раздели (revisor проверява сам себе си, така че проверката да не зависи от друг под). След това kubectl get pod … -o custom-columns=… отпечатва таблица с колоните, които ти избираш, с техните пътища вътре в обекта на пода: състоянието (phase) е Pending, а причината за изчакване (waiting.reason) е ErrImageNeverPull. Името вече разказва историята: грешка с образа, при политика Never, която не опита да го изтегли. Събитието я потвърждава с цяло изречение: Container image "…" is not present with pull policy of Never: образът не присъства и политиката забранява да се изтегля. Това е грешката: възелът няма образа. Нищо не е счупено в образа, нито в пода; липсва стъпката, която да го закара дотам. Преди да продължи, скриптът изтрива провалилия се под (kubectl delete pod revisor --wait=true), защото подът не променя политиката си сам и започването отначало е по-чисто.

10.11.5 Зареждане на образа и ново искане на пода

== 5. Cargar la imagen en el nodo (sin registro) y volver a pedir el pod
$ kind load docker-image curso-docker-revisor:fig10 --name curso-docker-puente-fig10
Image: "curso-docker-revisor:fig10" with ID "sha256:0c8af76b8a5fbea25ddafedf41bc49ad9ec5f7144117559da98afbd1da0d2eaa" not yet present on node "curso-docker-puente-fig10-control-plane", loading...
$ docker exec curso-docker-puente-fig10-control-plane crictl images 2>&1 | grep -E 'IMAGE|revisor' | cut -c1-100
IMAGE                                           TAG                  IMAGE ID            SIZE
docker.io/library/curso-docker-revisor      fig10                dda2fbcae183b       3.68MB
$ kubectl run revisor --image=curso-docker-revisor:fig10 --image-pull-policy=Never --port=8080 --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud'
pod/revisor created
$ kubectl wait --for=condition=Ready pod/revisor --timeout=90s
pod/revisor condition met
$ kubectl get pod revisor -o custom-columns=NOMBRE:.metadata.name,ESTADO:.status.phase,IMAGEN:.spec.containers[0].image,REINICIOS:.status.containerStatuses[0].restartCount
NOMBRE    ESTADO    IMAGEN                           REINICIOS
revisor   Running   curso-docker-revisor:fig10   0
$ kubectl logs revisor
revisor 1.6.0 escuchando en :8080 (1 servicio(s))
$ kubectl get events --field-selector involvedObject.name=revisor -o custom-columns=RAZON:.reason,MENSAJE:.message | cut -c1-110
RAZON               MENSAJE
Scheduled           Successfully assigned default/revisor to curso-docker-puente-fig10-control-plane
ErrImageNeverPull   Container image "curso-docker-revisor:fig10" is not present with pull policy of Never
Failed              Error: ErrImageNeverPull
Scheduled           Successfully assigned default/revisor to curso-docker-puente-fig10-control-plane
Pulled              Container image "curso-docker-revisor:fig10" already present on machine and can be acc
Created             Container created
Started             Container started

kind load docker-image curso-docker-revisor:fig10 --name curso-docker-puente-fig10 чете образа от твоя Docker и го копира във възела. Отпечатва един-единствен ред, който се чете така: образът с този идентификатор (ID "sha256:0c8af76b…") още не е присъствал във възела curso-docker-puente-fig10-control-plane и го зарежда. (Този идентификатор е на локалния образ, а не digest на регистър.) След това идва проверката, която не бива да се прескача: да попиташ възела какви образи има, с docker exec <възел> crictl images (crictl е инструментът от командния ред на средата за изпълнение на контейнери, която е вътре във възела; възелът на kind е контейнер на Docker, затова docker exec влиза в него). grep оставя заглавието и редовете на revisor. Появява се docker.io/library/curso-docker-revisor, таг fig10, 3.68 MB: образът е във възела, с пълното име, което Kubernetes дава на едно кратко име. Този ред е доказателството, че зареждането е проработило, и е по-добре от това да се довериш на съобщението loading..., както ще видиш в 10.11.6. След това, същият kubectl run както преди, и сега:

  • kubectl wait --for=condition=Ready pod/revisor --timeout=90s чака до 90 секунди подът да е готов; отговорът condition met е изпълненото условие.
  • Таблицата на пода казва Running с образа curso-docker-revisor:fig10 и 0 рестарта.
  • kubectl logs revisor показва какво отпечатва revisor при стартиране: revisor 1.6.0 escuchando en :8080 (1 servicio(s)). Това е същият ред от Урок 3. Версията 1.6.0 потвърждава, че работи образът, който си изградил.
  • Събитията са доказателството, че мостът е проработил, без да се изтегля нищо. Първите три реда са от предишния неуспешен опит (ErrImageNeverPull, Failed). След това новият под: Scheduled (назначен на възела) и после Pulled със съобщението Container image "…" already present on machine: „образът вече присъства на машината“. Макар събитието да се казва Pulled (изтеглен), текстът казва, че не е имало изтегляне: намерил го е вече във възела, където kind load го е оставил. Съобщението излиза отрязано от cut -c1-110 на скрипта (…and can be acc, от „and can be accessed by the container“). След това Created и Started.

Този израз, already present on machine, е доказателството, което търси цел 6 на курса: подът стартира без никакъв опит за изтегляне. Когато курсът по Kubernetes започне, ще повториш този мост с curso-docker-revisor:1.6.0 и клъстера curso-docker-puente.

За да оставиш чисто онова, което си направил на твоя компютър (твоя клъстер, а не този на някой скрипт): kind delete cluster --name curso-docker-puente. Изтрива клъстера и премахва контекста му от конфигурацията ти; образът в твоя Docker не се пипа.

10.11.6 Ако образът не си го изградил ти: онзи, който идва от регистър

Има случай, който следващият курс може да срещне: образът не го изграждаш на твоя компютър, а го сваляш от регистъра по неговия digest, както в 10.9. Би било естествено да мислиш, че kind load docker-image го приема с тази същата референция. Измерено е и не е така. Скриптът fig10_09.sh публикува индекс от две платформи, сваля го по digest на Mac, създава клъстера и изпробва три пътя:

== 3. Cargar la imagen traída del registro, por su referencia con digest
$ kind load docker-image localhost:5000/curso-docker-revisor@sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993 --name curso-docker-puente-fig10
Image: "localhost:5000/curso-docker-revisor@sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993" with ID "sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993" not yet present on node "curso-docker-puente-fig10-control-plane", loading...
$ docker exec curso-docker-puente-fig10-control-plane crictl images 2>&1 | grep -E 'IMAGE|revisor|<none>' | cut -c1-120
IMAGE                                           TAG                  IMAGE ID            SIZE
docker.io/library/import-2026-10-22             <none>               011d6bb2f3c70       3.68MB

== 4. Guardarla con docker save y cargar el archivo
$ docker save --platform linux/arm64 -o revisor.tar localhost:5000/curso-docker-revisor@sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993 && du -h revisor.tar | cut -f1
3.5M
$ kind load image-archive revisor.tar --name curso-docker-puente-fig10

== 5. ¿Qué nombres tiene la imagen dentro del nodo?
$ docker exec curso-docker-puente-fig10-control-plane crictl images 2>&1 | grep -E 'IMAGE|revisor|<none>' | cut -c1-120 | sed -E 's/([0-9a-f]{12})[0-9a-f]{52}/\1…/g'
IMAGE                                           TAG                  IMAGE ID            SIZE
docker.io/library/import-2026-10-22             <none>               011d6bb2f3c70       3.68MB
docker.io/library/import-2026-10-22             <none>               3b73f89567870       2.32kB

== 6. Pedir el pod con la MISMA referencia con digest, sin descarga
$ kubectl run revisor --image=localhost:5000/curso-docker-revisor@sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993 --image-pull-policy=Never --port=8080 --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud'
pod/revisor created
$ kubectl wait --for=condition=Ready pod/revisor --timeout=60s
error: timed out waiting for the condition on pods/revisor
[código de salida: 1]
$ kubectl get pod revisor -o custom-columns=NOMBRE:.metadata.name,ESTADO:.status.phase,RAZON:.status.containerStatuses[0].state.waiting.reason
NOMBRE    ESTADO    RAZON
revisor   Pending   ErrImageNeverPull

И двата останали пътя (блокове 7 и 8):

== 7. Una etiqueta local sobre la misma imagen, y cargar esa etiqueta
$ kubectl delete pod revisor --wait=true
pod "revisor" deleted from default namespace
$ docker tag localhost:5000/curso-docker-revisor@sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993 curso-docker-revisor:fig10-registro
$ kind load docker-image curso-docker-revisor:fig10-registro --name curso-docker-puente-fig10
Image: "curso-docker-revisor:fig10-registro" with ID "sha256:7c9f949290181fc458b8053b9442bb722c7b0be2c5a68afbd562738840eaf993" not yet present on node "curso-docker-puente-fig10-control-plane", loading...
$ docker exec curso-docker-puente-fig10-control-plane crictl images 2>&1 | grep -E 'IMAGE|revisor|<none>' | cut -c1-100
IMAGE                                           TAG                  IMAGE ID            SIZE
docker.io/library/curso-docker-revisor      fig10-registro       011d6bb2f3c70       3.68MB
docker.io/library/import-2026-10-22             <none>               3b73f89567870       2.32kB
$ kubectl run revisor --image=curso-docker-revisor:fig10-registro --image-pull-policy=Never --port=8080 --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud'
pod/revisor created
$ kubectl wait --for=condition=Ready pod/revisor --timeout=60s
pod/revisor condition met
$ kubectl get pod revisor -o custom-columns=NOMBRE:.metadata.name,ESTADO:.status.phase,IMAGEN:.spec.containers[0].image
NOMBRE    ESTADO    IMAGEN
revisor   Running   curso-docker-revisor:fig10-registro
$ kubectl delete pod revisor --wait=true
pod "revisor" deleted from default namespace

== 8. Traer solo el manifiesto de la plataforma (no el índice) y cargarlo
$ docker pull -q localhost:5000/curso-docker-revisor@sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0
localhost:5000/curso-docker-revisor@sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0
$ kind load docker-image localhost:5000/curso-docker-revisor@sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0 --name curso-docker-puente-fig10
Image: "localhost:5000/curso-docker-revisor@sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0" with ID "sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0" not yet present on node "curso-docker-puente-fig10-control-plane", loading...
$ docker exec curso-docker-puente-fig10-control-plane crictl images 2>&1 | grep -E 'IMAGE|revisor|<none>' | cut -c1-100
IMAGE                                           TAG                  IMAGE ID            SIZE
docker.io/library/import-2026-10-22             <none>               3b73f89567870       2.32kB
docker.io/library/curso-docker-revisor      fig10-registro       011d6bb2f3c70       3.68MB
$ kubectl run revisor --image=localhost:5000/curso-docker-revisor@sha256:7d767818297cb2c958a3037e95b1405641c6b3d46f13b8dc4093fb824a1199e0 --image-pull-policy=Never --port=8080 --env='REVISOR_SERVICIOS=propio http://127.0.0.1:8080/salud'
pod/revisor created
$ kubectl wait --for=condition=Ready pod/revisor --timeout=60s
error: timed out waiting for the condition on pods/revisor
[código de salida: 1]

Чете се така. Път 1, зареждане на референцията с digest (блок 3). kind load отпечатва not yet present on node … loading... и не дава никаква грешка. След това скриптът пита възела какви образи има, с crictl (инструментът от командния ред на средата за изпълнение на контейнери, която е вътре във възела), а филтърът пропуска редовете с revisor и тези без име (<none>). Няма нито един образ с име revisor, но има един без име: docker.io/library/import-2026-10-22, таг <none>, 3.68 MB. Образът е стигнал до възела, но без името, което му иска подът. import-, следвано от датата, е името-заместител, което ctr images import (командата, с която kind импортира, според докладите, цитирани по-долу) дава на онова, което импортира без собствена референция (containerd, ctr images import); датата е тази на часовника на възела, който е в UTC, и затова казва 22, макар изпълненията да са от 21 в Мексико. Път 2, запазване с docker save --platform и зареждане на файла с kind load image-archive (блок 4): командата завършва без грешка и без изход; блок 5 показва същия ред от 3.68 MB и втори без име от 2.32 kB (не е проучено какво съдържа). Блок 6 е следствието: под, който иска референцията с digest, остава в Pending с ErrImageNeverPull, защото във възела няма нито един образ с това име, макар съдържанието му да е там. Път 3, локален таг (блок 7): docker tag <референция с digest> curso-docker-revisor:fig10-registro дава локално име на свалия образ, kind load docker-image на този таг оставя образа във възела с име (crictl го показва като docker.io/library/curso-docker-revisor, с тага fig10-registro и 3.68 MB; редът без име от 3.68 MB вече го няма, защото беше същият образ: същият идентификатор, 011d6bb2f3c70) и подът стартира: Running. Две наблюдения. Първо: възелът записва пълното име, docker.io/library/…, но подът, който иска curso-docker-revisor:fig10-registro, го намира, защото Kubernetes допълва краткото име по същия начин. Второ: зареждането на референцията на манифеста на платформата (блок 8, който сваля …@sha256:7d76…, манифеста за arm64 вместо индекса) също не проработи: подът отново чака с ErrImageNeverPull. Така че проблемът не е в индекса с няколко платформи, а в референцията с digest.

Практическото правило: kind load работи с име с таг; с референция с digest отговаря „loading“ и не оставя във възела образ, който подът може да намери по тази референция. Ако образът ти идва от регистър, първо му сложи локален таг с docker tag и зареди него. Източникът, казан точно. Няма страница на kind, която да описва този случай така, както е (зареждане по референция с digest без получаване на каквато и да е грешка). Което съществува в хранилището на kind, са съседни доклади от същия произход: с Docker 29 и неговото хранилище на containerd, docker save запазва целия индекс на образ с няколко платформи, макар да са свалени само слоевете на твоята, а kind load (който импортира с ctr images import --all-platforms --digests) се проваля с content digest … not found. Поддържащите го отдават на това, че docker save „е направил промени, нарушаващи съвместимостта, когато се използва хранилището, базирано на containerd“, и посочват като кратък изход да не се използва това хранилище (kind, issue #4066, „kind unable to load images with docker 29“, затворен като дубликат на закрепения issue, и все още отворен, #3795; и #4224, който повтаря грешката на Mac с процесор на Apple и OrbStack и е решен с импортиране с ctr без --all-platforms). Разликата с онова, което измери този урок, е симптомът: тук не имаше грешка и образът стигна до възела без име (<none>), поради което подът не го намира. За този точен симптом: измерено, без официален източник. Не е проучено и защо локалният таг оставя образа с име (недоказана хипотеза: образът на тага е свален само със слоевете на твоята платформа и краткото име променя онова, което kind експортира). Затова практическото правило е винаги да проверяваш с crictl images, поглеждайки и редовете <none>. (Това е предупреждението с най-големи последствия за следващия курс: удобната команда се проваля мълчаливо, а симптомът, ErrImageNeverPull, се появява по-късно, в пода.)

10.12 Фиксираната база остарява: подновяване на digest

Остава висящата находка от 10.10: ca-certificates, версия 20250419, с налична поправка в 20250419+deb13u1. Откъде идва тази стара версия? От базата на образа, gcr.io/distroless/static-debian13:nonroot, която от Урок 5 е фиксирана по digest: @sha256:516757106de35…. Фиксирането по digest ти даде точно онова, което търсеше: базата да не се променя под краката ти. А другата страна на същото нещо е, че базата също не получава поправки сама. Документацията на Docker го казва с всички букви: фиксирането към digest означава „да се откажеш от автоматичните поправки за сигурност, което вероятно е нещо, което искаш да получаваш“, а търсенето и записването на новия digest е „досадно“; препоръчва да се автоматизира проверката, с Docker Scout (който съобщава дали фиксираната ти база вече не е текущата) или с Dependabot (който отваря планирани заявки за промяна, за да обнови тагове и digest стойности) (Docker, „Building best practices“).

Което има значение тук, е процедурата, която е кратка и може да се направи на ръка: (1) да попиташ регистъра на базата към кой digest сочи днес тагът; (2) да смениш digest в Dockerfile; (3) да преизградиш; (4) да сканираш отново; (5) да изпробваш отново. Упражнения 2 и 4 го правят с измервания. Един аванс, който е добре да се каже точно, защото лесно се обърква: онова, което се премести в деня на изпълненията, беше тагът nonroot, а не твоят digest. В девет сутринта, мексиканско време, този таг вече сочеше към sha256:d62067879de8…, докато Dockerfile фиксира sha256:516757106de3…; а фиксираният digest продължава да съществува и продължава да сочи към същия образ както винаги (Упражнение 2 го проверява, като го иска по неговия digest). Това е, което обещаваше фиксирането: базата да не се променя, без ти да го решиш. Следствието е другата страна на същата монета: поправките, които Debian публикува, пристигат само когато ти смениш digest. Решението на този урок е да остане с фиксираната база, защото всички изходи, които четеш, са измерени с нея, и да представи подновяването такова, каквото е, съзнателно действие (Упражнение 4 го прави и измерва, че сканирането минава на 0 находки). Образът 1.6.0, който курсът предава, запазва своята фиксирана база и своята документирана находка. В истински проект онова, което правиш, е да подновяваш нарочно, с нова версия.

Има граница, която не бива да се преминава: не подновяваш за да подновиш. Новата база е промяна: може да донесе различна библиотека, може да промени поведението на нещо. Затова процедурата завършва с тестовете на revisor (които етапът pruebas на твоя Dockerfile вече изпълнява при изграждане) и със ново стартиране на образа. И се прави в изграждане с нова версия (1.6.1), по правилото от 10.3: не се използва повторно публикуван таг.

Грешката, която ще видиш

Този урок има шест съобщения, които е добре да разпознаваш, по едно за инструмент (две са от моста към Kubernetes, 1 и 6). Всички вече се появиха; тук се събират, за да ги намираш бързо.

1. Подът, който не стартира: ErrImageNeverPull. Това е от fig10_06.sh:

revisor   Pending   ErrImageNeverPull
Container image "curso-docker-revisor:fig10" is not present with pull policy of Never

Чете се отдясно наляво като тези от предишните уроци. „With pull policy of Never“ казва, че политиката забранява изтеглянето; „is not present“ казва, че възелът няма образа. Двете заедно значат: никой не го е заредил. Решава се с kind load docker-image <образ> --name <клъстер>, като се провери, че името и тагът са точно тези на пода. Ако го заредиш и продължава да се проваля, обикновено е друг таг: провери с kubectl get pod revisor -o custom-columns=IMAGEN:.spec.containers[0].image какво име иска подът (това е същата колона IMAGEN, която вече използва).

2. Trivy, който не намира образа.

unable to find the specified image "curso-docker-revisor:fig10" in ["docker" "containerd" "podman" "remote"]

Означава, че е търсил на тези четири места и не го е намерил. Поправката не е да му дадеш сокета на Docker: тя е да запазиш образа с docker save и да сканираш файла с --input.

3. docker save с платформа, която нямаш.

Error response from daemon: no suitable export target found: image with reference … was found but does not provide the specified platform (linux/amd64)

Образът съществува в хранилището ти, но тази платформа никога не е била свалена. Поправката: първо изтегли тази платформа с docker pull --platform linux/amd64 <референция> и запази отново (блок 11 на fig10_04.sh го измери: след pull, save отговаря guardada: linux/amd64).

4. Емулираното изграждане, което гърми.

fatal error: fault
ERROR: failed to build: failed to solve: process "/bin/sh -c go test ./..." did not complete successfully: exit code: 2

Съвпада с емулацията, а не с твоя код: същият Dockerfile се компилира добре на собствения процесор и с кръстосана компилация. Решава се, като се избегне емулацията: кръстосана компилация с --platform=$BUILDPLATFORM в етапите на компилация и GOOS=$TARGETOS GOARCH=$TARGETARCH в go build.

5. 500 на отчета с read-only file system. Това е от Урок 8, което се появява отново с образа, изтеглен по digest:

{"error":"no pude guardar el reporte: open /datos/.reporte-<n>.tmp: read-only file system"}

Образът е наред и файловата система само за четене работи: липсва да се монтира том в /datos.

6. kind load, който казва „loading“ и не зарежда. Това е от 10.11.6: когато се зареди образ, свален от регистър, чрез референция с digest, kind не дава грешка и не оставя във възела образ, който подът може да намери по тази референция (стига без име, като <none>), а подът остава в Pending с ErrImageNeverPull. Решава се, като му се сложи предварително локален таг с docker tag и се зареди този таг; за да провериш, че е стигнал, docker exec <възел> crictl images трябва да показва образа с името, което иска подът.

Навикът за сигурност на урока

Този урок установява четири навика и четирите бяха упражнени с измервания:

  • Онова, което се изпълнява, се референцира по digest. Тагът се премества, а digest не; инцидентът, който предотвратява, е този от сцената (двама души със „същото“ име и различно съдържание), а атаката, която затруднява, е подмяната в регистъра на образ, на който други вече се доверяват по име. Измерено в 10.3.
  • Онова, което се публикува, носи своя списък на софтуерните компоненти и своя произход, а произходът се публикува чист. mode=max разкрива стойностите на аргументите на изграждането, така че никаква тайна не пътува през --build-arg: влиза през --mount=type=secret. Измерено в 10.7.
  • Сканира се, без да се дава сокетът на Docker на когото и да било. docker save, --input, папка, монтирана с :ro. Инцидентът, който предотвратява, е да се даде контролът на демона на инструмент, който сваля данни от интернет. Измерено в 10.10.
  • Сканирането има записан праг и дата на базата данни. „Нула“ без праг и без дата не е стойност. Измерено в 10.10.4 и 10.10.6.

Често срещани грешки

  • Повторно използване на публикуван таг („версията е същата, само поправих нещо“). Това е грешката от сцената. Всяко изграждане, което се предава, носи нов таг; ако нещо се поправи, това е 1.6.1.
  • Публикуване на latest. Kubernetes я третира различно (политиката му по подразбиране се променя с този таг, а документацията на kind иска да не се използва със заредени образи) и не казва коя версия е. Тагът latest не е име на версия: той е произволно име, което по конвенция се премества.
  • Даване на сокета на Docker на Trivy (или на който и да е контейнер) „за да прочете образа“. Който има сокета, управлява демона, а с него и компютъра. Решава се с docker save и --input.
  • Еднократно сканиране и забравяне. Базата данни на слабостите се променя всеки ден. Резултатът важи със своята дата, а предаването се сканира отново, преди да се използва.
  • Фиксиране на прага, след като си видял резултата. Ако сканирането се провали и свалиш прага, докато мине, сканирането е престанало да върши работа. Прагът се записва предварително.
  • Игнориране на находка без причина и без дата на изтичане. Един .trivyignore със самотни идентификатори е списък от проблеми, които някой е решил да замълчи, без да остави защо. Всеки ред носи коментар с причината и exp: с датата, на която съобщението изтича (Упражнение 3).
  • Фиксиране на базата по digest и повече да не я поглеждаш. Фиксирането предпазва от неочаквани промени, а също и от очаквани поправки. Планира се преглед (Docker Scout, Dependabot или твоя собствена задача).
  • Подаване на тайни през --build-arg и публикуване с mode=max. Стойностите остават записани в произхода, който пътува с образа. Тайните за изграждане влизат през --mount=type=secret.
  • Изграждане за друга платформа с емулация, когато може да се компилира кръстосано. По-бавно, а на тази машина дори не проработи.
  • Регистър на 0.0.0.0:5000 без TLS и без удостоверяване. Всеки в мрежата ти може да изпраща или сваля. Публикува се на 127.0.0.1 или се поставя TLS и контрол на достъпа.
  • Изпращане на образ по чат като файл .tar. Който го получава, не знае чий е, нито какво носи. Публикува се в регистър и се споделя digest.
  • Приемане на „сканиран“ за „сигурен“. Сканирането проверява онова, което базите му данни познават днес, в пакетите, които разпознава. Това е слой на проверка, а не гаранция.
  • Зареждане с kind load на образ с таг latest и очакване подът да не го изтегля. С този таг политиката по подразбиране е друга. Запиши imagePullPolicy или използвай таг на версия.

Упражнения

И четирите имат измерено решение по-долу, със скрипта, който го е произвел. Опитай всяко, преди да го погледнеш.

Упражнение 1 — Два тага, един digest

Имаш публикуван 1.6.0 в локалния си регистър. Искаш онзи, който поиска 1.6 („линията“ 1.6, без последното число), да получи точно същия образ като онзи, който иска 1.6.0, без да преизграждаш нищо. Добави тага 1.6 към вече публикувания образ, провери с регистъра, че сега има два тага, и докажи с imagetools inspect, че и двата имат същия digest. Подсказка: docker buildx imagetools create -t <цел> <източник> създава нов таг от референция, която вече е в регистъра.

Упражнение 2 — Какъв digest има днес базата?

Твоят Dockerfile фиксира базата gcr.io/distroless/static-debian13:nonroot по digest. Установи (а) първите дванайсет знака на digest, който носи твоят Dockerfile, и (б) тези на digest, към който днес сочи същият таг в регистъра на Google. Реши с едно изречение дали базата ти е актуална. Подсказка: docker buildx imagetools inspect върши работа и за образи от други регистри.

Упражнение 3 — Приемане на находка писмено

Trivy съобщи DSA-6572-1 (UNKNOWN) в ca-certificates. Приеми, че днес не можеш да подновиш базата. Напиши файл .trivyignore, който приема тази находка, с коментар, който казва причината, и с exp:, който задава датата на изтичане, и докажи четири неща: без файла Trivy излиза с код 1; с файла излиза с 0; ако файлът игнорира друг идентификатор, отново излиза с 1 (файлът замълчава само онова, което казва, не всичко); и ако exp: вече е минал, находката се връща и Trivy отново излиза с 1.

Упражнение 4 — Подновяване на базата и ново сканиране

Смени в твоя Dockerfile digest на базата с този, който установи в Упражнение 2 (б), преизгради образа, провери, че продължава да казва 1.6.0 с version, запази образа отново с docker save и сканирай отново с всички степени на сериозност и с --exit-code 1. Какво се промени в резултата? Запиши отговора в дневника си с датата: без датата резултатът не върши работа.

Решения

Решение 1

Това е първата част на fig10_08.sh. С вече изпратения 1.6.0, imagetools create създава новия таг направо в регистъра, от референция, която вече е там. Резултатът, както ще видиш, е ново име за същия индекс.

$ ./fig10_08.sh
== Ejercicio 1. Dos etiquetas, un digest
$ docker buildx imagetools create -t localhost:5000/curso-docker-revisor:1.6 localhost:5000/curso-docker-revisor:1.6.0 >/dev/null 2>&1 && echo 'etiqueta 1.6 creada'
etiqueta 1.6 creada
$ curl -s http://127.0.0.1:5000/v2/curso-docker-revisor/tags/list
{"name":"curso-docker-revisor","tags":["1.6","1.6.0"]}
$ for t in 1.6.0 1.6; do docker buildx imagetools inspect localhost:5000/curso-docker-revisor:$t --format '{{json .Manifest.Digest}}'; echo; done
"sha256:125ba52e53985a3e2e0b7618baf1edaa6c4b4e9a1afd93cd98459f245b433be8"
"sha256:125ba52e53985a3e2e0b7618baf1edaa6c4b4e9a1afd93cd98459f245b433be8"

Чете се така. create -t …:1.6 …:1.6.0 казва „създай тага 1.6, сочещ към онова, което днес е 1.6.0“; >/dev/null 2>&1 скрива изхода му за напредък, а && echo потвърждава, че е приключило добре. Списъкът с тагове вече има два, 1.6 и 1.6.0. А цикълът пита, за всеки таг, за digest на индекса: двата реда са идентични (125ba52e…). Две имена, един образ. Това е очакваното от таг, който следва линията 1.6: подвижно и четимо име, което в този момент сочи същото като фиксираното. Обратната страна, която вече знаеш: 1.6 е таг, който се премества (когато публикуваш 1.6.1, ще го насочиш към него), така че служи за хората и никога за проверка.

Решение 2

Намира се в fig10_07.sh. Първата команда извлича от Dockerfile първите дванайсет знака на digest, който фиксира; втората пита регистъра на Google за този на тага nonroot и отрязва началото:

$ ./fig10_07.sh
== Ejercicio 2. ¿Qué digest tiene HOY la etiqueta nonroot de la base?
$ grep -oE 'static-debian13:nonroot@sha256:[0-9a-f]{12}' Dockerfile
static-debian13:nonroot@sha256:516757106de3
$ docker buildx imagetools inspect gcr.io/distroless/static-debian13:nonroot --format '{{json .Manifest.Digest}}' | cut -c2-20
sha256:d62067879de8
(y el digest que fija el Dockerfile, ¿sigue existiendo en el registro?)
$ docker buildx imagetools inspect gcr.io/distroless/static-debian13@sha256:516757106de35205efb70b91ba03f6f1298424a0678c603255cf2852a7000087 --format '{{json .Manifest.Digest}}' | cut -c2-20
sha256:516757106de3

Dockerfile фиксира 516757106de3…; регистърът казва днес d62067879de8…. Не съвпадат: тагът вече не сочи към онова, което е фиксирал твоят Dockerfile. А третият ред на изхода го изяснява: когато поискаш от Google фиксирания digest (…nonroot@sha256:516757106de3…), той отговаря със същия този digest; не се е преместил, нито е изчезнал. Променило се е местоназначението на тага. grep -oE с този шаблон отпечатва само онова, което съвпада (-o), с разширен регулярен израз (-E), който иска низа static-debian13:nonroot@sha256: плюс дванайсет шестнайсетични знака. cut -c2-20 маха началната кавичка от JSON изхода и оставя първите 19 знака, тоест sha256: плюс дванайсет. Това, че тагът е сменил местоназначението си, не значи, че новото е по-добро, нито че старото е опасно: значи, че има какво да се погледне. Това е входът на Упражнение 4. (Това измерване е от 21 октомври; ако го повториш друг ден, може да ти даде друг резултат и това е нормално: затова се измерва.)

Решение 3

Също в fig10_07.sh. Файлът, с неговата причина и неговата дата, и трите изпълнения:

== Ejercicio 3. Aceptar el hallazgo por escrito, con razón y fecha
$ cat .trivyignore
# ca-certificates 20250419 en la base distroless fijada por digest.
# Debian ya lo corrigió (20250419+deb13u1); el arreglo llega al renovar el
# digest de la base (Ejercicio 4). Mientras tanto se acepta, por escrito.
# Revisado el 21-oct-2026. Borrar esta línea al renovar la base.
DSA-6572-1
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        1        │    -    │
./revisor-1.6.0.tar (debian 13.7)
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
código de Trivy: 1
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --ignorefile /trabajo/.trivyignore --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        0        │    -    │
código de Trivy: 0
(el mismo archivo, con un identificador que NO es el del hallazgo)
$ sed -i.orig 's/^DSA-6572-1$/DSA-0000-1/' .trivyignore && tail -1 .trivyignore
DSA-0000-1
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --ignorefile /trabajo/.trivyignore --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        1        │    -    │
./revisor-1.6.0.tar (debian 13.7)
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
código de Trivy: 1

== Ejercicio 3 (continuación). La fecha que Trivy SÍ hace cumplir: exp:
$ sed -i.orig 's/^DSA-0000-1$/DSA-6572-1 exp:2026-11-21/' .trivyignore
$ tail -1 .trivyignore
DSA-6572-1 exp:2026-11-21
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --ignorefile /trabajo/.trivyignore --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        0        │    -    │
código de Trivy: 0
(la misma línea, con una fecha que ya pasó)
$ sed -i.orig 's/exp:2026-11-21/exp:2026-01-01/' .trivyignore
$ tail -1 .trivyignore
DSA-6572-1 exp:2026-01-01
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --ignorefile /trabajo/.trivyignore --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        1        │    -    │
./revisor-1.6.0.tar (debian 13.7)
Total: 1 (UNKNOWN: 1, LOW: 0, MEDIUM: 0, HIGH: 0, CRITICAL: 0)
código de Trivy: 1

Прочети го в три такта. Без файл: таблицата отчита 1 находка, има Total: 1 и кодът е 1. С --ignorefile /trabajo/.trivyignore: таблицата отчита 0 за базата, вече няма ред Total и кодът е 0. А в третото изпълнение скриптът смени идентификатора във файла с измислен (DSA-0000-1): истинската находка се връща, Total: 1, код 1. Затова пълното решение има третото изпълнение: показва, че файлът не е общ прекъсвач, а списък от конкретни идентификатори. Във формата на файла редовете, които започват с #, са коментари, а всеки идентификатор стои сам на своя ред (Trivy, „Filtering“). Коментар с дата не задължава никого да погледне отново находката; онова, което налага една дата, е полето exp:, което същата документация показва в примерите си (CVE-2019-14697 exp:2023-01-01). Втората част на решението, „продължение“, го измерва с идентификатора на това упражнение: с DSA-6572-1 exp:2026-11-21 (бъдеща дата) Trivy излиза с код 0; с exp:2026-01-01 (вече минала дата) находката се връща и кодът е 1. Тоест приемането изтича само. Идентификаторите DSA-… не се появяват в примерите на документацията; измерването показва, че работят както CVE-…. Препоръчителната практика е да се комбинират двете неща: коментарът с причината и exp: с датата на прегледа. Опцията --ignorefile сочи пътя на файла вътре в контейнера (/trabajo/.trivyignore, защото /trabajo е твоята монтирана папка), а не този на твоя компютър; справката на командата указва, че по подразбиране опцията е .trivyignore, тоест файл с това име в текущата папка. И коментарът: не е украса. Той е онова, което позволява след два месеца някой, включително ти, да знае защо този ред е там и кога трябва пак да се реши.

Решение 4

Втората част на fig10_08.sh. Установява текущия digest на тага, записва го в Dockerfile със sed, преизгражда, проверява версията, запазва и сканира:

== Ejercicio 4. Renovar el digest de la base y volver a escanear
$ diff Dockerfile.viejo Dockerfile | cut -c1-120 | sed -E 's/sha256:([0-9a-f]{12})[0-9a-f]+/sha256:\1…/'; true
32c32
< FROM gcr.io/distroless/static-debian13:nonroot@sha256:516757106de3…
---
> FROM gcr.io/distroless/static-debian13:nonroot@sha256:d62067879de8…
$ docker build -q --build-arg VERSION=1.6.0 -t curso-docker-revisor:fig10 . >/dev/null && docker run --rm curso-docker-revisor:fig10 version
1.6.0
$ docker save --platform linux/arm64 -o revisor-1.6.0.tar curso-docker-revisor:fig10 && du -h revisor-1.6.0.tar | cut -f1
3.6M
$ { docker run --rm -v curso-docker-trivy-cache-fig10:/root/.cache -v "$PWD":/trabajo:ro aquasec/trivy:0.76.0 image --quiet --input /trabajo/revisor-1.6.0.tar --exit-code 1 2>&1; echo "código de Trivy: $?"; } | sin_fechas | grep -E '^Total|^código|tar \(' | sed 's#/trabajo/#./#'
│ ./revisor-1.6.0.tar (debian 13.7) │  debian  │        0        │    -    │
código de Trivy: 0

diff показва, че се променя само ред 32, този на крайния FROM, и то само digest. Изграждането минава (а с него и тестовете на Go от етапа pruebas), version продължава да казва 1.6.0, а сканирането с всички степени на сериозност сега дава 0 находки в базата и код 0. Тоест: онова, което скенерът виждаше в ca-certificates, изчезна при подновяването на базата, без да се изключва нищо и без да се замълчава никакво съобщение. Два нюанса за дневника ти. Първо: измерен е резултатът, а не причината: не е отварян списъкът с пакети на новата база, за да се провери коя версия на ca-certificates носи; сканирането казва, че вече няма находка, и с това се решава. Второ: сканирана е една платформа, тази на този компютър. За истинско предаване бихме повторили сканирането с другата (блок 11 от 10.9.2 я оставя запазена). А предаването би се публикувало с нов таг (1.6.1), според правилото от 10.3, а не върху 1.6.0.

Как разбирам, че съм успял

Провери всяка точка с команда, а не по памет:

  1. Регистърът слуша само на 127.0.0.1. docker port curso-docker-registro отговаря 5000/tcp -> 127.0.0.1:5000 и нищо с 0.0.0.0.
  2. Публикуваният образ е индекс от две платформи. docker buildx imagetools inspect localhost:5000/curso-docker-revisor:1.6.0 показва application/vnd.oci.image.index.v1+json и записи linux/amd64 и linux/arm64, плюс два от тип attestation-manifest.
  3. Умееш да четеш неговия списък на софтуерните компоненти и неговия произход, а твоят Docker използва хранилището на containerd. docker info -f '{{ .DriverStatus }}' включва io.containerd.snapshotter.v1. С шаблоните от 10.6 и 10.7 получаваш деветте пакета от списъка и реда construida EN.
  4. Можеш да докажеш, че тагът се премества, а digest не. Повтаряш измерването от 10.3 и виждаш два различни digest под един и същ таг.
  5. Работи по digest, на двете платформи и със засилена защита. На твоя компютър amd64 docker run --rm --platform linux/amd64 <референция> version отговаря 1.6.0.
  6. Работи със засилена защита: docker run … --read-only --cap-drop ALL --security-opt no-new-privileges --init -v <том>:/datos localhost:5000/curso-docker-revisor@sha256:… отговаря {"estado":"vivo","version":"1.6.0"} на /salud, а docker inspect --format '{{.Config.Image}}' <контейнер> показва референция с @sha256:.
  7. Сканирането има записан праг, изходен код и дата. Изпълняваш Trivy с --input, без сокета на Docker, и пазиш до резултата изхода на trivy version с датата на базата.
  8. Подът стартира, без да изтегля нищо. kubectl get events показва already present on machine, а kubectl get pod revisor казва Running с 0 рестарта.
  9. Остави компютъра си чист. Следвайки почистването от раздела „Край на курса“, docker ps -a, docker volume ls и kind get clusters вече не показват регистъра, помощните контейнери, кеша на Trivy, нито клъстера от този урок; остава само твоят образ curso-docker-revisor:1.6.0, който ще използва следващият курс.

Резюме

  • Тагът е табелка, която може да се премества; digest е отпечатък на съдържанието, който не се премества. Публикува се с нов таг при всяко предаване и се проверява по digest.
  • Тагът сочи към индекс; индексът сочи към един манифест на платформа и един манифест на атестация на платформа.
  • За да предадеш на два процесора без емулация, компилираш кръстосано: --platform=$BUILDPLATFORM в етапите на компилация и GOOS=$TARGETOS GOARCH=$TARGETARCH в go build. На тази машина емулацията се провали.
  • --sbom=true добавя списъка на софтуерните компоненти (SPDX, записан от Syft); --provenance=mode=max добавя пълния произход, който разкрива стойностите на аргументите на изграждането; mode=min не.
  • „Идентичното“ преизграждане не гарантира същия digest: с кеш се промени този на индекса; без кеш — този на всяка платформа.
  • Сваля се само платформата, която съответства; docker save --platform се проваля, ако тази платформа не е свалена.
  • Trivy се изпълнява без сокета: docker save и --input, с папката в :ro. Прагът го фиксираш ти, UNKNOWN се брои, ако не декларираш --severity, а без --exit-code Trivy излиза с 0, дори да намери неща.
  • Находка с поправка се решава чрез подновяване на базата или, междувременно, се приема писмено в .trivyignore с причина и дата. Фиксирането по digest има цената да подновяваш нарочно.
  • kind load docker-image закарва образа във възлите; с imagePullPolicy: Never подът стартира само ако е стигнал, а събитието already present on machine го доказва. С образ, свален от регистър, се зарежда чрез локален таг: с референция с digest kind не дава грешка и не оставя във възела образ, който подът може да намери по тази референция.
  • Двоичният файл на другата платформа се проверява, като се изпълни: docker run --rm --platform linux/amd64 <референция> version (тук работи емулиран; на твоя amd64, на собствения процесор).

Край на курса: как разбираш, че си приключил

README на курса обещава, че не свършва, когато си прочел урок 10, а когато проектът ти изпълнява шест неща. С този урок и шестте имат начин да се проверят и всяко от тях е измерено в урока, който го установява:

  1. docker compose up стартира всичко от нулата и отчетът показва неработещата тестова услуга като FALLA (Урок 9).
  2. Крайният образ тежи десетки мегабайта — 16.2 MB в изпълненията на уроци 8 до 10 —, без shell и без компилатор, и не работи като root (Уроци 5 и 8).
  3. Никаква тайна не се появява в docker history, в docker inspect, нито в средата (Урок 6).
  4. docker stop приключва за няколко секунди (в уроци 3, 5, 7 и 9 бяха измерени между 137 и 321 милисекунди с формата exec) и docker ps различава (healthy) от (unhealthy) (Урок 7).
  5. Отчетът оцелява след docker rm и revisor намира другия контейнер по неговото име (Урок 8).
  6. Публикуваният образ се референцира по digest, минава сканирането с прага, който си фиксирал, и се зарежда в клъстер kind без нито една грешка при изтегляне (този урок).

Има едно изречение, което е добре да се каже точно, защото лесно се преувеличава: твоят образ излиза от този курс с прегледано сканиране, не „сигурен“, нито „одобрен“. Има записан праг, дата на базата данни и една позната находка, с нейната причина и с пътя да се реши. Това е смисълът на работата с прецизност: не че няма находки, а че знаеш кои са, от кога са и какво ще направиш с тях. Курсът по Kubernetes с kind продължава оттук: образът curso-docker-revisor:1.6.0, с моста kind load docker-image, който вече измина.

Преди да затвориш дневника, спри и изтрий онова, което си стартирал в този урок и което е твое, с имената, които си му дал, едно по едно (без docker system prune и docker volume prune, които биха отнесли и чуждото). Първо виж какво има с docker ps -a, docker images и docker volume ls и изтрий само онова, което разпознаваш:

docker rm -f -v curso-docker-registro curso-docker-revisor   # el registro (con su volumen anónimo) y el contenedor de 10.9
docker images --digests --format '{{.Repository}}@{{.Digest}}' | grep '^localhost:5000/curso-docker-revisor@' | xargs -r docker rmi   # lo traído por digest
docker volume rm curso-docker-trivy-cache   # la caché de Trivy de 10.10
kind delete cluster --name curso-docker-puente

(Томът curso-docker-reportes не се изтрива тук: той е от Урок 8, пази отчетите, които вече си генерирал, и се чисти, ако искаш, при затварянето на онзи урок. Твоят локален образ curso-docker-revisor:1.6.0, който изгражда в 10.11.3, остава: използва го следващият курс. rmi по-горе изтрива по име копията, свалени по digest, никога по идентификатор.)

Източници

Всички са консултирани на 21 октомври 2026 г.

Термини в този урок

  • Регистър (registry): услуга, която пази образи и ги предава през стандартно HTTP API.
  • Хранилище (repository): името на един образ вътре в регистъра; пази всички негови версии.
  • Референция: текстът, който посочва един образ: регистър, хранилище и таг или digest.
  • Таг (tag): четимо и подвижно име на версия на един образ.
  • Digest: отпечатък sha256: на съдържанието на манифест или blob; не се променя.
  • Манифест: JSON документ, който изброява слоевете и конфигурацията на един образ.
  • Индекс (image index): манифест, който сочи към няколко манифеста, по един за платформа, и към атестациите.
  • Платформа: операционна система плюс архитектура на процесора, например linux/arm64.
  • Емулация (QEMU): симулиране на друг процесор със софтуер; бавна и, на тази машина, нестабилна.
  • Кръстосана компилация (cross-compilation): компилиране на един процесор на двоичен файл за друг, без да се изпълнява нищо чуждо.
  • Атестация: документ за един образ, който пътува в неговия индекс; тук SBOM и произходът.
  • SBOM: списък на софтуерните компоненти; какво носи вътре образът.
  • SPDX: отворен стандарт за списъци на софтуерните компоненти.
  • Произход (provenance): как, къде и с какво е изграден образът.
  • SLSA: рамка и формат за описване на произхода.
  • Уязвимост: публична слабост в сигурността, с идентификатор (CVE, DSA…) и степен на сериозност.
  • Праг: минималната степен на сериозност, която се брои за находка в твоето сканиране.
  • Изходен код: число, което една програма връща при приключване; 0 е успех.
  • .trivyignore: файл с идентификаторите на находките, които приемаш.
  • kind load docker-image: копира образ от локалния Docker във възлите на клъстер kind.
  • imagePullPolicy: Never: политика на Kubernetes, която забранява изтеглянето на образа.
Да обсъдим вашия проект

Предпочитате имейл? Пишете ни на hola@habil.mx