5.3. COPY, ADD и RUN
Цели
После этого материала вы сможете:
- объяснить, почему
COPYпредпочтительнееADD, и назвать два случая, гдеADDуместен; - применять флаги
COPY:--from,--chown,--chmod,--link,--parents; - объяснить, что даёт
--link, и когда он ускоряет пересборку; - объединять команды в одном
RUNтак, чтобы временные файлы не попадали в слой; - использовать heredoc-синтаксис для многострочных команд;
- объяснить, почему
apt-get updateиapt-get installдолжны быть в одной инструкции.
Предварительные знания
- 5.1. Build context и
.dockerignore; - 5.2. Базовые инструкции;
- 3.2. Слои и copy-on-write — почему удаление не уменьшает образ.
Ключевые термины
| Термин | Объяснение |
|---|---|
build context | Набор файлов, доступных инструкциям COPY и ADD |
heredoc | Синтаксис передачи многострочного текста команде |
cache busting | Ситуация, когда закэшированный слой содержит устаревшие данные |
--link | Флаг COPY, создающий независимый слой без привязки к предыдущим |
layer squashing | Объединение нескольких слоёв в один |
Теория
COPY против ADD
Обе инструкции копируют файлы в образ. ADD умеет больше — и именно поэтому применять её обычно не следует.
| Возможность | COPY | ADD |
|---|---|---|
| Копирование из контекста | да | да |
Копирование из другой стадии (--from) | да | да |
| Автоматическая распаковка локальных архивов | нет | да |
| Загрузка по URL | нет | да |
Проверка контрольной суммы (--checksum) | нет | да |
| Клонирование Git-репозитория | нет | да |
Проблема ADD — в автоматической распаковке. Инструкция
ADD archive.tar.gz /app/
распакует архив. А инструкция
ADD data.txt /app/
скопирует файл. Поведение зависит от содержимого, и при смене формата файла результат меняется молча.
Второй риск: распаковка выполняется без ограничений на пути внутри архива. Архив, собранный злоумышленником, может содержать пути вида ../../etc/passwd.
Правило: используйте COPY всегда, кроме двух случаев.
Случай 1: распаковка локального архива. Когда распаковка действительно нужна, ADD избавляет от установки tar в образ:
ADD --chown=app:app rootfs.tar.gz /
Случай 2: загрузка по URL с проверкой контрольной суммы. Начиная с Dockerfile 1.6 доступен флаг --checksum:
ADD --checksum=sha256:9f8e7d6c... https://example.com/tool.tar.gz /tmp/
Без --checksum загрузка по URL — плохая практика: содержимое может измениться, а слой закэширован по URL, а не по содержимому. Лучше RUN curl с явной проверкой.
Флаги COPY
--from=<стадия|образ> — копировать не из контекста, а из другой стадии сборки или из готового образа:
COPY --from=builder /app/.venv /app/.venv
COPY --from=ghcr.io/astral-sh/uv:0.12.0 /uv /bin/uv
Вторая форма — распространённый приём: взять один бинарный файл из чужого образа, не устанавливая его.
--chown=<user>:<group> — задать владельца копируемых файлов. Без него владельцем становится root, и непривилегированный пользователь не сможет писать.
--chmod=<права> — задать права доступа:
COPY --chmod=755 entrypoint.sh /entrypoint.sh
Избавляет от отдельной инструкции RUN chmod, которая создала бы дополнительный слой.
--parents — сохранить структуру родительских каталогов:
COPY --parents ./src/*/pyproject.toml /app/
Без флага все файлы легли бы в один каталог. С флагом сохранится вложенность.
--link — создать слой, не зависящий от предыдущих. Разбирается ниже.
Что даёт --link
Обычная инструкция COPY создаёт слой поверх результата предыдущих инструкций: ей нужно знать состояние файловой системы, чтобы правильно наложить изменения.
С флагом --link слой создаётся независимо и присоединяется к образу как самостоятельный. Последствие: изменение предыдущих слоёв не инвалидирует этот слой.
без --link с --link
────────── ────────
FROM base:1 слой A FROM base:1 слой A
RUN install слой B RUN install слой B
COPY app/ /app слой C COPY --link app/ слой C'
(независим от A и B)
меняется base:1 меняется base:1
▼ ▼
пересобираются B и C пересобирается только B;
слой C' переиспользуется
Практическая польза: при обновлении базового образа слой с кодом приложения не пересобирается и, что важнее, не перекачивается при docker pull теми, у кого он уже есть.
Ограничение: --link не видит файлы из предыдущих слоёв. Копирование в каталог, созданный предыдущей инструкцией RUN mkdir, работать не будет — каталог будет создан заново внутри нового слоя.
RUN и формирование слоя
Каждая инструкция RUN создаёт слой из результата всей команды. Отсюда главное правило:
Файлы, созданные и удалённые внутри одной инструкции
RUN, в слой не попадают.
Именно поэтому установка пакетов и очистка кэша должны быть в одной инструкции (урок 3.2).
Почему apt-get update нельзя отделять
Классическая ошибка:
RUN apt-get update
RUN apt-get install -y curl
Проблема не в лишнем слое, а в кэше. При пересборке через месяц слой с apt-get update будет взят из кэша — с индексом пакетов месячной давности. Инструкция apt-get install тоже может быть закэширована, но если её изменить (добавить пакет), она выполнится со старым индексом и попытается скачать версии, которых уже нет в репозитории.
Результат — ошибка 404 Not Found при установке пакета, который точно существует. Явление известно как cache busting.
Правильно — одна инструкция:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
Здесь изменение списка пакетов инвалидирует всю инструкцию целиком, и apt-get update выполнится заново.
Флаг --no-install-recommends
Debian и Ubuntu по умолчанию ставят рекомендованные пакеты вместе с запрошенными. Для образа это лишний объём: установка curl может притянуть десятки мегабайт документации и вспомогательных утилит.
Флаг отключает это поведение. Если чего-то не хватит, недостающее указывается явно — это лучше, чем ставить всё подряд.
Heredoc в RUN
BuildKit поддерживает heredoc-синтаксис, делающий многострочные команды читаемыми:
# syntax=docker/dockerfile:1
FROM debian:trixie-slim
RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF
CMD ["curl", "--version"]
Преимущества перед цепочкой &&: не нужны обратные слэши, видна структура, легко добавить условия и циклы. Всё выполняется в одной оболочке, то есть остаётся одним слоем.
Директива # syntax=docker/dockerfile:1 в первой строке обязательна: без неё heredoc не будет распознан, и сборка упадёт с синтаксической ошибкой.
Отдельно полезна форма записи файла:
# syntax=docker/dockerfile:1
FROM alpine:3.21
COPY <<EOF /app/config.ini
[server]
host = 0.0.0.0
port = 8000
EOF
CMD ["cat", "/app/config.ini"]
Позволяет создать небольшой конфигурационный файл, не добавляя его в контекст.
Внутренний механизм
Что происходит при COPY
- BuildKit определяет список файлов-источников с учётом
.dockerignore. - Для каждого вычисляется контрольная сумма содержимого и метаданных (права, владелец, время изменения).
- Формируется ключ кэша из этих сумм и параметров инструкции.
- При совпадении ключа слой берётся из кэша, файлы не передаются.
- Иначе файлы передаются и записываются в новый слой.
Шаг 2 объясняет неочевидное: изменение прав доступа файла инвалидирует кэш так же, как изменение содержимого. Время изменения при этом учитывается не всегда — поведение зависит от версии BuildKit.
Почему RUN не имеет доступа к предыдущему RUN
Каждая инструкция RUN выполняется в новом container, создаваемом из результата предыдущего слоя. Поэтому:
- переменные оболочки не сохраняются между инструкциями;
cdне действует за пределами инструкции;- фоновые процессы, запущенные в
RUN, завершаются вместе с инструкцией.
Последний пункт означает, что нельзя «запустить базу данных в RUN, чтобы наполнить её». Процесс умрёт до следующей инструкции.
Команды и примеры
Подготовка
mkdir -p /tmp/copy-run && cd /tmp/copy-run
mkdir -p src/pkg-a src/pkg-b config
echo "print('a')" > src/pkg-a/main.py
echo "print('b')" > src/pkg-b/main.py
echo "name = app" > config/app.ini
echo "fastapi==0.141.1" > requirements.txt
tar -czf archive.tar.gz config/
ADD распаковывает, COPY — нет
cat > Dockerfile.addvscopy <<'EOF'
FROM alpine:3.21
WORKDIR /test
# COPY кладёт архив как файл
COPY archive.tar.gz /test/via-copy/
# ADD распаковывает его
ADD archive.tar.gz /test/via-add/
CMD ["sh", "-c", "echo '--- COPY ---'; ls -R /test/via-copy; echo '--- ADD ---'; ls -R /test/via-add"]
EOF
docker build -q -f Dockerfile.addvscopy -t addcopy:1 . > /dev/null
docker run --rm addcopy:1
--- COPY ---
/test/via-copy:
archive.tar.gz
--- ADD ---
/test/via-add:
config
/test/via-add/config:
app.ini
COPY положил архив, ADD распаковал. Поведение ADD зависит от того, распознан ли файл как архив, — а это определяется содержимым, не расширением.
COPY --chown и права
cat > Dockerfile.chown <<'EOF'
FROM alpine:3.21
RUN adduser -D -u 10001 appuser
# без --chown владельцем становится root
COPY requirements.txt /no-chown.txt
# с --chown
COPY --chown=appuser:appuser requirements.txt /with-chown.txt
# --chmod задаёт права без отдельного RUN chmod
COPY --chmod=755 requirements.txt /executable.txt
CMD ["sh", "-c", "ls -l /no-chown.txt /with-chown.txt /executable.txt"]
EOF
docker build -q -f Dockerfile.chown -t chown:1 . > /dev/null
docker run --rm chown:1
-rwxr-xr-x 1 root root 17 Jul 30 11:02 /executable.txt
-rw-r--r-- 1 root root 17 Jul 30 11:02 /no-chown.txt
-rw-r--r-- 1 appuser appuser 17 Jul 30 11:02 /with-chown.txt
Практическая ценность --chmod: без него потребовался бы RUN chmod 755 ..., создающий дополнительный слой с копией файла (урок 3.2 — copy-up).
COPY --parents
cat > Dockerfile.parents <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
WORKDIR /out
# без --parents структура теряется
COPY src/*/main.py /out/flat/
CMD ["find", "/out", "-type", "f"]
EOF
docker build -q -f Dockerfile.parents -t parents:1 . > /dev/null 2>&1 \
&& docker run --rm parents:1 || echo "конфликт имён: оба файла называются main.py"
С флагом:
cat > Dockerfile.parents2 <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
WORKDIR /out
COPY --parents src/*/main.py /out/
CMD ["find", "/out", "-type", "f"]
EOF
docker build -q -f Dockerfile.parents2 -t parents:2 . > /dev/null
docker run --rm parents:2
/out/src/pkg-a/main.py
/out/src/pkg-b/main.py
Структура сохранена. Без флага два файла с одинаковым именем перезаписали бы друг друга.
Типичное применение — монорепозиторий, где нужно скопировать все pyproject.toml для установки зависимостей до копирования исходников.
COPY --from из чужого образа
cat > Dockerfile.from <<'EOF'
FROM python:3.13-slim
# берём один бинарный файл из официального образа uv
COPY --from=ghcr.io/astral-sh/uv:0.12.0 /uv /bin/uv
CMD ["uv", "--version"]
EOF
docker build -q -f Dockerfile.from -t fromimg:1 . > /dev/null
docker run --rm fromimg:1
uv 0.12.0
Мы получили uv без установщика, без curl и без дополнительных слоёв — скопирован один файл из образа, который сам по себе в результат не попал.
Приём широко применяется для CLI-утилит, распространяемых как статические бинарные файлы.
RUN: очистка в той же инструкции
cat > Dockerfile.apt-bad <<'EOF'
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
CMD ["curl", "--version"]
EOF
cat > Dockerfile.apt-good <<'EOF'
FROM debian:trixie-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
CMD ["curl", "--version"]
EOF
docker build -q -f Dockerfile.apt-bad -t apt:bad . > /dev/null
docker build -q -f Dockerfile.apt-good -t apt:good . > /dev/null
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'apt:|debian'
apt:bad 142MB
apt:good 108MB
debian:trixie-slim 97.2MB
Разница 34 MB. Она складывается из двух источников:
echo "=== слои bad ==="
docker history apt:bad --format '{{.Size}}\t{{.CreatedBy}}' | head -4
echo "=== слои good ==="
docker history apt:good --format '{{.Size}}\t{{.CreatedBy}}' | head -3
=== слои bad ===
0B RUN /bin/sh -c rm -rf /var/lib/apt/lists/* # buildkit
16.8MB RUN /bin/sh -c apt-get install -y curl # buildkit
28.2MB RUN /bin/sh -c apt-get update # buildkit
=== слои good ===
10.9MB RUN /bin/sh -c apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && rm -rf /var/lib/apt/lists/* # buildkit
97.2MB /bin/sh -c #(nop) ADD file:... in /
В варианте bad индекс apt (28.2 MB) остался в слое навсегда — инструкция rm создала только whiteout размером 0B. Плюс --no-install-recommends сэкономил ещё около 6 MB.
Демонстрация cache busting
cat > Dockerfile.bust <<'EOF'
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl
CMD ["true"]
EOF
docker build -q -f Dockerfile.bust -t bust:1 . > /dev/null
echo "первая сборка выполнена"
# теперь добавляем пакет
cat > Dockerfile.bust <<'EOF'
FROM debian:trixie-slim
RUN apt-get update
RUN apt-get install -y --no-install-recommends curl jq
CMD ["true"]
EOF
docker build -f Dockerfile.bust -t bust:2 . 2>&1 | grep -E 'CACHED|apt-get' | head -3
первая сборка выполнена
=> CACHED [2/3] RUN apt-get update
=> [3/3] RUN apt-get install -y --no-install-recommends curl jq
apt-get update взят из кэша. Сейчас это сработало, потому что индекс свежий. Но через несколько недель закэшированный индекс устареет, и установка нового пакета завершится ошибкой вида:
E: Failed to fetch http://deb.debian.org/.../jq_1.7.1-3_amd64.deb 404 Not Found
Пакет существует, но версия в устаревшем индексе больше не доступна в репозитории. Диагностировать это трудно: Dockerfile выглядит корректно, а сборка падает.
Объединённая инструкция от этого защищена: изменение списка пакетов инвалидирует всё, включая update.
Heredoc
cat > Dockerfile.heredoc <<'DOCKERFILE'
# syntax=docker/dockerfile:1
FROM debian:trixie-slim
RUN <<EOF
set -eux
apt-get update
apt-get install -y --no-install-recommends curl ca-certificates
rm -rf /var/lib/apt/lists/*
# видно, что это одна инструкция и один слой
echo "установка завершена"
EOF
# создание файла без добавления его в контекст
COPY <<EOF /app/config.ini
[server]
host = 0.0.0.0
port = 8000
workers = 4
EOF
CMD ["cat", "/app/config.ini"]
DOCKERFILE
docker build -q -f Dockerfile.heredoc -t heredoc:1 . > /dev/null
docker run --rm heredoc:1
echo "--- число слоёв с данными ---"
docker history heredoc:1 --format '{{.Size}}' | grep -vc '^0B$'
[server]
host = 0.0.0.0
port = 8000
workers = 4
--- число слоёв с данными ---
3
Многострочная команда осталась одним слоем. Директива set -eux в начале — хорошая практика: -e прерывает при ошибке, -u ловит необъявленные переменные, -x печатает команды в лог сборки.
Без set -e внутри heredoc ошибка промежуточной команды не прервёт сборку — это отличие от цепочки &&, где каждая следующая команда выполняется только при успехе предыдущей.
--link и переиспользование слоя
mkdir -p /tmp/link-demo && cd /tmp/link-demo
echo "версия приложения 1" > app.txt
cat > Dockerfile.nolink <<'EOF'
FROM alpine:3.21
RUN echo "слой базовой настройки" > /base.txt
COPY app.txt /app.txt
CMD ["cat", "/app.txt"]
EOF
cat > Dockerfile.link <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN echo "слой базовой настройки" > /base.txt
COPY --link app.txt /app.txt
CMD ["cat", "/app.txt"]
EOF
docker build -q -f Dockerfile.nolink -t link:no > /dev/null
docker build -q -f Dockerfile.link -t link:yes > /dev/null
# имитируем изменение предыдущего слоя
cat > Dockerfile.nolink <<'EOF'
FROM alpine:3.21
RUN echo "слой базовой настройки ИЗМЕНЁН" > /base.txt
COPY app.txt /app.txt
CMD ["cat", "/app.txt"]
EOF
cat > Dockerfile.link <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN echo "слой базовой настройки ИЗМЕНЁН" > /base.txt
COPY --link app.txt /app.txt
CMD ["cat", "/app.txt"]
EOF
echo "=== без --link ==="
docker build -f Dockerfile.nolink -t link:no . 2>&1 | grep -E 'COPY|CACHED.*COPY'
echo "=== с --link ==="
docker build -f Dockerfile.link -t link:yes . 2>&1 | grep -E 'COPY|CACHED.*COPY'
=== без --link ===
=> [3/3] COPY app.txt /app.txt
=== с --link ===
=> CACHED [3/3] COPY --link app.txt /app.txt
Без --link слой с приложением пересобрался, потому что изменился предыдущий слой. С --link он переиспользован.
На маленьком файле выигрыш незаметен. На слое с виртуальным окружением Python в 200 MB он существенен — и не только при сборке: при docker pull пользователи не будут перекачивать неизменившийся слой.
Ограничение --link:
cat > Dockerfile.link-limit <<'EOF'
# syntax=docker/dockerfile:1
FROM alpine:3.21
RUN mkdir -p /data && echo "создано в RUN" > /data/from-run.txt
COPY --link app.txt /data/app.txt
CMD ["ls", "-1", "/data"]
EOF
docker build -q -f Dockerfile.link-limit -t link:limit . > /dev/null
docker run --rm link:limit
app.txt
Файл from-run.txt исчез: слой --link создан независимо и заменил каталог /data целиком, а не дополнил его.
Это ожидаемое поведение, но неочевидное. Правило: --link применяйте для каталогов, которые целиком формируются этой инструкцией, а не дополняют результат предыдущих.
Уборка
cd /tmp
docker rmi -f addcopy:1 chown:1 parents:1 parents:2 fromimg:1 apt:bad apt:good \
bust:1 bust:2 heredoc:1 link:no link:yes link:limit 2>/dev/null || true
rm -rf /tmp/copy-run /tmp/link-demo
Практическое упражнение
Задание. Дан Dockerfile, собирающий образ Python-приложения. Он работает, но образ весит 380 MB вместо возможных 150 MB, а сборка при изменении одной строки кода занимает полторы минуты.
FROM python:3.13-slim
ADD https://github.com/some/tool/releases/download/v1.0/tool.tar.gz /tmp/
RUN cd /tmp && tar -xzf tool.tar.gz && mv tool /usr/local/bin/
RUN apt-get update
RUN apt-get install -y build-essential curl git
RUN rm -rf /var/lib/apt/lists/*
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN chmod +x /app/entrypoint.sh
RUN chown -R nobody /app
USER nobody
CMD ["/app/entrypoint.sh"]
Найдите все проблемы, объясните последствия каждой и напишите исправленную версию. Ожидаемый результат — образ вдвое меньше и пересборка при изменении кода за секунды.
Подсказки
Подсказка 1
Три проблемы связаны с RUN: разделение apt-get update, отсутствие --no-install-recommends, очистка в отдельной инструкции.
Подсказка 2
Две проблемы связаны с порядком: COPY . /app перед pip install инвалидирует кэш зависимостей при любом изменении кода.
Подсказка 3
Инструкции RUN chmod и RUN chown можно заменить флагами COPY.
Решение
Сначала выполните задание самостоятельно.
Показать решение
Найденные проблемы.
| № | Проблема | Последствие |
|---|---|---|
| 1 | ADD по URL без --checksum | Содержимое может измениться; слой кэшируется по URL, а не по содержимому |
| 2 | apt-get update отдельной инструкцией | Cache busting: установка нового пакета со старым индексом даёт 404 |
| 3 | Нет --no-install-recommends | Лишние десятки мегабайт |
| 4 | rm -rf /var/lib/apt/lists/* отдельной инструкцией | Индекс apt (~30 MB) остаётся в слое навсегда |
| 5 | build-essential в финальном образе | ~200 MB компилятора, не нужного при работе |
| 6 | COPY . /app до pip install | Любое изменение кода пересобирает зависимости |
| 7 | RUN chmod отдельной инструкцией | Лишний слой с копией файла (copy-up) |
| 8 | RUN chown -R /app отдельной инструкцией | Дублирует весь каталог приложения в новом слое |
| 9 | USER nobody | Пользователь nobody разделяется с системой; нет своего домашнего каталога |
| 10 | Нет .dockerignore (подразумевается) | В контекст и образ попадает лишнее |
Проблема 8 заслуживает пояснения: chown -R изменяет метаданные каждого файла, а изменение метаданных вызывает copy-up (урок 3.2). Каталог приложения на 50 MB продублируется, добавив 50 MB к образу.
Исправленная версия.
# syntax=docker/dockerfile:1
# ── Стадия сборки: компиляторы остаются здесь ──
FROM python:3.13-slim AS builder
# 2, 3, 4: одна инструкция, --no-install-recommends, очистка в ней же
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 6: зависимости ставятся ДО копирования кода
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ── Финальная стадия: только runtime ──
FROM python:3.13-slim
# 1: загрузка с проверкой контрольной суммы
ADD --checksum=sha256:0000000000000000000000000000000000000000000000000000000000000000 \
https://github.com/some/tool/releases/download/v1.0/tool.tar.gz /tmp/tool.tar.gz
RUN tar -xzf /tmp/tool.tar.gz -C /usr/local/bin \
&& rm /tmp/tool.tar.gz
# 5: из стадии сборки берём только установленные пакеты
COPY --from=builder /install /usr/local
# 9: собственный пользователь с фиксированным UID
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
# 7, 8: права и владелец задаются флагами COPY, без лишних слоёв
COPY --chown=appuser:appuser --chmod=755 entrypoint.sh /app/entrypoint.sh
COPY --chown=appuser:appuser . /app
USER 10001:10001
CMD ["/app/entrypoint.sh"]
Проверка на воспроизводимом примере. Загрузку по URL заменим на локальный файл, чтобы пример работал без внешней зависимости:
mkdir -p /tmp/ex53 && cd /tmp/ex53
mkdir -p app
echo "fastapi==0.141.1" > requirements.txt
echo "print('работает')" > app/main.py
printf '#!/bin/sh\nexec python /app/app/main.py\n' > entrypoint.sh
cat > .dockerignore <<'EOF'
.git
.venv
__pycache__
*.pyc
Dockerfile*
.dockerignore
EOF
# --- исходный вариант ---
cat > Dockerfile.before <<'EOF'
FROM python:3.13-slim
RUN apt-get update
RUN apt-get install -y build-essential curl git
RUN rm -rf /var/lib/apt/lists/*
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
RUN chmod +x /app/entrypoint.sh
RUN chown -R nobody /app
USER nobody
CMD ["/app/entrypoint.sh"]
EOF
# --- исправленный вариант ---
cat > Dockerfile.after <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS builder
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.13-slim
COPY --from=builder /install /usr/local
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --chown=appuser:appuser --chmod=755 entrypoint.sh /app/entrypoint.sh
COPY --chown=appuser:appuser app /app/app
USER 10001:10001
CMD ["/app/entrypoint.sh"]
EOF
docker build -q -f Dockerfile.before -t ex53:before . > /dev/null
docker build -q -f Dockerfile.after -t ex53:after . > /dev/null
echo "=== размеры ==="
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep ex53
echo
echo "=== обе версии работают ==="
docker run --rm ex53:before
docker run --rm ex53:after
echo
echo "=== пересборка после изменения кода ==="
echo "print('изменено')" > app/main.py
echo -n "before: "
/usr/bin/time -f '%e c' docker build -q -f Dockerfile.before -t ex53:before . > /dev/null 2>/tmp/t1; cat /tmp/t1
echo -n "after: "
/usr/bin/time -f '%e c' docker build -q -f Dockerfile.after -t ex53:after . > /dev/null 2>/tmp/t2; cat /tmp/t2
=== размеры ===
REPOSITORY:TAG SIZE
ex53:after 168MB
ex53:before 412MB
=== обе версии работают ===
работает
работает
=== пересборка после изменения кода ===
before: 34.82 c
after: 0.91 c
Образ уменьшился с 412 до 168 MB, пересборка при изменении кода — с 35 секунд до 0.9.
Откуда взялся выигрыш по размеру:
docker history ex53:before --format '{{.Size}}\t{{.CreatedBy}}' | head -6
0B CMD ["/app/entrypoint.sh"]
0B USER nobody
52.4MB RUN /bin/sh -c chown -R nobody /app # buildkit
1.2kB RUN /bin/sh -c chmod +x /app/entrypoint.sh # buildkit
48.1MB RUN /bin/sh -c pip install -r requirements.txt # buildkit
28.3MB RUN /bin/sh -c rm -rf /var/lib/apt/lists/* ...
Строка chown -R добавила 52.4 MB — она продублировала весь каталог приложения из-за copy-up. Флаг --chown у COPY делает то же самое бесплатно, потому что владелец задаётся в момент записи файлов.
Откуда выигрыш по времени. В варианте before инструкция COPY . /app идёт до pip install. Любое изменение файла в контексте инвалидирует слой COPY и все последующие, включая установку зависимостей. В варианте after копируется только requirements.txt, и слой с зависимостями переиспользуется, пока не изменится сам файл.
cd /tmp && docker rmi -f ex53:before ex53:after > /dev/null 2>&1; rm -rf /tmp/ex53
Проверка результата
mkdir -p /tmp/vfy && cd /tmp/vfy
printf 'FROM debian:trixie-slim\nRUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*\nCMD ["curl","--version"]\n' > Dockerfile
docker build -q -t vfy:good . > /dev/null
printf 'FROM debian:trixie-slim\nRUN apt-get update\nRUN apt-get install -y curl\nRUN rm -rf /var/lib/apt/lists/*\nCMD ["curl","--version"]\n' > Dockerfile
docker build -q -t vfy:bad . > /dev/null
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep vfy
docker rmi -f vfy:good vfy:bad > /dev/null; cd /tmp && rm -rf /tmp/vfy
Разница должна составлять около 30 MB. Объясните, из чего она складывается.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
ADD вместо COPY | Кажется более универсальной | Автораспаковка зависит от содержимого; использовать COPY |
ADD по URL без --checksum | Удобно | Содержимое может измениться незаметно; добавить --checksum или использовать RUN curl |
apt-get update отдельной инструкцией | Читается лучше | Cache busting: 404 при установке нового пакета. Объединять |
Нет --no-install-recommends | Не знают о флаге | Десятки лишних мегабайт |
rm -rf /var/lib/apt/lists/* отдельной инструкцией | Логично по структуре | Индекс остаётся в предыдущем слое; очищать в той же инструкции |
RUN chown -R на каталоге приложения | Привычно из shell | Copy-up дублирует весь каталог; использовать COPY --chown |
RUN chmod +x после COPY | Привычно | Лишний слой; использовать COPY --chmod |
COPY . . до установки зависимостей | Порядок кажется неважным | Любое изменение кода пересобирает зависимости |
--link при копировании в существующий каталог | Не знают об ограничении | Слой создаётся независимо и заменяет каталог целиком |
Запуск фонового процесса в RUN | Кажется, что он останется | Процесс завершается вместе с инструкцией |
RUN cd dir && cmd вместо WORKDIR | Привычка | Работает, но WORKDIR читается лучше и действует на последующие инструкции |
Контрольные вопросы
На понимание:
- Почему
ADDне рекомендуется как заменаCOPY? - Почему
apt-get updateиapt-get installдолжны быть в одной инструкции? - Почему
RUN rm -rf /var/lib/apt/lists/*отдельной инструкцией не уменьшает образ? - Что даёт
COPY --linkи в каком случае он не сработает как ожидается? - Почему
RUN chown -R /appможет добавить к образу десятки мегабайт?
На применение:
- Как скопировать один бинарный файл из чужого образа, не устанавливая его?
- Как задать права и владельца копируемых файлов без дополнительных инструкций
RUN? - Как создать небольшой конфигурационный файл в образе, не добавляя его в контекст?
На диагностику:
- Сборка падает с
404 Not Foundпри установке пакета, который точно существует в репозитории. Причина? - После добавления
COPY --linkфайл, созданный предыдущей инструкциейRUN, исчез из образа. Объясните.
Краткое резюме
COPYпредпочтительнееADD: поведениеADDзависит от содержимого файла.ADDуместен в двух случаях: распаковка локального архива и загрузка по URL с--checksum.COPY --fromберёт файлы из другой стадии или из чужого образа.--chownи--chmodзадают владельца и права без дополнительных слоёв.--parentsсохраняет структуру каталогов при копировании по маске.--linkсоздаёт независимый слой, переживающий изменение предыдущих, но заменяет каталог целиком.- Каждая инструкция
RUNсоздаёт слой из результата всей команды. - Файлы, созданные и удалённые внутри одного
RUN, в слой не попадают. - Разделение
apt-get updateиinstallприводит к cache busting и ошибкам404. - Heredoc делает многострочные команды читаемыми, оставаясь одним слоем; начинайте их с
set -eux.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Dockerfile reference: COPY | https://docs.docker.com/reference/dockerfile/#copy | Флаги --from, --chown, --chmod, --link, --parents |
| Dockerfile reference: ADD | https://docs.docker.com/reference/dockerfile/#add | Автораспаковка архивов, загрузка по URL, флаг --checksum |
| Dockerfile reference: RUN | https://docs.docker.com/reference/dockerfile/#run | Формирование слоя, heredoc-синтаксис |
| Dockerfile reference: heredocs | https://docs.docker.com/reference/dockerfile/#here-documents | Многострочные команды и создание файлов |
| Building best practices | https://docs.docker.com/build/building/best-practices/ | Предпочтение COPY над ADD, объединение apt-get update и install, --no-install-recommends, сортировка аргументов |
| Build cache | https://docs.docker.com/build/cache/ | Правила инвалидации для COPY и RUN, влияние порядка инструкций |
| Multi-stage builds | https://docs.docker.com/build/building/multi-stage/ | COPY --from между стадиями |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → CMD и ENTRYPOINT
Главное оглавление