Главная/Dockerfile/Урок

5.3. COPY, ADD и RUN

Цели

После этого материала вы сможете:

  • объяснить, почему COPY предпочтительнее ADD, и назвать два случая, где ADD уместен;
  • применять флаги COPY: --from, --chown, --chmod, --link, --parents;
  • объяснить, что даёт --link, и когда он ускоряет пересборку;
  • объединять команды в одном RUN так, чтобы временные файлы не попадали в слой;
  • использовать heredoc-синтаксис для многострочных команд;
  • объяснить, почему apt-get update и apt-get install должны быть в одной инструкции.

Предварительные знания

Ключевые термины

ТерминОбъяснение
build contextНабор файлов, доступных инструкциям COPY и ADD
heredocСинтаксис передачи многострочного текста команде
cache bustingСитуация, когда закэшированный слой содержит устаревшие данные
--linkФлаг COPY, создающий независимый слой без привязки к предыдущим
layer squashingОбъединение нескольких слоёв в один

Теория

COPY против ADD

Обе инструкции копируют файлы в образ. ADD умеет больше — и именно поэтому применять её обычно не следует.

ВозможностьCOPYADD
Копирование из контекстадада
Копирование из другой стадии (--from)дада
Автоматическая распаковка локальных архивовнетда
Загрузка по URLнетда
Проверка контрольной суммы (--checksum)нетда
Клонирование Git-репозиториянетда

Проблема ADD — в автоматической распаковке. Инструкция

dockerfile
ADD archive.tar.gz /app/

распакует архив. А инструкция

dockerfile
ADD data.txt /app/

скопирует файл. Поведение зависит от содержимого, и при смене формата файла результат меняется молча.

Второй риск: распаковка выполняется без ограничений на пути внутри архива. Архив, собранный злоумышленником, может содержать пути вида ../../etc/passwd.

Правило: используйте COPY всегда, кроме двух случаев.

Случай 1: распаковка локального архива. Когда распаковка действительно нужна, ADD избавляет от установки tar в образ:

dockerfile
ADD --chown=app:app rootfs.tar.gz /

Случай 2: загрузка по URL с проверкой контрольной суммы. Начиная с Dockerfile 1.6 доступен флаг --checksum:

dockerfile
ADD --checksum=sha256:9f8e7d6c... https://example.com/tool.tar.gz /tmp/

Без --checksum загрузка по URL — плохая практика: содержимое может измениться, а слой закэширован по URL, а не по содержимому. Лучше RUN curl с явной проверкой.

Флаги COPY

--from=<стадия|образ> — копировать не из контекста, а из другой стадии сборки или из готового образа:

dockerfile
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=<права> — задать права доступа:

dockerfile
COPY --chmod=755 entrypoint.sh /entrypoint.sh

Избавляет от отдельной инструкции RUN chmod, которая создала бы дополнительный слой.

--parents — сохранить структуру родительских каталогов:

dockerfile
COPY --parents ./src/*/pyproject.toml /app/

Без флага все файлы легли бы в один каталог. С флагом сохранится вложенность.

--link — создать слой, не зависящий от предыдущих. Разбирается ниже.

Обычная инструкция COPY создаёт слой поверх результата предыдущих инструкций: ей нужно знать состояние файловой системы, чтобы правильно наложить изменения.

С флагом --link слой создаётся независимо и присоединяется к образу как самостоятельный. Последствие: изменение предыдущих слоёв не инвалидирует этот слой.

text
   без --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 нельзя отделять

Классическая ошибка:

dockerfile
RUN apt-get update
RUN apt-get install -y curl

Проблема не в лишнем слое, а в кэше. При пересборке через месяц слой с apt-get update будет взят из кэша — с индексом пакетов месячной давности. Инструкция apt-get install тоже может быть закэширована, но если её изменить (добавить пакет), она выполнится со старым индексом и попытается скачать версии, которых уже нет в репозитории.

Результат — ошибка 404 Not Found при установке пакета, который точно существует. Явление известно как cache busting.

Правильно — одна инструкция:

dockerfile
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-синтаксис, делающий многострочные команды читаемыми:

dockerfile
# 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 не будет распознан, и сборка упадёт с синтаксической ошибкой.

Отдельно полезна форма записи файла:

dockerfile
# 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

  1. BuildKit определяет список файлов-источников с учётом .dockerignore.
  2. Для каждого вычисляется контрольная сумма содержимого и метаданных (права, владелец, время изменения).
  3. Формируется ключ кэша из этих сумм и параметров инструкции.
  4. При совпадении ключа слой берётся из кэша, файлы не передаются.
  5. Иначе файлы передаются и записываются в новый слой.

Шаг 2 объясняет неочевидное: изменение прав доступа файла инвалидирует кэш так же, как изменение содержимого. Время изменения при этом учитывается не всегда — поведение зависит от версии BuildKit.

Почему RUN не имеет доступа к предыдущему RUN

Каждая инструкция RUN выполняется в новом container, создаваемом из результата предыдущего слоя. Поэтому:

  • переменные оболочки не сохраняются между инструкциями;
  • cd не действует за пределами инструкции;
  • фоновые процессы, запущенные в RUN, завершаются вместе с инструкцией.

Последний пункт означает, что нельзя «запустить базу данных в RUN, чтобы наполнить её». Процесс умрёт до следующей инструкции.


Команды и примеры

Подготовка

bash
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 — нет

bash
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
text
--- COPY ---
/test/via-copy:
archive.tar.gz
--- ADD ---
/test/via-add:
config

/test/via-add/config:
app.ini

COPY положил архив, ADD распаковал. Поведение ADD зависит от того, распознан ли файл как архив, — а это определяется содержимым, не расширением.

COPY --chown и права

bash
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
text
-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

bash
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"

С флагом:

bash
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
text
/out/src/pkg-a/main.py
/out/src/pkg-b/main.py

Структура сохранена. Без флага два файла с одинаковым именем перезаписали бы друг друга.

Типичное применение — монорепозиторий, где нужно скопировать все pyproject.toml для установки зависимостей до копирования исходников.

COPY --from из чужого образа

bash
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
text
uv 0.12.0

Мы получили uv без установщика, без curl и без дополнительных слоёв — скопирован один файл из образа, который сам по себе в результат не попал.

Приём широко применяется для CLI-утилит, распространяемых как статические бинарные файлы.

RUN: очистка в той же инструкции

bash
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'
text
apt:bad               142MB
apt:good              108MB
debian:trixie-slim    97.2MB

Разница 34 MB. Она складывается из двух источников:

bash
echo "=== слои bad ==="
docker history apt:bad --format '{{.Size}}\t{{.CreatedBy}}' | head -4
echo "=== слои good ==="
docker history apt:good --format '{{.Size}}\t{{.CreatedBy}}' | head -3
text
=== слои 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

bash
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
text
первая сборка выполнена
 => CACHED [2/3] RUN apt-get update
 => [3/3] RUN apt-get install -y --no-install-recommends curl jq

apt-get update взят из кэша. Сейчас это сработало, потому что индекс свежий. Но через несколько недель закэшированный индекс устареет, и установка нового пакета завершится ошибкой вида:

text
E: Failed to fetch http://deb.debian.org/.../jq_1.7.1-3_amd64.deb  404  Not Found

Пакет существует, но версия в устаревшем индексе больше не доступна в репозитории. Диагностировать это трудно: Dockerfile выглядит корректно, а сборка падает.

Объединённая инструкция от этого защищена: изменение списка пакетов инвалидирует всё, включая update.

Heredoc

bash
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$'
text
[server]
host = 0.0.0.0
port = 8000
workers = 4
--- число слоёв с данными ---
3

Многострочная команда осталась одним слоем. Директива set -eux в начале — хорошая практика: -e прерывает при ошибке, -u ловит необъявленные переменные, -x печатает команды в лог сборки.

Без set -e внутри heredoc ошибка промежуточной команды не прервёт сборку — это отличие от цепочки &&, где каждая следующая команда выполняется только при успехе предыдущей.

bash
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'
text
=== без --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:

bash
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
text
app.txt

Файл from-run.txt исчез: слой --link создан независимо и заменил каталог /data целиком, а не дополнил его.

Это ожидаемое поведение, но неочевидное. Правило: --link применяйте для каталогов, которые целиком формируются этой инструкцией, а не дополняют результат предыдущих.

Уборка

bash
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, а сборка при изменении одной строки кода занимает полторы минуты.

dockerfile
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.

Решение

Сначала выполните задание самостоятельно.

Показать решение

Найденные проблемы.

ПроблемаПоследствие
1ADD по URL без --checksumСодержимое может измениться; слой кэшируется по URL, а не по содержимому
2apt-get update отдельной инструкциейCache busting: установка нового пакета со старым индексом даёт 404
3Нет --no-install-recommendsЛишние десятки мегабайт
4rm -rf /var/lib/apt/lists/* отдельной инструкциейИндекс apt (~30 MB) остаётся в слое навсегда
5build-essential в финальном образе~200 MB компилятора, не нужного при работе
6COPY . /app до pip installЛюбое изменение кода пересобирает зависимости
7RUN chmod отдельной инструкциейЛишний слой с копией файла (copy-up)
8RUN chown -R /app отдельной инструкциейДублирует весь каталог приложения в новом слое
9USER nobodyПользователь nobody разделяется с системой; нет своего домашнего каталога
10Нет .dockerignore (подразумевается)В контекст и образ попадает лишнее

Проблема 8 заслуживает пояснения: chown -R изменяет метаданные каждого файла, а изменение метаданных вызывает copy-up (урок 3.2). Каталог приложения на 50 MB продублируется, добавив 50 MB к образу.

Исправленная версия.

dockerfile
# 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 заменим на локальный файл, чтобы пример работал без внешней зависимости:

bash
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
text
=== размеры ===
REPOSITORY:TAG    SIZE
ex53:after        168MB
ex53:before       412MB

=== обе версии работают ===
работает
работает

=== пересборка после изменения кода ===
before: 34.82 c
after:  0.91 c

Образ уменьшился с 412 до 168 MB, пересборка при изменении кода — с 35 секунд до 0.9.

Откуда взялся выигрыш по размеру:

bash
docker history ex53:before --format '{{.Size}}\t{{.CreatedBy}}' | head -6
text
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, и слой с зависимостями переиспользуется, пока не изменится сам файл.

bash
cd /tmp && docker rmi -f ex53:before ex53:after > /dev/null 2>&1; rm -rf /tmp/ex53

Проверка результата

bash
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 на каталоге приложенияПривычно из shellCopy-up дублирует весь каталог; использовать COPY --chown
RUN chmod +x после COPYПривычноЛишний слой; использовать COPY --chmod
COPY . . до установки зависимостейПорядок кажется неважнымЛюбое изменение кода пересобирает зависимости
--link при копировании в существующий каталогНе знают об ограниченииСлой создаётся независимо и заменяет каталог целиком
Запуск фонового процесса в RUNКажется, что он останетсяПроцесс завершается вместе с инструкцией
RUN cd dir && cmd вместо WORKDIRПривычкаРаботает, но WORKDIR читается лучше и действует на последующие инструкции

Контрольные вопросы

На понимание:

  1. Почему ADD не рекомендуется как замена COPY?
  2. Почему apt-get update и apt-get install должны быть в одной инструкции?
  3. Почему RUN rm -rf /var/lib/apt/lists/* отдельной инструкцией не уменьшает образ?
  4. Что даёт COPY --link и в каком случае он не сработает как ожидается?
  5. Почему RUN chown -R /app может добавить к образу десятки мегабайт?

На применение:

  1. Как скопировать один бинарный файл из чужого образа, не устанавливая его?
  2. Как задать права и владельца копируемых файлов без дополнительных инструкций RUN?
  3. Как создать небольшой конфигурационный файл в образе, не добавляя его в контекст?

На диагностику:

  1. Сборка падает с 404 Not Found при установке пакета, который точно существует в репозитории. Причина?
  2. После добавления COPY --link файл, созданный предыдущей инструкцией RUN, исчез из образа. Объясните.

Краткое резюме

  1. COPY предпочтительнее ADD: поведение ADD зависит от содержимого файла.
  2. ADD уместен в двух случаях: распаковка локального архива и загрузка по URL с --checksum.
  3. COPY --from берёт файлы из другой стадии или из чужого образа.
  4. --chown и --chmod задают владельца и права без дополнительных слоёв.
  5. --parents сохраняет структуру каталогов при копировании по маске.
  6. --link создаёт независимый слой, переживающий изменение предыдущих, но заменяет каталог целиком.
  7. Каждая инструкция RUN создаёт слой из результата всей команды.
  8. Файлы, созданные и удалённые внутри одного RUN, в слой не попадают.
  9. Разделение apt-get update и install приводит к cache busting и ошибкам 404.
  10. Heredoc делает многострочные команды читаемыми, оставаясь одним слоем; начинайте их с set -eux.

Официальные источники

ИсточникСсылкаЧто подтверждает
Dockerfile reference: COPYhttps://docs.docker.com/reference/dockerfile/#copyФлаги --from, --chown, --chmod, --link, --parents
Dockerfile reference: ADDhttps://docs.docker.com/reference/dockerfile/#addАвтораспаковка архивов, загрузка по URL, флаг --checksum
Dockerfile reference: RUNhttps://docs.docker.com/reference/dockerfile/#runФормирование слоя, heredoc-синтаксис
Dockerfile reference: heredocshttps://docs.docker.com/reference/dockerfile/#here-documentsМногострочные команды и создание файлов
Building best practiceshttps://docs.docker.com/build/building/best-practices/Предпочтение COPY над ADD, объединение apt-get update и install, --no-install-recommends, сортировка аргументов
Build cachehttps://docs.docker.com/build/cache/Правила инвалидации для COPY и RUN, влияние порядка инструкций
Multi-stage buildshttps://docs.docker.com/build/building/multi-stage/COPY --from между стадиями

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → CMD и ENTRYPOINT
Главное оглавление

Markdown на GitHub ↗