Stapel-импорт между вспомогательными образами

Уровень: advanced ~45 мин Практика: Vikunja (AGPL-3.0)

Что нужно знать перед уроком

  • M3.04 — Линковка образов — нужно понимать dependencies/imports через targetBuildArg, чтобы сравнить со Stapel-импортом

Что нужно знать перед уроком

M3 урок 4 — линковка образов через dependencies/imports+targetBuildArg (передача метаданных: имя/тег/дайджест). Здесь — другой механизм, специфичный именно для Stapel: прямое копирование файлов из одного образа в другой без прохождения через build-аргументы Dockerfile.

Теория

import: — прямое копирование файлов между образами Stapel

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
image: builder
from: ubuntu:24.04
shell:
  beforeInstall:
    - apt update -q
    - apt install -y gcc g++ build-essential make
  install:
    - cd /app
    - make build
---
image: app
from: alpine:latest
import:
  - image: builder
    add: /app/build/app
    to: /usr/local/bin/app
    after: install

import (в отличие от dependencies/imports из прошлого урока) — механизм именно Stapel-модели (обязателен after/before — стадия целевого Stapel-образа, куда встраивается импорт) и копирует непосредственно файлы/директории, а не передаёт метаданные через build-аргумент. Обязательные поля: image (источник), add (абсолютный путь в источнике), to (абсолютный путь назначения, по умолчанию равен add, если не указан явно), after/before (install или setup).

Источником (image:) может быть не только другой image:-блок текущего werf.yaml, но и произвольный внешний образ по полному имени с тегом или дайджестом (node:24-alpine, nginx:1.30@sha256:...) — он вообще не обязан быть объявлен где-либо в конфигурации проекта:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
image: web
from: nginx:1.30-alpine
import:
  - image: application-assets # свой image:-блок этого werf.yaml
    add: /app/public/assets
    to: /var/www/site/assets
    after: install
  - image: node:24-alpine # произвольный внешний образ, не описанный в werf.yaml
    add: /usr/share/nginx/html
    to: /var/www/site/prebuilt
    after: setup

Это удобно, когда нужный готовый артефакт уже существует в чужом опубликованном образе (например, взять статическую страницу-заглушку из стороннего образа) и заводить для него отдельный image:-блок ради одного import избыточно.

Почему это лучше, чем «поставить-собрать-удалить» в одном слое

Классическая антипаттерн-альтернатива без импорта — установить компилятор, собрать бинарник и удалить компилятор в одном и том же образе:

1
2
3
4
5
6
7
8
# АНТИПАТТЕРН — не делайте так
image: app
from: ubuntu:24.04
shell:
  install:
    - apt update -q && apt install -y gcc make # ставим компилятор
    - make build # собираем
    - apt purge -y gcc make && apt autoremove -y # пытаемся "почистить"

Проблема — из-за copy-on-write (M3 урок 1) удаление пакета не уменьшает физический размер слоя: файлы компилятора всё равно физически присутствовали в промежуточном слое (или в том же слое, если все команды в одном shell-блоке дают один слой) и были лишь помечены как удалённые. Итоговый образ всё равно «толстый». Кроме того, такой слой невозможно эффективно кэшировать по отдельности: изменение кода приложения форсирует пересборку всей цепочки «поставить-собрать-удалить» заново.

import решает обе проблемы: builder-образ с компилятором собирается отдельно (со своим независимым кэшем по стадиям, изученным в M3 уроке 3), а в целевой app-образ копируется только конечный артефакт (add: /app/build/app) — сам компилятор физически никогда не попадает ни в один слой app-образа.

Дополнительные возможности import: маски, владелец, несколько источников

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
image: app
from: alpine:latest
import:
  - image: builder-backend
    add: /app/bin/server
    to: /usr/local/bin/server
    after: install
  - image: builder-frontend
    add: /app/dist
    to: /var/www/html
    after: install
    includePaths:
      - "*.js"
      - "*.css"
      - "*.html"
    owner: nginx
    group: nginx

Можно импортировать из нескольких разных вспомогательных образов в один целевой (backend-бинарник из одного builder’а, frontend-статика из другого) — именно этот случай прямо применим к Vikunja: два builder-образа (Go и Node), один финальный runtime-образ. includePaths/excludePaths фильтруют, какие файлы из директории попадут в целевой образ, owner/group — задают владельца скопированных файлов в целевом образе (актуально для непривилегированных runtime-образов, где процесс должен работать не от root).

Частые ошибки и подводные камни

  • Пытаться использовать import для встраивания в образ, собранный через Dockerfile. Целевым (принимающим import) может быть только Stapel-образ — after/before завязаны на стадии install/setup, которых у Dockerfile-builder просто нет; для линковки с Dockerfile-образами нужен dependencies+COPY --from (M3 урок 4). Источником при этом может быть любой образ — хоть Stapel, хоть внешний по имени/тегу.
  • Забыть про after/before, из-за чего импортированные файлы «встраиваются» на неожидаемой стадии. Импорт применяется к определённой стадии целевого образа — если импортированные файлы нужны до setup (например, для генерации конфигурации на основе бинарника), а указано after: setup, финальная стадия не увидит импортированные файлы.
  • Импортировать избыточно много (всю директорию сборки) вместо конкретного артефакта. Без includePaths/точного add: в целевой образ может попасть больше, чем нужно (промежуточные объектные файлы, кэш пакетного менеджера сборочного окружения).

Практика в кластере

Практики в кластере в этом уроке нет — фокус на механике сборки.

Практика разработки

🧩 Практика урока: Vikunja · лицензия AGPL-3.0
  1. Перепишите werf.yaml Vikunja так, чтобы backend и frontend собирались как отдельные вспомогательные Stapel-образы, а финальный runtime-образ собирал их через import (без компилятора Go и node_modules в финальном слое):
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
project: vikunja
configVersion: 1
---
image: builder-backend
from: golang:1.26
git:
  - add: /api
    to: /app
shell:
  install:
    - cd /app && go build -o /app/bin/vikunja .
---
image: builder-frontend
from: node:24-alpine
git:
  - add: /frontend
    to: /app
shell:
  install:
    - cd /app && npm ci
  setup:
    - cd /app && npm run build
---
image: app
from: alpine:3.22
import:
  - image: builder-backend
    add: /app/bin/vikunja
    to: /usr/local/bin/vikunja
    after: install
  - image: builder-frontend
    add: /app/dist
    to: /var/www/html
    after: install
    owner: nobody
    group: nobody
shell:
  setup:
    - apk add --no-cache ca-certificates
  1. Соберите итоговый образ и сравните его размер с гипотетическим вариантом «всё в одном образе с компилятором и npm»:
1
2
werf build
werf run app -- sh -c "which go; which npm; ls /usr/local/bin/vikunja"

Убедитесь, что ни go, ни npm не присутствуют в финальном runtime-образе — только бинарник и статика.

Шпаргалка команд урока

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import:
  - image: <source-image>
    add: <абсолютный путь в источнике>
    to: <абсолютный путь назначения>
    after: install|setup
    before: install|setup
    includePaths: ["<mask>"]
    excludePaths: ["<mask>"]
    owner: <user>
    group: <group>

Вопросы для самопроверки

Чем import: отличается от dependencies/imports+targetBuildArg?

import — прямое копирование файлов в целевой Stapel-образ; dependencies/imports через targetBuildArg — передача метаданных об образе для использования внутри Dockerfile.

Источник: [Werf — документация](https://ru.werf.io/docs/v2/usage/build/stapel/imports.html vs usage/build/images.html)

Почему паттерн builder+import предпочтительнее установки-сборки-удаления компилятора в одном образе?

При установке-сборке-удалении в одном образе файлы всё равно физически присутствуют в слоях (copy-on-write); builder+import гарантирует, что компилятор физически никогда не попадёт в целевой образ.

Источник: Werf — документация — обоснование паттерна

Что определяет after/before в директиве import?

after/before указывают точку встраивания импортированных файлов в конвейер стадий целевого образа — install или setup.

Источник: Werf — документация

Можно ли импортировать файлы из нескольких разных вспомогательных образов в один целевой образ?

import: — список, каждый элемент которого независимо указывает source image и пути — типичный случай для приложений с backend+frontend builder'ами.

Источник: Werf — документация

Может ли источником import: быть образ, вообще не описанный в werf.yaml текущего проекта?

import поддерживает как импорт из образов текущего проекта, так и из внешних образов по image_name:tag/image_name@digest. Ограничение в другом: целевым (принимающим import) может быть только Stapel-образ, а не Dockerfile-образ — для линковки с Dockerfile нужен dependencies/imports+COPY --from.

Источник: Werf — документация — Import configuration

Рекомендуемая литература

Официальная документация

Статьи и блоги

Книги

  • Nigel Poulton. Docker Deep Dive. 2023. ISBN 978-1916585206.

Связанные материалы