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

Урок 3 — Твоят първи Dockerfile

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

Петък е, 6:40 следобед, а прозорецът за обновяване на сървъра се затваря в 7. Планът беше прост: да спреш стария revisor, да стартираш новия и да се прибереш вкъщи. Въвеждаш docker stop и терминалът замлъква. Една секунда. Три. Седем. На десетата най-накрая се връща. Опитваш със следващото копие и пак: точно десет секунди тишина. Преглеждаш логовете и липсва редът, който revisor винаги пише при спиране, apagado limpio; последният отчет е останал недовършен. Някой от екипа намира състоянието на контейнера и прочита на глас: ExitCode=137. Никой не го е убил нарочно, и въпреки това са го убили. Виновникът не е в кода на revisor, който умее да спира правилно. Той е в един ред от три думи в края на неговия Dockerfile, ред, който изглежда безобиден и който много ръководства пишат точно така. Този урок те учи да пишеш този ред, и всички останали, като знаеш какво прави всеки от тях.

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

Какво изграждаш: първия образ на revisor, curso-docker-revisor:1.0.0, в един-единствен етап, с неговите Dockerfile и .dockerignore. Работи без привилегии, спира за част от секундата и носи версия в името си.

Какво научаваш: какво е Dockerfile и какво прави всяка от основните му инструкции (FROM, WORKDIR, COPY, RUN, USER, EXPOSE, CMD); разликата между формата exec и формата shell, измерена с хронометър; какво е контекстът на изграждане и за какво служи .dockerignore; защо първият Dockerfile вече съдържа USER; как се именува образ и как му се слага таг с версия; и как се проверява Dockerfile преди изграждане с docker build --check.

Откъде идваш. От Урок 2 носиш ежедневната сръчност: стартираш контейнер с docker run, разглеждаш го с ps, logs, inspect и exec, публикуваш порта му само на 127.0.0.1 и го изтриваш, без да оставяш боклук. Там изгради образа на тестовата услуга с една-единствена команда, без да се питаш какво има в нейния Dockerfile. От Урок 0 носиш картата: контейнерът е процес; образът е шаблонът, от който се ражда; а код 137 означава, че ядрото е убило процеса със сигнал 9. Тази последна идея ще се върне тук в друг вид. От курса носиш и кода на revisor, в programas/revisor/, такъв, какъвто курсът го предоставя: HTTP услуга, написана на Go, която още няма Dockerfile. Не е нужно да знаеш Go: няма да промениш нито един ред от програмата.

Какво не прави този урок. Не прави образа малък: този, който излиза оттук, тежи повече от гигабайт, и засега е добре да е така, защото да го видиш голям е най-добрият начин да разбереш после защо си струва да се смали (планът на курса оставя това за Урок 5). Не подрежда и Dockerfile така, че повторното изграждане да е бързо, и не обяснява слоевете един по един: това е материя на Урок 4. Не свързва revisor с други контейнери (затова в този урок revisor проверява сам себе си) и не му добавя проверка на състоянието (health check), нито тайни. Един етап, един образ, всичко пред очите ти.

За изпълненията. Всички изходи са реални и са получени на 21 октомври 2026 г. на Mac с Colima, която стартира виртуална машина с Ubuntu 24.04.4, същата основа като Linux Mint 22, референтната система на курса. Скриптовете бяха пуснати вътре в тази виртуална машина, с Docker 29.2.1 и Buildx v0.31.1 (първият ред на docker build --check гласи building with "default" instance, както ще гласи и в твоя Mint). Същата машина се ползваше едновременно от други изпълнения и беше силно натоварена, затова, за да не се сблъскат, скриптовете бяха пуснати с друг префикс на имената и друг порт: те имат променливите PREFIJO (по подразбиране curso-docker-) и PUERTO (по подразбиране 8080) и бяха изпълнени с PREFIJO=curso-docker-l03- и PUERTO=18030. В изходите, където се появяват имена и порт, урокът показва стойностите по подразбиране, които ще видиш и ти: curso-docker-revisor вместо curso-docker-l03-revisor и 8080 вместо 18030 (а ширината на таблиците, която зависи от най-дългото име, е оставена такава, каквато излезе); това е единствената промяна, а файловете .salida.txt пазят изпълнението такова, каквото е. Онова, което не се променя с променливите, защото се случва вътре в контейнера, излиза такова, каквото е: 8080 на вътрешния порт. Програмите са осем: обичайните скриптове fig03_02, fig03_04, fig03_05, fig03_07 и fig03_08, плюс fig03_09 (папката „В твоята папка“ от 3.6.1, сглобена във временна папка и с тага 1.0.0-carpeta, за да не пипа твоята), fig03_12 (двата Dockerfile с дефекти от упражненията, fig03_10.Dockerfile и fig03_11.Dockerfile) и fig03_13 (докъде стига един шаблон в .dockerignore). Помощната функция t в скриптовете прибягва до python3, когато системният date не дава милисекунди, както този на macOS; в Mint и във виртуалната машина не е нужно. Времената в този урок (изграждане, спиране) са получени с натоварена машина, така че в твоя Mint би трябвало да са по-малки или подобни; 10-те секунди на формата shell не зависят от натоварването. Курсът беше планиран с Docker Engine 29.8.2, а най-новата публикувана версия в този ден беше 29.9.2, от 20 октомври 2026 г. (бележки към версиите); нищо в този урок не се променя между тези версии. Което се променя в твоя Mint, и се казва там, където се появява: номерата на процесите (PID), времената и, ако компютърът ти е с Intel или AMD, архитектурата (amd64 вместо arm64), която тук не се отпечатва, но решава кой вариант на образа на Go се изтегля. Програмите са в programas/03-primer-dockerfile/, всяка с изхода си до нея; estado/ пази Dockerfile и .dockerignore такива, каквито остават в края.

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

  • Напишеш Dockerfile с един етап за вече готова програма и да кажеш, ред по ред, дали тази инструкция действа при изграждането на образа, или при стартирането на контейнера.
  • Изградиш образ с име и таг с версия и да обясниш защо revisor от курса се казва curso-docker-revisor:1.0.0, а не revisor, нито revisor:latest.
  • Покажеш с docker exec ... id, че контейнерът ти не работи като root, и да обясниш какво променя USER 10001:10001.
  • Напишеш .dockerignore и да провериш, като погледнеш вътре в образа, че един файл .env не е пътувал.
  • Измериш колко трае docker stop с CMD във форма exec и във форма shell и да обясниш с docker top защо едната трае част от секундата, а другата малко над 10 s с код 137.
  • Пуснеш docker build --check преди изграждане и да разчетеш предупреждението JSONArgsRecommended.

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

В Урок 2 работи с образ, който ти дадоха готов. Работи, но има капан: ако не знаеш как е сглобен, не знаеш какво съдържа, нито как ще се държи. Работи ли като root? Взема ли със себе си конфигурационните ти файлове? Спира ли, когато го помолиш, или чака да го убият? На тези три въпроса не се отговаря, като гледаш работещия контейнер; отговаря се, като прочетеш неговата рецепта.

Рецептата на един образ се казва Dockerfile. Това е текстов файл с инструкции, по една на ред, който Docker чете отгоре надолу, за да произведе образа. Документацията го определя така: Docker изгражда образи, като чете инструкциите от Dockerfile, „текстов файл, който съдържа инструкциите за изграждане на изходния код“ (Docker, „Dockerfile overview“); а справочникът му го описва като текстов документ с всички команди, които потребител би могъл да изпълни в командния ред, за да сглоби образ (справочник на Dockerfile). Важното за теб е, че рецептата е повторяема: описва един и същ процес на твоя компютър, на този на колегата ти и на сървъра, и затова образ се поправя, като се смени рецептата му и се изгради наново, а не като се влезе в контейнера. (За да дадат две изграждания идентичен образ, трябва освен това основата да се фиксира по отпечатъка ѝ, както ще направиш в Урок 5; с таг основата може да се промени под краката ти, както ще видиш в 3.3.1.) Онова, което в Урок 0 нарече „образът не се поправя, като влезеш в контейнера“, тук има своята друга половина: образ се поправя, като се промени рецептата му и се изгради наново.

А всеки ред от тази рецепта е решение. Сцената от началото не е причинена от грешка в програмирането: причинена е от това, че е написано CMD revisor вместо CMD ["revisor"]. Изглеждат почти еднакво. Ето разликата, която ще измериш в раздел 3.7, със същия образ и същата програма:

## docker stop con forma exec
tardó 562 ms

## docker stop con forma shell
tardó 10365 ms
ExitCode=137

Няколко десети от секундата срещу десет секунди и един труп. Това са числата от изпълнението на този урок. Милисекундите се менят (тази машина беше силно натоварена); десетте секунди и 137 — не. Урокът има седем части:

  1. Какво е изграждане (3.1): рецептата, контекстът и кой върши работата.
  2. Dockerfile на revisor, ред по ред (3.2).
  3. Името и тагът (3.3): защо curso-docker-revisor:1.0.0.
  4. USER: да не работиш като root (3.4).
  5. Контекстът и .dockerignore (3.5), със симулирана тайна, която се промъква.
  6. Изграждане, провал и стартиране (3.6) и формата exec срещу формата shell (3.7), с хронометъра.
  7. Проверка преди изграждане (3.8) и остарялото, което ще срещнеш в ръководствата (3.9).

Понятията

3.1 Какво е изграждане на образ

3.1.1 Рецептата, съставките и кухнята

Изграждането на образ има три части и си струва да се разделят от самото начало, защото всяка се проваля по различен начин:

  • Рецептата: Dockerfile.
  • Съставките: файловете, които рецептата може да използва. Наричат се контекст на изграждане и почти винаги са съдържанието на една папка. В командата docker build . крайната точка е тази папка: „контекстът е папката, в която се намирам“.
  • Кухнята: изграждащият механизъм, който в Docker се казва BuildKit и е част от демона. Спомни си Урок 0: клиентът docker не върши работата; иска я от демона. При изграждане клиентът му изпраща рецептата и съставките, които рецептата иска, а демонът готви.

Документацията за изграждане го казва със същите части: контекстът е множеството от файлове, до които изграждането има достъп, а инструкциите на Dockerfile могат да вземат файлове само оттам (Docker, „Build context“). Това има практическо последствие, което ще използваш в 3.5: онова, което не е в контекста, не може да влезе в образа, а онова, което е в контекста, може да влезе, дори да не искаш.

3.1.2 Два момента: изграждане и стартиране

Има една идея, която подрежда всичко останало в този урок: Dockerfile описва два различни момента.

  1. При изграждане (docker build): Docker тръгва от базов образ, копира файлове и изпълнява команди. Резултатът остава записан в образа. Това става веднъж, на твоя компютър или на сървър за изграждане.
  2. При стартиране (docker run): Docker създава контейнер от този образ и стартира един процес. Това става всеки път, когато някой пусне образа, на който и да е компютър.

Някои инструкции действат в първия момент, а други само отбелязват какво да се направи във втория. Ако объркаш двата момента, пишеш неща като RUN, който се опитва да стартира сървъра (би се изпълнил при изграждане, би останал да чака и изграждането никога не би приключило), или очакваш EXPOSE да отвори порт (само го отбелязва). Таблицата в 3.2.2 разделя всяка инструкция по момент.

3.2 Dockerfile на revisor, ред по ред

3.2.1 Пълната рецепта

Това е Dockerfile, с който завършва урокът. Слага се в корена на проекта на revisor, до go.mod, и се казва точно Dockerfile, без разширение, защото това е името, което docker build търси по подразбиране:

# syntax=docker/dockerfile:1
# fig03_01.Dockerfile — Lección 3: el primer Dockerfile del revisor: una sola etapa.
FROM golang:1.27.3-trixie

WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/revisor ./cmd/revisor

# Un usuario sin privilegios, por número: la imagen no necesita tenerlo en /etc/passwd.
USER 10001:10001
EXPOSE 8080
CMD ["revisor"]

Дванайсет реда, от които седем са инструкции. Да ги минем един по един.

# syntax=docker/dockerfile:1. Прилича на коментар, и за почти целия файл е такъв, но на първия ред е директива: казва на BuildKit коя версия на синтаксиса на Dockerfile да използва. Справочникът го обяснява така: служи да се „декларира версията на синтаксиса на Dockerfile, която ще се използва за изграждането“, и позволява да ползваш нови възможности, без да обновяваш Docker (справочник на Dockerfile, syntax). 1 означава „най-новата версия 1“. Затова в изходите от изграждането ще видиш, че Docker първо разрешава docker.io/docker/dockerfile:1: взема интерпретатора на рецептата, преди да я прочете. Всички Dockerfile в курса започват с този ред.

FROM golang:1.27.3-trixie. Всеки образ тръгва от друг (единственото изключение е празният образ scratch, който няма да ти трябва в този курс). FROM казва кой е базовият образ: тук официалният на Go във версия 1.27.3, изграден върху Debian 13, чието кодово име е trixie. revisor е написан на Go и за да се компилира, е нужен компилаторът на Go; този образ го съдържа, заедно с цял Debian. Docker Hub изброява този таг сред актуалните тагове на образа на Go и уточнява, че имена като bookworm или trixie са кодовите имена на версиите на Debian, върху които е направен (golang в Docker Hub).

WORKDIR /src. Задава работната папка вътре в образа: всичко, което идва след това (COPY, RUN, CMD), се случва там. Ако папката не съществува, Docker я създава (справочник, WORKDIR). По-добре е от това да напишеш RUN cd /src, което не върши работа: всеки RUN започва наново и cd се губи в края на този ред.

COPY . .. Копира от контекста (първата точка) в работната папка на образа (втората точка, която тук е /src). Това е единствената входна точка, през която твоите файлове влизат в образа. Да копираш всичко е най-простото и най-опасното: влиза всичко, което е в контекста. Затова съществува .dockerignore от 3.5. (Копирането на всичко наведнъж прави и повторното изграждане бавно; планът на курса разглежда това в Урок 4.)

RUN go build -o /usr/local/bin/revisor ./cmd/revisor. Изпълнява команда при изграждане и записва резултата в образа. Тук компилира revisor и оставя изпълнимия файл в /usr/local/bin/revisor, папка, която вече е в променливата PATH на образа. Затова по-надолу е достатъчно да напишеш revisor, без път.

USER 10001:10001. Оттук нататък, и най-вече при стартиране, процесът използва потребител 10001 и група 10001 вместо root. Има собствен раздел, 3.4.

EXPOSE 8080. Отбелязва, че програмата слуша на порт 8080. Само го отбелязва: справочникът е ясен, че EXPOSE „всъщност не публикува порта“; за да го публикуваш, е нужно -p при стартиране (справочник, EXPOSE). Служи като документация за онзи, който чете образа, и за инструменти, които го консултират. Портът се публикува, както научи в Урок 2, с -p 127.0.0.1:8080:8080.

CMD ["revisor"]. Отбелязва кой процес да се стартира при стартиране на контейнера. Ефективен може да е само един: ако напишеш няколко CMD, печели последният (справочник, CMD). И това е стойност по подразбиране: ако на docker run подадеш команда след името на образа, тази команда замества CMD (Docker, „Running containers“). Квадратните скоби и кавичките не са украса: те са формата exec, темата на 3.7.

3.2.2 Всяка инструкция в своя момент

Инструкция Кога действа Какво оставя в образа
FROM при изграждане цялото съдържание на базовия образ
WORKDIR при изграждане и отбелязва папката за стартиране папката и стойността откъде да се стартира
COPY при изграждане копираните файлове
RUN при изграждане онова, което командата е записала на диска
USER отбелязва за стартиране (и за следващите RUN) стойността на потребителя
EXPOSE само отбелязва стойността на порта; не отваря нищо
CMD само отбелязва командата, която ще стартира всеки контейнер

Прочети внимателно третата колона: последните три инструкции не добавят файлове, а добавят данни към конфигурацията на образа. Ще можеш да ги прочетеш с docker inspect и, по-нататък, в историята на образа. Имай предвид, че редът има значение: USER, поставен преди RUN, кара този RUN да се изпълни с този потребител. Затова тук USER е след компилирането: компилирането трябва да пише в папки на root, сървърът — не.

3.3 Името и тагът

3.3.1 Частите на едно име

Когато изграждаш, даваш име на образа с -t. Пълното име на един образ има следния вид според документацията на docker image tag: [ANFITRIÓN[:PUERTO]/]ESPACIO/REPOSITORIO[:ETIQUETA] (docker image tag). На практика почти всичко може да се пропусне: golang:1.27.3-trixie не изписва нито регистъра, нито пространството, защото Docker допълва docker.io/library/ (пространството library е това на официалните образи; вече видя това пълно име в Урок 2, в naming to docker.io/library/…). За образ, който живее само на твоя компютър, стигат две части:

  • хранилището (repository), което казва какво е: curso-docker-revisor;
  • тагът (tag), след двоеточието, който казва коя от версиите му: 1.0.0.

Същата документация казва нещо, което си струва да се подчертае: „ако не е зададен таг, Docker използва latest“. Тоест latest не означава „най-новият“. Означава „не са му сложили таг“. Образ, изграден преди две години без таг, се казва latest точно като днешния. В сцената от началото, ако образите се казваха revisor:latest, дори нямаше да можеш да разбереш коя версия е работела, когато е започнал проблемът.

Освен това таговете са прякори, които се местят. Виж реда на базовия образ в Docker Hub: едно и също съдържание носи едновременно таговете 1.27.3-trixie, 1.27-trixie, 1-trixie и trixie (golang в Docker Hub). Когато излезе Go 1.27.4, 1.27-trixie ще започне да сочи към друго, а 1.27.3-trixie ще остане, където е. Затова FROM в този урок използва най-конкретния наличен таг. Дори той не е абсолютна гаранция, защото публикуващият може да качи наново един таг: ръководството с добри практики предупреждава, че „таговете на образите са променливи“ и че за наистина фиксирана версия трябва да се използва нейният отпечатък (digest) (добри практики). Планът на курса оставя тази стъпка за Урок 5; тук е достатъчно да не използваш latest.

3.3.2 Защо curso-docker-revisor, а не revisor

Планът на курса, в таблицата с уроците, вече казва, че тук изграждаш curso-docker-revisor:1.x.0, а не revisor. Струва си да се спрем на този префикс, защото сам по себе си е урок: префиксът на курса.

От Урок 0 насам всичко, което курсът създава на твоя компютър, започва с curso-docker-: контейнерите, мрежите, томовете и, отсега нататък, образите. Този префикс прави три неща за теб:

  1. Избягва сблъсъци. revisor е име, което всеки би използвал. Ако на компютъра ти вече има образ revisor:1.0.0 от друг проект (от друг курс на Hábil, например), твоят docker build -t revisor:1.0.0 му отнема името, без да пита: старият образ остава без таг, а новият заема мястото му. Не е хипотетично: на машината, където беше изпълнен този урок, вече имаше образ с име revisor:1.0.0, от друг проект, който няма нищо общо с този Dockerfile. С префикса двата съжителстват.
  2. Позволява да чистиш само своето. Филтрите на Docker работят по име. docker ps -a --filter name=curso-docker- ти изброява само контейнерите на курса, а docker image ls --filter reference='curso-docker-*' — само неговите образи (формата, която справочникът на docker image ls описва за заместващите знаци). Изтриването на „всичко от курса“ става безопасна команда, която не докосва базите данни, нито образите на други проекти, които имаш на същия компютър.
  3. Казва откъде е дошъл. След шест месеца, когато чистиш и намериш curso-docker-revisor:1.0.0, ще знаеш какво е, без да разследваш.

Правилото за версиите на revisor в този курс е просто: всеки път, когато урок промени образа, версията се увеличава. Това е 1.0.0: първата, която работи изцяло. Когато Dockerfile се промени, тагът не се използва повторно; създава се друг.

3.4 USER: първият Dockerfile вече не работи като root

3.4.1 Какво става, ако не кажеш нищо

В Урок 0 видя, че root вътре в контейнер е потребител 0 на същото ядро, с четиринайсет включени възможности. Сега конкретният въпрос: ако твоят Dockerfile не казва нищо за потребителя, с кого работи revisor? Зависи от базовия образ. fig03_02.sh пита образа на Go:

#!/usr/bin/env bash
# fig03_02.sh — Sin USER, la imagen base corre como root.
# Pregunta con qué usuario arranca la imagen de Go que usa el revisor, y compara
# con un usuario sin privilegios elegido por número.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
IMAGEN=golang:1.27.3-trixie
docker pull -q "$IMAGEN" >/dev/null

echo "== 1. Lo que la imagen base declara como usuario (vacío = root)"
docker image inspect --format 'User="{{.Config.User}}"' "$IMAGEN"

echo
echo "== 2. Quién soy dentro, sin decir nada"
docker run --rm --name "${PREFIJO}quien" "$IMAGEN" id

echo
echo "== 3. Quién soy dentro, con el usuario 10001:10001"
docker run --rm --name "${PREFIJO}quien" --user 10001:10001 "$IMAGEN" id

echo
echo "== 4. ¿Existe ese usuario en /etc/passwd de la imagen?"
docker run --rm --name "${PREFIJO}quien" "$IMAGEN" \
  sh -c 'grep -q ":10001:" /etc/passwd && echo "sí está" || echo "no está"'

echo
echo "== 5. ¿Puede ese usuario abrir un puerto bajo? (primer puerto sin privilegios)"
docker run --rm --name "${PREFIJO}quien" --user 10001:10001 "$IMAGEN" \
  cat /proc/sys/net/ipv4/ip_unprivileged_port_start

echo
echo "== 6. Lo mismo con la red del anfitrión (--network host)"
docker run --rm --name "${PREFIJO}quien" --network host --user 10001:10001 "$IMAGEN" \
  cat /proc/sys/net/ipv4/ip_unprivileged_port_start

echo
echo "== 7. Y el valor del propio anfitrión (sin contenedor)"
cat /proc/sys/net/ipv4/ip_unprivileged_port_start
$ ./fig03_02.sh
== 1. Lo que la imagen base declara como usuario (vacío = root)
User=""

== 2. Quién soy dentro, sin decir nada
uid=0(root) gid=0(root) groups=0(root)

== 3. Quién soy dentro, con el usuario 10001:10001
uid=10001 gid=10001 groups=10001

== 4. ¿Existe ese usuario en /etc/passwd de la imagen?
no está

== 5. ¿Puede ese usuario abrir un puerto bajo? (primer puerto sin privilegios)
0

== 6. Lo mismo con la red del anfitrión (--network host)
0

== 7. Y el valor del propio anfitrión (sin contenedor)
0

Този изход е стабилен. Първия път docker pull изтегля образа на Go, който тежи стотици мегабайти; имай търпение. --user прави при стартиране същото, което USER прави в Dockerfile, и тук служи да изпробваш потребителя, без да изграждаш нищо.

Чети го отгоре надолу. Базовият образ декларира празен потребител, а празен означава root: блок 2 го потвърждава с uid=0(root). Dockerfile, който тръгва от golang и не казва нищо, наследява това и revisor би обслужвал заявки от мрежата като потребител 0 на твоето ядро. С потребител 10001 блок 3 казва uid=10001 gid=10001, без име в скоби. А блок 4 обяснява защо: този потребител не съществува в /etc/passwd на образа.

3.4.2 Едно число стига

Това е правилно, и е нарочно. За ядрото потребителят е число; името е удобство, което живее в /etc/passwd. Справочникът за USER приема име или число във вида USER <UID>[:<GID>] (справочник, USER), а да напишеш двете числа има две предимства. Първото: не е нужно да създаваш потребителя в образа. Това стига на revisor, който не пита нито за името си, нито за личната си папка; програма, която го прави, ще открие, че личната ѝ папка е /, където не може да пише (това ще видиш в Решение 1). За такива случаи потребителят се създава в образа или се използва основа, която вече го има, като nonroot от Урок 5. Второто: групата остава фиксирана; ако зададеш потребител без група и този потребител няма основна група, справочникът предупреждава, че процесът работи с групата на root. С 10001:10001 няма изненади.

Защо 10001? Защото е далеч от числата, които дистрибуциите използват за собствените си потребители и за хората в екипа (в Mint твоят потребител обикновено е 1000). Високо и собствено число намалява възможността процесът на контейнера да съвпадне с реален потребител на хоста.

Ръководството с добри практики го изисква без заобикалки: „ако една услуга може да работи без привилегии, използвай USER, за да превключиш към потребител, който не е root“ (добри практики, USER). На revisor не му трябва нищо от root: слуша на 8080, висок порт, и не пише в системни папки. (Портовете под 1024 са тези, които в Linux изискват root или специална capability; първият, който не я изисква, е записан в net.ipv4.ip_unprivileged_port_start, който по подразбиране е 1024 и е стойност за всяко мрежово пространство от имена (документация на ядрото). Вътре в контейнер на Docker със собствена мрежа тази стойност е 0: блок 5 на fig03_02.sh я измери и даде 0, което означава, че всеки порт може да се отвори без привилегии. Но това не е закон: с --network host контейнерът споделя мрежата на хоста и вижда стойността на хоста. На машината на този урок тази стойност също е 0 (виртуалната машина я задава така), така че блокове 5, 6 и 7 дават 0 и не различават случаите; на току-що инсталиран Mint би трябвало да видиш 0 в 5 и 1024 в 6 и 7. Тази разлика не беше измерена тук.) Затова още от първия Dockerfile работи като 10001. Планът на курса го казва така: сигурността не е урок, а навик.

3.5 Контекстът и .dockerignore

3.5.1 Каквото пътува, свършва вътре

Сега опасната съставка: COPY . .. Тя копира целия контекст. В реален проект папката съдържа повече от код: .git с историята, .env с пароли за тестване на твоя компютър, архив .tar, който си оставил там миналата седмица. fig03_04.sh изгражда revisor с Dockerfile от този урок и с симулиран файл .env в папката, първо без .dockerignore и после с такъв, и гледа какво е останало в /src вътре в образа:

#!/usr/bin/env bash
# fig03_04.sh — Lo que viaja en el contexto acaba dentro de la imagen.
# Construye el revisor con un archivo .env de mentira en la carpeta: primero SIN
# .dockerignore y después CON el de fig03_03. Mira qué quedó en /src cada vez.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
SIN="${PREFIJO}revisor:1.0.0-sin-ignore"
CON="${PREFIJO}revisor:1.0.0-con-ignore"
trap 'docker rmi -f "$SIN" "$CON" >/dev/null 2>&1 || true; rm -rf "$TRABAJO"' EXIT

# El proyecto: el código que entrega el curso + el Dockerfile de la lección.
cp -R "$AQUI/../revisor/." "$TRABAJO/"
cp "$AQUI/fig03_01.Dockerfile" "$TRABAJO/Dockerfile"
cd "$TRABAJO"
# Un secreto DE MENTIRA, como el que cualquiera deja en la carpeta del proyecto.
echo 'REVISOR_TOKEN=curso-docker-env-de-mentira-3c1d' > .env

echo "== 1. SIN .dockerignore: lo que hay en /src dentro de la imagen"
docker build -q -t "$SIN" . >/dev/null
docker run --rm --name "${PREFIJO}mira" "$SIN" ls -A /src
echo "-- y el .env, leído desde la imagen:"
docker run --rm --name "${PREFIJO}mira" "$SIN" cat /src/.env

echo
echo "== 2. CON .dockerignore: lo que hay en /src dentro de la imagen"
cp "$AQUI/fig03_03.dockerignore" .dockerignore
docker build -q -t "$CON" . >/dev/null
docker run --rm --name "${PREFIJO}mira" "$CON" ls -A /src
$ ./fig03_04.sh
== 1. SIN .dockerignore: lo que hay en /src dentro de la imagen
.env
Dockerfile
cmd
go.mod
internal
-- y el .env, leído desde la imagen:
REVISOR_TOKEN=curso-docker-env-de-mentira-3c1d

== 2. CON .dockerignore: lo que hay en /src dentro de la imagen
cmd
go.mod
internal

Този изход е стабилен. Скриптът работи във временна папка (mktemp -d), за да не замърсява programas/revisor/, който е кодът, предоставен от курса, и не се редактира, и изтрива двата си образа при излизане. docker build -q изгражда тихо (quiet); ls -A изброява и файловете, които започват с точка. Двете изграждания траят колкото едно нормално, защото всяко компилира наново: при изпълнението на този урок целият скрипт отне малко повече от минута, с вече изтеглен базов образ.

Виж първия блок. .env е влязъл в образа и се чете с един cat. Всеки, който има образа — колегата ти, сървърът, публичен регистър, ако някой ден го качиш —, има и тази тайна. И не е нужно да стартираш образа, за да я извадиш: достатъчно е да го запишеш във файл и да го отвориш. Влязъл е и самият Dockerfile, който вътре е безполезен. Във втория блок, с .dockerignore, /src съдържа само онова, което е нужно на програмата, за да се компилира: cmd, go.mod и internal.

3.5.2 .dockerignore на урока

Ето файла. Казва се точно .dockerignore, с точката, и стои в корена на контекста, до Dockerfile:

# fig03_03.dockerignore
# Lo que no entra a la imagen. (El Dockerfile y este archivo sí viajan al
# constructor porque los necesita, pero COPY no los mete en la imagen.)
.git
.env
*.tar
Dockerfile*
.dockerignore

Всеки ред е шаблон, подобен на заместващите символи на shell; редовете с # са коментари (Docker, „Build context“, файлове .dockerignore). Документацията казва къде се търси: клиентът търси файл с име .dockerignore „в кореновата папка на контекста“. Всеки ред има своята причина:

  • .git: цялата история на проекта. Може да тежи много повече от кода и може да пази тайни, които някой е изтрил от кода, но не и от историята.
  • .env: мястото, където хората пазят пароли за тестване на компютъра си. Това е точно файлът от примера.
  • *.tar: архиви и експортирани файлове, които остават в папката, без никой да си спомня за тях. Внимавай с обхвата: този шаблон обхваща само .tar в корена на контекста; такъв в подпапка продължава да пътува. Проверено е с fig03_13.sh (по-долу). За да ги изключиш на всяко ниво, шаблонът е **/*.tar.
  • Dockerfile* и .dockerignore: рецептата няма защо да е вътре в ястието. А тогава как изгражда Docker, ако изключиш Dockerfile? Документацията отговаря: тези два файла „все пак се изпращат на изграждащия механизъм, защото са му нужни за изграждането“; шаблонът пречи само на COPY да ги вкара в образа. Звездичката в Dockerfile* обхваща и Dockerfile.forma-shell от 3.7.

fig03_13.sh го измерва с един .tar в корена и друг в respaldos/ и изброява онези, които са стигнали до образа:

#!/usr/bin/env bash
# fig03_13.sh — Dónde alcanza un patrón de .dockerignore: «*.tar» contra «**/*.tar».
# Construye una imagen mínima con un .tar en la raíz y otro en una subcarpeta, y
# lista los .tar que llegaron adentro. Borra lo que crea.
set -euo pipefail
PREFIJO="${PREFIJO:-curso-docker-}"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}ctx:1.0.0-tar"
trap 'docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true; rm -rf "$TRABAJO"' EXIT
cd "$TRABAJO"
mkdir respaldos
touch raiz.tar respaldos/enero.tar
printf 'FROM golang:1.27.3-trixie\nCOPY . /ctx\n' > Dockerfile

muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}

echo '*.tar' > .dockerignore
echo "## .dockerignore con: *.tar"
docker build -q -t "$IMAGEN" . >/dev/null
muestra "docker run --rm --name ${PREFIJO}mira $IMAGEN find /ctx -name '*.tar'"

echo
echo '**/*.tar' > .dockerignore
echo "## .dockerignore con: **/*.tar"
docker build -q -t "$IMAGEN" . >/dev/null
muestra "docker run --rm --name ${PREFIJO}mira $IMAGEN find /ctx -name '*.tar'"
$ ./fig03_13.sh
## .dockerignore con: *.tar
$ docker run --rm --name curso-docker-mira curso-docker-ctx:1.0.0-tar find /ctx -name '*.tar'
/ctx/respaldos/enero.tar

## .dockerignore con: **/*.tar
$ docker run --rm --name curso-docker-mira curso-docker-ctx:1.0.0-tar find /ctx -name '*.tar'

С *.tar този в корена остана навън, а respaldos/enero.tar влезе в образа; с **/*.tar — нито един. Този изход е стабилен.

Една подробност при четенето: ред, който започва с !, прави изключение („това да, макар предишен шаблон да го изключва“), а когато няколко шаблона съвпадат с един файл, печели последният. Днес не ти трябва, но ще го видиш в чужди проекти.

3.6 Изграждане, провал и стартиране

3.6.1 Изграждане

С Dockerfile и .dockerignore в папката на revisor изграждането е една-единствена команда. fig03_05.sh сглобява проекта във временна папка, изгражда, предизвиква първата грешка, стартира услугата правилно, изпраща ѝ заявки и я спира:

#!/usr/bin/env bash
# fig03_05.sh — Construir el revisor, verlo fallar sin configuración, correrlo
# bien y detenerlo. CMD en forma exec. Borra lo que crea al salir.
set -euo pipefail
# Nombres y puerto por omisión: los del curso. Se pueden cambiar para no chocar
# con otra corrida en el mismo equipo (PREFIJO=... PUERTO=... ./figNN.sh).
PREFIJO="${PREFIJO:-curso-docker-}"
PUERTO="${PUERTO:-8080}"
LIMITES="--memory 64m --cpus 0.5 --pids-limit 50"   # los tres límites de la Lección 2
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"

# El proyecto: el código que entrega el curso + los archivos de esta lección.
cp -R "$AQUI/../revisor" "$TRABAJO/revisor"
cp "$AQUI/estado/Dockerfile" "$AQUI/estado/.dockerignore" "$TRABAJO/revisor/"
cp "$AQUI/fig03_06.Dockerfile" "$TRABAJO/revisor/Dockerfile.forma-shell"
cd "$TRABAJO/revisor"

titulo() { printf '\n## %s\n' "$*"; }
# muestra: imprime el comando con «$ » y lo ejecuta (salida y error juntos)
muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}
# t: milisegundos desde la época, para medir duraciones
t() {
  local x; x=$(date +%s%3N)
  case "$x" in *N) python3 -c 'import time; print(int(time.time() * 1000))' ;; *) echo "$x" ;; esac
}
esperar_http() {
  for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
  echo "(no respondió $1)"; return 1
}
trap 'docker rm -f ${PREFIJO}revisor >/dev/null 2>&1; docker rmi -f ${PREFIJO}revisor:1.0.0 >/dev/null 2>&1; rm -rf "$TRABAJO"' EXIT

titulo "Construir y medir"
muestra "docker build -q -t ${PREFIJO}revisor:1.0.0 . >/dev/null && docker image ls ${PREFIJO}revisor:1.0.0 --format '{{.Repository}}:{{.Tag}} {{.Size}}'"

titulo "El error: arrancar sin configuración"
muestra "docker run --name ${PREFIJO}revisor ${LIMITES} ${PREFIJO}revisor:1.0.0"
muestra "docker ps -a --filter name=${PREFIJO}revisor --format '{{.Names}} {{.Status}}'"
docker rm ${PREFIJO}revisor >/dev/null

titulo "Correrlo bien"
muestra "docker run -d --name ${PREFIJO}revisor -p 127.0.0.1:${PUERTO}:8080 ${LIMITES} -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' ${PREFIJO}revisor:1.0.0 >/dev/null"
esperar_http "http://127.0.0.1:${PUERTO}/salud"
muestra "curl -s http://127.0.0.1:${PUERTO}/salud"
muestra "curl -s 'http://127.0.0.1:${PUERTO}/api/estados?formato=tabla' | sed -E 's/[0-9.]+(ms|µs)/<tiempo>/'"
muestra "docker exec ${PREFIJO}revisor id"
muestra "docker top ${PREFIJO}revisor -o pid,user,args"

titulo "docker stop con forma exec"
inicio=$(t); docker stop ${PREFIJO}revisor >/dev/null; fin=$(t)
echo "tardó $(( fin - inicio )) ms"
muestra "docker logs ${PREFIJO}revisor"

Скриптът е дълъг заради помощните функции в началото, които ще разпознаеш в следващите: muestra отпечатва всяка команда с $ отпред, преди да я изпълни, за да знаеш в изхода кое какво е произвело, а ако командата се провали, отбелязва кода ѝ на изход, вместо да спре скрипта; t дава часа в милисекунди за хронометриране; esperar_http пита услугата за състоянието ѝ, докато не отговори, защото току-що стартиран контейнер има нужда от миг, преди да започне да слуша. Първата част от изхода:

## Construir y medir
$ docker build -q -t curso-docker-revisor:1.0.0 . >/dev/null && docker image ls curso-docker-revisor:1.0.0 --format '{{.Repository}}:{{.Tag}} {{.Size}}'
curso-docker-revisor:1.0.0 1.45GB

1,45 GB. Компилираният revisor е един-единствен файл от няколко мегабайта; всичко останало е онова, което носи базовият образ: цял Debian, компилаторът на Go и инструментите му, плюс онова, което компилирането е оставило в образа. Нищо от това не е нужно, за да се стартира revisor, а само за да се изгради. Запиши числото в дневника си: това е изходната точка, а планът на курса посвещава Урок 5 на това да го смали. На твоя компютър числото може да варира малко според архитектурата.

В твоята папка: образът, който остава

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

mkdir -p ~/curso-docker/revisor
cp -R programas/revisor/. ~/curso-docker/revisor/
cp programas/03-primer-dockerfile/estado/Dockerfile \
   programas/03-primer-dockerfile/estado/.dockerignore ~/curso-docker/revisor/
cd ~/curso-docker/revisor
ls -A
docker build -q -t curso-docker-revisor:1.0.0 .
docker image ls curso-docker-revisor --format '{{.Repository}}:{{.Tag}} {{.Size}}'
$ ls -A
.dockerignore
Dockerfile
cmd
go.mod
internal
$ docker build -q -t curso-docker-revisor:1.0.0 .
sha256:1eb7de835928…
$ docker image ls curso-docker-revisor --format '{{.Repository}}:{{.Tag}} {{.Size}}'
curso-docker-revisor:1.0.0 1.45GB

sha256:…, който отпечатва docker build -q, е идентификаторът на образа на твоя компютър и се променя при всяко изграждане; тук е съкратен. (Отпечатъкът, който служи за фиксиране на основа, този от 3.3.1, е онзи, който публикува регистърът; Урок 5 го използва.) Този изход е на fig03_09.sh, който прави същите команди във временна папка и с тага curso-docker-revisor:1.0.0-carpeta (и префикса за изпълнения от „За изпълненията“), за да не пипа твоята; тук е показан с тага, който ти ще използваш, и е запазен такъв, какъвто е, в fig03_09.salida.txt. Редът, в който ls -A изброява файловете, може да варира според езика на системата ти. cp -R programas/revisor/. … копира съдържанието на папката, така че повтарянето му не създава вложена revisor/revisor. Ето fig03_09.sh, ако искаш да видиш как е сглобено изпълнението:

#!/usr/bin/env bash
# fig03_09.sh — «En tu carpeta»: armar la carpeta del revisor y construir la imagen
# que se queda. Aquí se hace en una carpeta temporal y con la etiqueta 1.0.0-carpeta,
# para no tocar tu ~/curso-docker ni tu curso-docker-revisor:1.0.0. Borra lo que crea.
set -euo pipefail
# Nombres por omisión: los del curso. Se pueden cambiar para no chocar con otra
# corrida en el mismo equipo (PREFIJO=... ./fig03_09.sh).
PREFIJO="${PREFIJO:-curso-docker-}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
IMAGEN="${PREFIJO}revisor:1.0.0-carpeta"
trap 'docker rmi -f "$IMAGEN" >/dev/null 2>&1 || true; rm -rf "$TRABAJO"' EXIT

# muestra: imprime el comando con «$ » y lo ejecuta (salida y error juntos)
muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}

# Los comandos del bloque de la lección, desde la raíz del repositorio del curso;
# la carpeta ~/curso-docker es aquí una carpeta temporal.
cd "$AQUI/../.."
mkdir -p "$TRABAJO/curso-docker/revisor"
cp -R programas/revisor/. "$TRABAJO/curso-docker/revisor/"
cp programas/03-primer-dockerfile/estado/Dockerfile \
   programas/03-primer-dockerfile/estado/.dockerignore "$TRABAJO/curso-docker/revisor/"
cd "$TRABAJO/curso-docker/revisor"

muestra "ls -A"
muestra "docker build -q -t $IMAGEN ."
muestra "docker image ls ${PREFIJO}revisor --format '{{.Repository}}:{{.Tag}} {{.Size}}'"

Оттук нататък, когато урокът казва „в папката на revisor“, това е ~/curso-docker/revisor, а именно този curso-docker-revisor:1.0.0 остава на компютъра ти накрая. Командите от 3.6.2 и 3.6.3 можеш да ги въвеждаш там точно както са написани, с едно предупреждение, казано във всяка от тях: името curso-docker-revisor може да го има само един контейнер наведнъж.

3.6.2 Първата грешка: стартиране без конфигурация

## El error: arrancar sin configuración
$ docker run --name curso-docker-revisor --memory 64m --cpus 0.5 --pids-limit 50 curso-docker-revisor:1.0.0
revisor: falta REVISOR_SERVICIOS (ejemplo: REVISOR_SERVICIOS="prueba http://curso-docker-prueba:9000/ 2s")
[código de salida: 1]
$ docker ps -a --filter name=curso-docker-revisor --format '{{.Names}} {{.Status}}'
curso-docker-revisor Exited (1) Less than a second ago

Образът е наред; контейнерът стартира и revisor отказа да работи. revisor се конфигурира с променливи на средата, а единствената задължителна е REVISOR_SERVICIOS, списъкът с услуги, които трябва да проверява. Без нея отпечатва какво му липсва, с пример, и излиза с код 1. docker ps -a го показва като Exited (1): процесът е приключил сам, със собствена грешка. Сравни го със 137 от Урок 0: там някой уби процеса; тук процесът реши да си тръгне. Да можеш да разчетеш тази разлика ти спестява търсенето на проблема на грешното място. Забележи също, че контейнерът остана създаден, макар да е приключил: скриптът го изтрива с docker rm, преди да продължи, защото името е заето. Ако въвеждаш командите сам, направи същото, преди да минеш към 3.6.3: docker rm curso-docker-revisor.

3.6.3 Стартиране по правилния начин

## Correrlo bien
$ docker run -d --name curso-docker-revisor -p 127.0.0.1:8080:8080 --memory 64m --cpus 0.5 --pids-limit 50 -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' curso-docker-revisor:1.0.0 >/dev/null
$ curl -s http://127.0.0.1:8080/salud
{"estado":"vivo","version":"desarrollo"}
$ curl -s 'http://127.0.0.1:8080/api/estados?formato=tabla' | sed -E 's/[0-9.]+(ms|µs)/<tiempo>/'
SERVICIO  ESTADO    TIEMPO  DETALLE
propio    OK           <tiempo>  200
$ docker exec curso-docker-revisor id
uid=10001 gid=10001 groups=10001
$ docker top curso-docker-revisor -o pid,user,args
PID                 USER                COMMAND
2246176             10001               revisor

Номерът на процеса се променя при всяко изпълнение; всичко останало е стабилно, с изключение на времето в таблицата: sed в командата е там, за да го скрие, когато излезе в милисекунди, а при това изпълнение излезе така, затова виждаш <tiempo> (при друго изпълнение може да излезе 0s и тогава няма какво да се скрива).

-p 127.0.0.1:8080:8080 и трите ограничения (--memory 64m --cpus 0.5 --pids-limit 50) са навиците от Урок 2, приложени още от първия контейнер на revisor: портът само за твоя компютър и таван за памет, CPU и процеси. Тези числа са от Урок 2, а не измерване на revisor; ако по-нататък е нужно повече, се измерва и се увеличава. -e подава променлива на средата на контейнера. Тук revisor проверява сам себе си: списъкът има една-единствена услуга, propio, която сочи към собствената му проверка на състоянието. Това е начин да провериш, че всичко работи, без друг контейнер, защото свързването на контейнери помежду им е тема на мрежите и планът на курса я оставя за Урок 8. Таблицата казва OK и 200.

/salud отговаря "version":"desarrollo". Това не е грешка: образът е изграден, без в програмата да е записана версия, и revisor е направен така, че да издава това. Внимавай с разликата: образът има таг 1.0.0, но програмата вътре не знае в коя версия е. Това са две различни неща и свързването им е задача на по-късен урок.

Сега последните два реда, които са важните в този раздел. docker exec ... id казва uid=10001: USER от Dockerfile е сработил. А docker top, който изброява процесите на един контейнер, видени от хоста, показва един-единствен процес: revisor, на потребител 10001. Този единствен ред е формата exec в действие. Запомни го, защото в следващия раздел ще видиш такъв с два реда. Когато приключиш с пробите: docker stop curso-docker-revisor && docker rm curso-docker-revisor, за да остане името свободно и docker ps -a — чист.

3.7 Формата exec срещу формата shell

3.7.1 Два начина да се напише едно и също

CMD (а също RUN и ENTRYPOINT, сестринската инструкция на CMD, която задава главната програма на образа и която ще използваш от Урок 5 нататък) може да се напише по два начина (справочник на Dockerfile, форми shell и exec):

  • Форма exec: списък във формат JSON, с квадратни скоби и двойни кавички: CMD ["revisor"]. Docker стартира тази програма директно, без нищо по средата.
  • Форма shell: обикновен текст: CMD revisor. Docker го обвива в shell; в Linux — в /bin/sh -c "revisor". Справочникът го казва така: за разлика от формата exec, инструкциите във форма shell „винаги използват shell“.

Формата shell съществува с причина: с shell по средата можеш да ползваш променливи ($HOME), конвейери (|) и да свързваш команди (&&). Затова в RUN се използва много и там е добре да се използва. Проблемът се появява, когато я използваш за главния процес на контейнера, този, който стартира CMD.

За да го видиш, урокът съдържа копие на Dockerfile, в което се променя само последният ред. Съществува единствено за сравнение:

# syntax=docker/dockerfile:1
# fig03_06.Dockerfile — Lección 3: la MISMA imagen, pero con CMD en forma shell. Existe solo para
# comparar: así NO se escribe.
FROM golang:1.27.3-trixie
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/revisor ./cmd/revisor
USER 10001:10001
EXPOSE 8080
CMD revisor

В проекта се записва като Dockerfile.forma-shell и се изгражда с docker build -f Dockerfile.forma-shell: опцията -f казва на Docker коя рецепта да използва, когато не е тази с име Dockerfile.

3.7.2 Измерването

Първо, половината, която вече видя: краят на fig03_05.sh, който спира контейнера с форма exec и показва логовете му:

## docker stop con forma exec
tardó 562 ms
$ docker logs curso-docker-revisor
revisor desarrollo escuchando en :8080 (1 servicio(s))
revisor: GET /salud 200 0s
revisor: GET /salud 200 0s
revisor: GET /salud 200 0s
revisor: GET /api/estados 200 1ms
revisor: recibí terminated, apagando
revisor: apagado limpio

Времената (562 ms и 0s и 1ms на всяка заявка) се менят при всяко изпълнение; последователността от редове е стабилна, макар броят на GET /salud да зависи от това колко пъти е питал esperar_http, преди услугата да отговори. Последните два реда са важните: revisor получи сигнала (terminated е името, което Go дава на SIGTERM), довърши онова, което правеше, и се сбогува. Всичко за 562 милисекунди при това изпълнение, със силно натоварена машина.

Сега другата половина. fig03_07.sh изгражда образа с форма shell, стартира го със същата конфигурация, разглежда процесите му и го спира:

#!/usr/bin/env bash
# fig03_07.sh — La MISMA imagen con CMD en forma shell: quién es el PID 1,
# cuánto tarda docker stop y con qué código muere. Borra lo que crea al salir.
set -euo pipefail
# Nombres y puerto por omisión: los del curso. Se pueden cambiar para no chocar
# con otra corrida en el mismo equipo (PREFIJO=... PUERTO=... ./figNN.sh).
PREFIJO="${PREFIJO:-curso-docker-}"
PUERTO="${PUERTO:-8080}"
LIMITES="--memory 64m --cpus 0.5 --pids-limit 50"   # los tres límites de la Lección 2
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"

# El proyecto: el código que entrega el curso + los archivos de esta lección.
cp -R "$AQUI/../revisor" "$TRABAJO/revisor"
cp "$AQUI/estado/Dockerfile" "$AQUI/estado/.dockerignore" "$TRABAJO/revisor/"
cp "$AQUI/fig03_06.Dockerfile" "$TRABAJO/revisor/Dockerfile.forma-shell"
cd "$TRABAJO/revisor"

titulo() { printf '\n## %s\n' "$*"; }
# muestra: imprime el comando con «$ » y lo ejecuta (salida y error juntos)
muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}
# t: milisegundos desde la época, para medir duraciones
t() {
  local x; x=$(date +%s%3N)
  case "$x" in *N) python3 -c 'import time; print(int(time.time() * 1000))' ;; *) echo "$x" ;; esac
}
esperar_http() {
  for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
  echo "(no respondió $1)"; return 1
}
trap 'docker rm -f ${PREFIJO}revisor-shell >/dev/null 2>&1; docker rmi -f ${PREFIJO}revisor:1.0.0-shell >/dev/null 2>&1; rm -rf "$TRABAJO"' EXIT

titulo "docker stop con forma shell"
docker build -q -t ${PREFIJO}revisor:1.0.0-shell -f Dockerfile.forma-shell . >/dev/null
docker run -d --name ${PREFIJO}revisor-shell ${LIMITES} -e REVISOR_SERVICIOS='propio http://127.0.0.1:8080/salud' ${PREFIJO}revisor:1.0.0-shell >/dev/null
sleep 1
muestra "docker top ${PREFIJO}revisor-shell -o pid,user,args"
inicio=$(t); docker stop ${PREFIJO}revisor-shell >/dev/null; fin=$(t)
echo "tardó $(( fin - inicio )) ms"
muestra "docker inspect --format 'ExitCode={{.State.ExitCode}}' ${PREFIJO}revisor-shell"
muestra "docker logs ${PREFIJO}revisor-shell"

Помощните функции в началото са същите като във fig03_05.sh. Тагът 1.0.0-shell и името curso-docker-revisor-shell са спомагателни: живеят само колкото трае скриптът.

## docker stop con forma shell
$ docker top curso-docker-revisor-shell -o pid,user,args
PID                 USER                COMMAND
2254874             10001               /bin/sh -c revisor
2254924             10001               revisor
tardó 10365 ms
$ docker inspect --format 'ExitCode={{.State.ExitCode}}' curso-docker-revisor-shell
ExitCode=137
$ docker logs curso-docker-revisor-shell
revisor desarrollo escuchando en :8080 (1 servicio(s))

Номерата на процесите се менят; времето се мени с няколкостотин милисекунди, но винаги остава малко над 10 секунди; ExitCode=137 и логът от един-единствен ред са стабилни. Това е сцената от началото, цялата.

3.7.3 Какво стана: сигналът, който не стигна

Сложи двата изхода на docker top един до друг. С форма exec имаше един процес, revisor. С форма shell има два: /bin/sh -c revisor и, като негово дете, revisor. Shell стартира пръв, така че shell е процес номер 1 на контейнера (спомни си Урок 0: вътре в контейнера главната програма е номер 1). revisor остана като негово дете.

Сега веригата на docker stop според документацията ѝ: изпраща на главния процес на контейнера сигнала SIGTERM, чака срок, който по подразбиране е 10 секунди в Linux, и ако процесът е още жив, му изпраща SIGKILL (docker container stop). „Главният процес“ е номер 1. С форма exec номер 1 е revisor, който е написан да обработва SIGTERM: получава го, спира подредено и излиза. С форма shell номер 1 е /bin/sh.

Тук се срещат два факта. Първият: shell не препраща сигнала на детето си. Обяснението на правилото JSONArgsRecommended го казва дословно: с форма shell програмата „работи като дъщерен процес на shell, който не предава сигналите“ (Docker, JSONArgsRecommended). Справочникът на Dockerfile го казва за формата shell на ENTRYPOINT черно на бяло: изпълнимият файл „няма да бъде PID 1 на контейнера и няма да получава сигнали на Unix“ (справочник, ENTRYPOINT). Вторият факт е на ядрото и е по-фин: процес номер 1 в едно пространство от имена на процеси е специален. docker stop изпраща сигнала извън контейнера, от пространството от имена на хоста, което е „предшественик“ на контейнера. За този случай наръчникът на Linux казва, че до процес номер 1 стигат само сигналите, за които е установил обработчик, и че SIGKILL и SIGSTOP са изключението: тези два „се доставят принудително“, когато идват от пространство от имена предшественик (pid_namespaces(7)). Shell не е инсталирал обработчик за SIGTERM, така че сигналът дори не убива него: губи се.

С това изходът се чете сам:

  1. docker stop изпраща SIGTERM на номер 1, shell. Губи се. revisor, отдолу, така и не разбира.
  2. Минават 10 секунди. Това е почти цялото време, което измери: 10 365 ms са 10 000-те на срока плюс онова, което отнемат Docker и ядрото (с натоварената машина).
  3. docker stop изпраща SIGKILL, който според наръчника за сигналите „не може да бъде уловен, блокиран или пренебрегнат“ (signal(7)). Shell и revisor умират изведнъж.
  4. Docker отбелязва ExitCode=137. Вече знаеш да го разчиташ от Урок 0: 128 плюс 9, а 9 е номерът на SIGKILL.
  5. Логът има един-единствен ред. Не пише recibí terminated, защото revisor не е получил нищо, и не пише apagado limpio, защото не е спрял: убили са го насред работата. Ако в този миг е пишел отчет, отчетът е останал недовършен.

Забележи, че кодът на revisor е точно същият в двата образа. Той умее да спира: доказа го за няколко десети от секундата. Промени се това, че с shell по средата сигналът така и не стигна до него. И не си струва да проучваш как се държи shell на всеки базов образ: с формата exec няма shell по средата и въпросът изчезва.

Уроци 0 и 2 ти оставиха този проблем записан за по-нататък. Тук видя половината, която зависи от Dockerfile: формата exec поставя програмата ти като процес номер 1. Урок 7 носи другата половина: какви задължения има този процес номер 1 към процесите, които създава (--init), как се сменя сигналът със STOPSIGNAL и как се мери спирането заедно с проверката на състоянието.

3.7.4 Кога наистина е нужен shell

Понякога стартирането изисква нещо, което само shell дава, например да прочете променлива. Същата страница на правилото дава два изхода, които остават честни: да напишеш малък стартов скрипт и да го извикаш във форма exec, или да декларираш shell нарочно с инструкцията SHELL. И в двата случая програмата отново е дете на shell и сигналът отново е твоя отговорност, например с exec в края на скрипта, който заменя shell с твоята програма. На revisor не му трябва нищо от това: сам чете променливите си. Практическото правило на този курс остава така: CMD и ENTRYPOINT — винаги във форма exec.

3.8 Проверка преди изграждане: docker build --check

Онова, което току-що измери с хронометър, Docker може да го открие, като прочете рецептата, без да изгражда нищо. docker build --check пуска проверките при изграждане (build checks): правила, които търсят често срещани грешки в Dockerfile. Документацията казва, че вместо да изпълнява стъпките на изграждането, командата „само пуска проверките и докладва какво открие“ и че те съществуват от версия 1.8 на синтаксиса на Dockerfile и Buildx 0.15.0 (Docker, „Build checks“). Твоят Docker има много по-нова версия. fig03_08.sh проверява двата Dockerfile на урока:

#!/usr/bin/env bash
# fig03_08.sh — Revisar los dos Dockerfile ANTES de construir, con
# docker build --check. No construye nada ni crea contenedores.
set -euo pipefail
# Nombres y puerto por omisión: los del curso. Se pueden cambiar para no chocar
# con otra corrida en el mismo equipo (PREFIJO=... PUERTO=... ./figNN.sh).
PREFIJO="${PREFIJO:-curso-docker-}"
PUERTO="${PUERTO:-8080}"
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"

# El proyecto: el código que entrega el curso + los archivos de esta lección.
cp -R "$AQUI/../revisor" "$TRABAJO/revisor"
cp "$AQUI/estado/Dockerfile" "$AQUI/estado/.dockerignore" "$TRABAJO/revisor/"
cp "$AQUI/fig03_06.Dockerfile" "$TRABAJO/revisor/Dockerfile.forma-shell"
cd "$TRABAJO/revisor"

titulo() { printf '\n## %s\n' "$*"; }
# muestra: imprime el comando con «$ » y lo ejecuta (salida y error juntos)
muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}
# t: milisegundos desde la época, para medir duraciones
t() {
  local x; x=$(date +%s%3N)
  case "$x" in *N) python3 -c 'import time; print(int(time.time() * 1000))' ;; *) echo "$x" ;; esac
}
esperar_http() {
  for _ in $(seq 40); do curl -fsS -o /dev/null "$1" 2>/dev/null && return 0; sleep 0.5; done
  echo "(no respondió $1)"; return 1
}
trap 'rm -rf "$TRABAJO"' EXIT

titulo "Revisar el Dockerfile antes de construir"
muestra "docker build --check ."
muestra "docker build --check -f Dockerfile.forma-shell ."

Първият, добрият:

## Revisar el Dockerfile antes de construir
$ docker build --check .
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile
#1 transferring dockerfile: 372B 0.0s done
#1 DONE 0.0s

#2 resolve image config for docker-image://docker.io/docker/dockerfile:1
#2 DONE 0.2s

#3 docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e
#3 resolve docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e 0.0s done
#3 CACHED

#4 [internal] load metadata for docker.io/library/golang:1.27.3-trixie
#4 DONE 0.2s

#5 [internal] load .dockerignore
#5 transferring context: 231B done
#5 DONE 0.0s
Check complete, no warnings found.

Времената и дългият digest след dockerfile:1@sha256: се менят (този digest е точната версия на интерпретатора на синтаксиса, който Docker е използвал в този ден); менят се също размерът в байтове на transferring dockerfile и на transferring context, които зависят от коментарите във файловете. Стабилни са последователността от стъпки и последният ред. Номерираните стъпки са онова, което BuildKit е направил: зареждане на рецептата (#1), разрешаване на интерпретатора, който изисква редът # syntax (#2 и #3), запитване за данните на базовия образ, без да се изтегля целият (#4), и прочитане на .dockerignore (#5). Нямаше COPY, нито RUN: нищо не беше изградено. А накрая — Check complete, no warnings found.

Вторият, този с формата shell:

$ docker build --check -f Dockerfile.forma-shell .
#0 building with "default" instance using docker driver

#1 [internal] load build definition from Dockerfile.forma-shell
#1 transferring dockerfile: 355B done
#1 DONE 0.0s

#2 resolve image config for docker-image://docker.io/docker/dockerfile:1
#2 DONE 0.1s

#3 docker-image://docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e
#3 resolve docker.io/docker/dockerfile:1@sha256:4edf897a3ffa55b89f906fc8cc78afdb3f1834cc9c7083565e611a8a7d5fe99e done
#3 CACHED

#4 [internal] load metadata for docker.io/library/golang:1.27.3-trixie
#4 DONE 0.1s

#5 [internal] load .dockerignore
#5 transferring context: 231B done
#5 DONE 0.0s
Check complete, 1 warning has been found!

WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
Dockerfile.forma-shell:10
--------------------
   8 |     USER 10001:10001
   9 |     EXPOSE 8080
  10 | >>> CMD revisor
  11 |     
--------------------
[código de salida: 1]

Ето я сцената от началото, открита преди изграждането. Предупреждението има четири части. Името на правилото, JSONArgsRecommended, с адреса на обяснението му. Изречението: „препоръчват се аргументи JSON в CMD, за да се избегне нежелано поведение, свързано със сигналите на операционната система“. Точното място: Dockerfile.forma-shell:10. И редът, посочен с >>>. Освен това командата излиза с код 1: документацията уточнява, че за разлика от нормалното изграждане, с --check всяко нарушение кара командата да завърши с код, различен от нула. Това я прави полезна на сървър за изграждане: едно предупреждение спира процеса, вместо да се изгуби на екрана.

Нормално изграждане (docker build без --check) също пуска тези проверки и показва същото предупреждение, но завършва успешно и изгражда образа въпреки всичко. Лесно е да го пропуснеш сред стотици редове. Затова навикът е да пускаш --check предварително, нарочно. А ако искаш никое изграждане да не минава с предупреждения, Dockerfile може да го декларира на втория си ред, веднага след # syntax, с # check=error=true: така предупреждението спира и нормален docker build (Docker, „Build checks“).

3.9 Какво ще срещнеш в стари ръководства

Ще четеш чужди Dockerfile и някои съдържат неща, които този курс не използва. Добре е да ги разпознаваш:

  • MAINTAINER име. Декларираше автора на образа. Отхвърлена е: справочникът посочва, че LABEL е „много по-гъвкава версия“ и че трябва да се използва вместо нея, например LABEL org.opencontainers.image.authors="..." (справочник, MAINTAINER).
  • ADD за копиране на локални файлове. ADD прави повече от COPY: също изтегля от интернет и разархивира. Тази магия изненадва. Ръководството с добри практики използва COPY за копиране и оставя ADD за случаите, когато наистина трябва да донесеш нещо отдалечено (добри практики, ADD или COPY). В този курс ADD не е нужна.
  • FROM нещо:latest или FROM нещо без таг. Вече знаеш защо не (3.3).
  • CMD команда и ENTRYPOINT команда във форма shell. Вече знаеш как да измериш защо не (3.7).
  • Инсталиране на sudo в образа. Ръководството не го препоръчва заради непредсказуемото му поведение с терминалите и с препращането на сигнали. Ако нещо изисква root при изграждане, то е преди USER; при стартиране не би трябвало да му е нужно.

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

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

1. Предупреждението на docker build --check.

WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals
Dockerfile.forma-shell:10

Чете се така: има правило, което не е спазено (JSONArgsRecommended); обяснението му е на адреса; а ред 10 от Dockerfile.forma-shell е виновникът. Поправя се, като CMD revisor се смени с CMD ["revisor"]. Ако наистина ти трябва shell при стартиране, виж 3.7.4. Което не се прави, е да пренебрегнеш предупреждението, защото „образът се изгради добре“: изгражда се добре и спира зле.

2. Контейнерът, който излиза с 1 при стартиране.

revisor: falta REVISOR_SERVICIOS (ejemplo: REVISOR_SERVICIOS="prueba http://curso-docker-prueba:9000/ 2s")
curso-docker-revisor Exited (1) Less than a second ago

Чете се така: контейнерът стартира, програмата прочете конфигурацията си, не намери нещо задължително и излезе сама с 1. Това не е проблем на образа, нито на Docker. Първият въпрос пред току-що стартиран Exited (1) винаги е docker logs <контейнер>: там почти винаги е причината, написана от самата програма. Поправя се, като се подаде променливата с -e.

3. docker stop от 10 секунди и код 137.

tardó 10365 ms
ExitCode=137

Чете се така: главният процес не е обработил SIGTERM, срокът е изтекъл и Docker го е убил със SIGKILL (137 = 128 + 9). Преди да обвиниш програмата, погледни docker top <контейнер>: ако първият ред е /bin/sh -c ..., сигналът е останал в shell. Поправя се с CMD във форма exec. Има и други възможни причини за 137 — липсата на памет от Урок 0 е другата голяма и се различава по това, че docker inspect казва OOMKilled=true —, но когато времето на docker stop е точно срокът, главното подозрение е това. Което не се прави, е да увеличиш срока на docker stop, за да „стигне“: по-дълъг срок само удължава чакането, защото сигналът продължава да не стига.

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

Първият Dockerfile вече има USER, .dockerignore и таг с версия. И трите са евтини в първия ден и скъпи за добавяне по-късно, когато вече има работещи контейнери и хора, които зависят от тях.

  • USER 10001:10001 предотвратява грешка в revisor — или в която и да е библиотека, която използва — да даде на онзи, който я експлоатира, потребител 0 на твоето ядро. Провери го с docker exec ... id.
  • .dockerignore предотвратява инцидента от 3.5: .env, ключ или историята на .git, вкарани в образ, който после пътува към други компютри. Провери го, като погледна /src вътре в образа.
  • Тагът с версия и префиксът предотвратяват стартирането на „каквото и да е, което се казва така“: знаеш кой образ работи и го изтриваш, без да докосваш чуждото.

Какво се прави погрешно

Да пишеш CMD във форма shell, „защото се чете по-лесно“. Чете се по-лесно и спира по-зле: 10 секунди чакане и 137 при всеки docker stop. Формата exec е две квадратни скоби и няколко кавички.

Да използваш latest или да не слагаш таг, което е същото. latest не значи „най-новият“; значи „без таг“. В деня на проблема няма да знаеш коя версия е работела.

Да кръщаваш образа с общо име като revisor или app. Сблъсква се с образи на други проекти на същия компютър и им отнема името, без да предупреди.

Да оставиш Dockerfile без USER, защото „е само за тестване“. Онова, което е за тестване, обикновено стига до продукция със същия Dockerfile. Струва един ред.

COPY . . без .dockerignore. Всичко от папката пътува и остава в образа, включително файловете, които никой не помни да е оставил там.

Да използваш RUN, за да стартираш услугата. RUN се изпълнява при изграждане: изграждането би чакало сървъра завинаги. Онова, което се изпълнява при стартиране, отива в CMD.

Да вярваш, че EXPOSE публикува порта. Само го отбелязва. Без -p при стартиране никой отвън не стига до него. А с -p портът се публикува на 127.0.0.1, както научи в Урок 2.

Да пренебрегваш предупрежденията при изграждане. Нормалното изграждане показва предупреждението JSONArgsRecommended и завършва успешно. Да пуснеш docker build --check предварително превръща предупреждението в грешка, която не може да се подмине.

Упражнения

Прави ги по ред. Преди да пуснеш всяко, запиши в дневника какво мислиш, че ще стане; сравняването после е половината от ученето. Накрая изтрий онова, което си създал: всичко носи префикса curso-docker-.

Упражнение 1 — При изграждане или при стартиране?

Без да гледаш таблицата в 3.2.2, класифицирай всеки ред: действа ли при изграждане, само отбелязва нещо за стартиране, или и двете?

  1. FROM golang:1.27.3-trixie
  2. COPY . .
  3. RUN go build -o /usr/local/bin/revisor ./cmd/revisor
  4. USER 10001:10001
  5. EXPOSE 8080
  6. CMD ["revisor"]

И един въпрос с уловка: ако смениш реда и сложиш USER 10001:10001 преди RUN go build, с кой потребител се изпълнява компилирането?

Упражнение 2 — Твоят собствен .dockerignore (проект)

Работи в папката си ~/curso-docker/revisor (тази от блока „В твоята папка“ в 3.6.1). Премести за малко .dockerignore на урока настрана с mv .dockerignore ../dockerignore-leccion3 и създай там три неща: файл .env със симулирана тайна, файл notas.md и папка secretos/ с файл в нея. Изгради curso-docker-revisor:1.0.0-ej2 и изброй /src вътре в образа с docker run --rm curso-docker-revisor:1.0.0-ej2 ls -A /src. После напиши нов .dockerignore, който изключва трите неща, плюс всичко, което изключваше този на урока (имаш го в ../dockerignore-leccion3), изгради наново със същия таг и изброй отново. Упражнението се самопроверява: готов си, когато /src показва точно cmd, go.mod и internal. Накрая — docker rmi curso-docker-revisor:1.0.0-ej2. Разширеният ти .dockerignore може да остане: изключва всичко, което изключваше този на урока, и още нещо, така че твоят curso-docker-revisor:1.0.0 не се променя.

Упражнение 3 — Хронометърът на твоя компютър

Пусни fig03_05.sh и fig03_07.sh на твоя Mint (след Урок 1 и с изтеглен образ на Go). Запиши в дневника си, на реда, който Урок 3 има за това, двете времена на docker stop.

Едно предупреждение, преди да ги стартираш: fig03_05.sh изгражда и при излизане изтрива образ с име curso-docker-revisor:1.0.0 — същото име като твоя от 3.6.1. За да не го отнесе, стартирай го с друг префикс — затова е тази променлива: PREFIJO=curso-docker-ej3- ./fig03_05.sh. В изхода си ще четеш curso-docker-ej3-revisor там, където урокът казва curso-docker-revisor; пак започва с curso-docker-, така че почистването на курса го обхваща. Ако вече си го стартирал без префикса и образът ти е изчезнал, върни се в ~/curso-docker/revisor и го изгради отново с docker build -q -t curso-docker-revisor:1.0.0 .: отнема секунди, защото всичко идва от кеша.

Преди да ги пуснеш, предскажи:

  1. Кое от двете времена ще се мени повече от изпълнение на изпълнение и кое ще остане почти същото?
  2. Какво число ще излезе в ExitCode при формата shell? А какво число би очаквал при формата exec, като знаеш, че revisor е написан да завършва с 0 след чисто спиране? Провери го, като добавиш след docker stop във fig03_05.sh един docker inspect --format 'ExitCode={{.State.ExitCode}}' "${PREFIJO}revisor" (с префикса отгоре контейнерът се казва curso-docker-ej3-revisor).

Упражнение 4 — Поправи чуждия Dockerfile

Колега ти дава този Dockerfile за revisor. Намери шест проблема, пет във файла и един, който липсва до него; кажи какъв инцидент може да причини всеки и напиши поправената версия. После провери своята с docker build --check: трябва да каже Check complete, no warnings found.

# ASÍ NO — seis problemas a propósito (Ejercicio 4 de la Lección 3).
FROM golang
MAINTAINER alguien@ejemplo.invalid
COPY . /src
RUN cd /src && go build -o /usr/local/bin/revisor ./cmd/revisor
EXPOSE 8080
CMD revisor

Подсказка: docker build --check открива два от шестте; другите четири трябва да ги намериш ти.

Решения

Решение 1

  1. При изграждане. Донася цялото съдържание на базовия образ.
  2. При изграждане. Копира файловете от контекста в образа.
  3. При изграждане. Компилира и оставя изпълнимия файл в образа. Не се изпълнява отново при стартиране.
  4. И двете. Отбелязва потребителя, с който ще работи контейнерът, и освен това засяга следващите RUN в Dockerfile.
  5. Само отбелязва. Не публикува нищо (3.2).
  6. Само отбелязва процеса, който ще стартира всеки контейнер.

Въпросът с уловка: с потребител 10001, защото USER сменя потребителя на всичко, което идва след него, и при изграждане. И компилирането се проваля. Go се опитва да създаде кеша си в личната папка на потребителя и, тъй като 10001 не съществува в /etc/passwd, тази папка е /, където не може да пише. Измерено на машината на този урок с fig03_12.sh, който изгражда с docker build --progress=plain файла fig03_11.Dockerfile (този от урока, с USER преди RUN); #10 и 0.204 са номерът на стъпката и секундите и се менят, а между двата реда по-долу останалата част от изхода е в fig03_12.salida.txt:

#10 0.204 failed to initialize build cache at /.cache/go-build: mkdir /.cache: permission denied
ERROR: failed to build: failed to solve: process "/bin/sh -c go build -o /usr/local/bin/revisor ./cmd/revisor" did not complete successfully: exit code: 1

Дори кешът да работеше, пак нямаше да може да пише в /usr/local/bin. Затова USER е след компилирането.

Ето Dockerfile с преместения USER (fig03_11.Dockerfile), който съществува само за да се види провалът:

# syntax=docker/dockerfile:1
# fig03_11.Dockerfile — Lección 3: el Dockerfile de la lección con USER ANTES del RUN.
# Existe solo para ver fallar la compilación (Solución 1): así NO se escribe.
FROM golang:1.27.3-trixie

WORKDIR /src
COPY . .
USER 10001:10001
RUN go build -o /usr/local/bin/revisor ./cmd/revisor

EXPOSE 8080
CMD ["revisor"]

А ето fig03_12.sh, който го изгражда и, между другото, проверява с docker build --check чуждия Dockerfile от Упражнение 4 (fig03_10.Dockerfile, същият като по-горе):

#!/usr/bin/env bash
# fig03_12.sh — Los dos Dockerfile con defectos de la lección: el del Ejercicio 4,
# revisado con docker build --check, y el de la Solución 1 (USER antes del RUN),
# que falla al compilar. No deja imágenes ni contenedores.
set -euo pipefail
AQUI="$(cd "$(dirname "$0")" && pwd)"
TRABAJO="$(mktemp -d)"
trap 'rm -rf "$TRABAJO"' EXIT

# El proyecto: el código que entrega el curso + los dos Dockerfile con defectos.
cp -R "$AQUI/../revisor" "$TRABAJO/revisor"
cp "$AQUI/estado/.dockerignore" "$TRABAJO/revisor/"
cp "$AQUI/fig03_10.Dockerfile" "$TRABAJO/revisor/Dockerfile.ajeno"
cp "$AQUI/fig03_11.Dockerfile" "$TRABAJO/revisor/Dockerfile.user-antes"
cd "$TRABAJO/revisor"

titulo() { printf '\n## %s\n' "$*"; }
muestra() {
  printf '$ %s\n' "$*"
  local rc=0
  eval "$@" 2>&1 || rc=$?
  [ "$rc" -ne 0 ] && echo "[código de salida: $rc]"
  return 0
}

titulo "Ejercicio 4: el Dockerfile ajeno, revisado antes de construir"
muestra "docker build --check -f Dockerfile.ajeno ."

titulo "Solución 1: USER antes del RUN, la compilación falla"
muestra "docker build --progress=plain -f Dockerfile.user-antes ."

Решение 2

Един .dockerignore, който изпълнява условието:

.git
.env
*.tar
*.md
secretos/
Dockerfile*
.dockerignore

С него ls -A /src вътре в образа показва само cmd, go.mod и internal, точно както вторият блок на fig03_04.sh. Ако в списъка ти още се появява notas.md или secretos, провери дали шаблонът е написан правилно и дали файлът .dockerignore е в корена на контекста, до Dockerfile, а не в подпапка. Ако се появява .dockerignore или Dockerfile, липсват ти последните два реда. Също валиден вариант е *.md да се смени с notas.md: изключва само този файл. Горният е по-предвидлив, защото на revisor не му трябва нито един .md, за да се компилира.

Решение 3

  1. Времето на формата exec се мени повече от изпълнение на изпълнение (десетки или стотици милисекунди според това колко е натоварен компютърът ти); това на формата shell остава почти същото, винаги малко над 10 000 ms, защото почти всичко е фиксираният срок на docker stop.
  2. При формата shell — ExitCode=137, както във fig03_07.sh. При формата exec — ExitCode=0: revisor получава SIGTERM, пише apagado limpio и кодът му завършва с 0. Ако твоето изпълнение даде друго число, погледни docker logs: ако не се появява apagado limpio, сигналът не е стигнал и първото подозрение е, че твоят Dockerfile е във форма shell.

Решение 4

Петте проблема във файла:

  1. FROM golang без таг, което е golang:latest: всяко изграждане може да използва друга версия на Go. Поправка: FROM golang:1.27.3-trixie.
  2. MAINTAINER, отхвърлена. Поправка: да се махне или да се използва LABEL org.opencontainers.image.authors="...".
  3. RUN cd /src && ... вместо WORKDIR. Работи в този ред, но cd се губи после и контейнерът стартира в /. Поправка: WORKDIR /src и COPY . ..
  4. Без USER: revisor би работил като root. Поправка: USER 10001:10001 след компилирането.
  5. CMD revisor във форма shell: 10 секунди и 137 при всеки docker stop. Поправка: CMD ["revisor"]. Точно това посочва docker build --check.

И шестият, който не е във файла: липсва .dockerignore, а с COPY . /src целият контекст влиза в образа. Поправената версия е fig03_01.Dockerfile от този урок плюс fig03_03.dockerignore.

docker build --check върху оригиналния Dockerfile (fig03_10.Dockerfile, който fig03_12.sh проверява) открива два от шестте. Измерено на машината на този урок (показани са само редовете с предупрежденията; пълният изход е в fig03_12.salida.txt):

Check complete, 2 warnings have been found!
WARNING: MaintainerDeprecated - https://docs.docker.com/go/dockerfile/rule/maintainer-deprecated/
Maintainer instruction is deprecated in favor of using label
WARNING: JSONArgsRecommended - https://docs.docker.com/go/dockerfile/rule/json-args-recommended/
JSON arguments recommended for CMD to prevent unintended behavior related to OS signals

MaintainerDeprecated е проблем 2: правилото казва, че MAINTAINER е отхвърлена и че вместо нея трябва да се използва етикет (LABEL) (Docker, MaintainerDeprecated). JSONArgsRecommended е проблем 5. Другите четири (latest, cd, липсата на USER и тази на .dockerignore) не ги открива нито едно от правилата, пуснати тук: затова --check помага, но не замества четенето на рецептата. Твоята версия трябва да каже Check complete, no warnings found.

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

Отбелязвай всяка точка само ако си я проверил на своя компютър:

  • docker build --check . в ~/curso-docker/revisor завършва с Check complete, no warnings found. и код 0.
  • docker image ls 'curso-docker-revisor' показва curso-docker-revisor с таг 1.0.0 и си записал размера му в дневника (при изпълнението на този урок — 1,45 GB).
  • Без REVISOR_SERVICIOS контейнерът остава Exited (1) и docker logs казва какво липсва.
  • С променливата curl -s http://127.0.0.1:8080/salud отговаря {"estado":"vivo","version":"desarrollo"}.
  • docker exec curso-docker-revisor id казва uid=10001 gid=10001 groups=10001.
  • docker top curso-docker-revisor -o pid,user,args показва един-единствен процес, revisor.
  • docker stop curso-docker-revisor трае по-малко от секунда и docker logs завършва с revisor: apagado limpio.
  • С образа във форма shell измери 10-те секунди и 137 и можеш да обясниш с docker top защо.
  • Вътре в образа ls -A /src не показва .env, .git, нито Dockerfile.
  • Накрая docker ps -a --filter name=curso-docker-revisor не показва нищо, а docker image ls 'curso-docker-revisor' показва само curso-docker-revisor:1.0.0, този, който изгради в папката си и който използва Урок 4.

Обобщение

Отговори, без да гледаш урока:

  1. Какво е Dockerfile, какво е контекстът и кой върши работата по изграждането?
  2. Кои инструкции действат при изграждане и кои само отбелязват нещо за стартиране?
  3. Какво наистина означава тагът latest? Защо образът на курса се казва curso-docker-revisor:1.0.0, а не revisor:1.0.0?
  4. С кой потребител работи един образ, ако никой не е написал USER? Защо е достатъчно едно число?
  5. Какво прави .dockerignore и какво става с един .env, ако го нямаш?
  6. Каква е разликата между CMD ["revisor"] и CMD revisor? Какво показва docker top във всеки от случаите?
  7. Защо при формата shell docker stop трае 10 секунди и оставя 137? Какво е 137?
  8. Какво прави docker build --check и с какъв код излиза, ако открие нещо?

Източници

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

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

BuildKit изграждащият механизъм за образи, който Docker използва; част е от демона
контекст на изграждане файловете, които изграждането може да използва; почти винаги — една папка
Dockerfile текстовата рецепта, с която се изгражда образ
.dockerignore списъкът с онова, което не пътува в контекста и не може да влезе в образа
ENTRYPOINT инструкция, която задава главната програма на контейнера; CMD ѝ дава аргументи по подразбиране; използваш я от Урок 5 нататък
таг (tag) частта от името на образ след двоеточието; казва коя версия е
форма exec инструкция, написана като списък JSON: CMD ["revisor"]; стартира програмата без shell
форма shell инструкция, написана като текст: CMD revisor; обвива я /bin/sh -c
базов образ образът, от който тръгва друг — този от FROM
latest тагът, който Docker слага, когато не е зададен никакъв; не означава „най-новият“
PID 1 първият процес на един контейнер; този, който получава сигнала от docker stop
проверка при изграждане (build check) правило, което търси често срещани грешки в Dockerfile, без да го изгражда
SIGTERM сигнал 15: „приключи, моля“; този, който docker stop изпраща първо
SIGKILL сигнал 9: незабавна смърт; не може да бъде уловен; оставя код 137
Да обсъдим вашия проект

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