Чтобы вам было проще изучать информацию в области DevOps и Kubernetes из зарубежных источников, мы выбираем интересные англоязычные материалы и переводим их. Очередным таким материалом стала статья про устройство distroless-контейнеров от инженера и программиста Ивана Величко. Дальше идёт текст автора.

Базовые distroless-образы GoogleContainerTools часто упоминаются как один из способов создания (более) маленьких, (более) быстрых и (более) безопасных контейнеров. Но что на самом деле они собой представляют? Зачем они нужны? В чем разница между контейнером, созданным на distroless-базе, и контейнером, созданным с нуля? Давайте разберёмся.

Подводные камни контейнеров, созданных с нуля

Некоторое время назад я пытался развенчать (только мое?) заблуждение, что у каждого контейнера внутри есть операционная система. Используя пустой (он же scratch) базовый образ, я создал контейнер, содержащий всего один файл — крошечную программу hello-world. И, к моему огромному удивлению, она заработала. Также пришло понимание, что в контейнере не обязательно должен быть полноценный дистрибутив Linux. Но, как это часто бывает, эксперимент оказался не совсем научным.

Я сознательно упростил исполняемый файл: он был статически скомпонован и всего лишь печатал кучку ASCII-символов. Что, если повторить эксперимент, но уже с чуть более сложными приложениями? Всплывут ли неочевидные проблемы?

Готовим тестовое окружение

Статья будет практической, единственное условие — наличие машины с Docker. Для воспроизводимости я напишу примеры в виде многоступенчатых Docker-файлов (используя функцию heredoc). Но, чтобы не раздувать статью, большая часть Docker-файлов останется под катом — будут приведены только важные части.

Вот общая идея:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  ...
)

func main() {
  <...test program goes here...>
}
EOF

RUN CGO_ENABLED=0 go build main.go


# -=== Target image ===-
FROM scratch

COPY --from=builder /app/main /

CMD ["/main"]

Подводный камень №1: в scratch-контейнерах нет управления пользователями

Начнём с того, что попробуем поместить в такой «пустой» контейнер скрипт на Go, выводящий информацию о пользователе:

user, err := user.Current()
if err != nil {
  panic(err)
}

fmt.Println("UID:", user.Uid)
fmt.Println("GID:", user.Gid)
fmt.Println("Username:", user.Username)
fmt.Println("Name:", user.Name)
fmt.Println("HomeDir:", user.HomeDir)
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "os/user"
)

func main() {
  user, err := user.Current()
  if err != nil {
    panic(err)
  }

  fmt.Println("UID:", user.Uid)
  fmt.Println("GID:", user.Gid)
  fmt.Println("Username:", user.Username)
  fmt.Println("Name:", user.Name)
  fmt.Println("HomeDir:", user.HomeDir)
}
EOF

RUN CGO_ENABLED=0 go build main.go

# -=== Target image ===-
FROM scratch

COPY --from=builder /app/main /

CMD ["/main"]

Команда для сборки:

$ docker buildx build -t scratch-current-user .

Запустим его и посмотрим, будет ли он работать:

$ docker run --rm scratch-current-user
panic: user: Current requires cgo or $USER set in environment

goroutine 1 [running]:
main.main()
  /app/main.go:11 +0x23c

Неудача с первой попытки. Реально ли это исправить?

cgo здесь не подходит (я намеренно отключил его, чтобы избежать зависимости от libc или любой другой общей библиотеки). Поэтому, согласно Go stdlib, единственный способ решить проблему — установить переменную окружения $USER:

$ docker run --rm -e USER=root scratch-current-user
UID: 0
GID: 0
Username: root
Name:
HomeDir: /

Похоже, работает. Но контейнер не должен запускаться под root'ом. Реально ли запустить его от обычного пользователя?

$ docker run --rm -e USER=nonroot scratch-current-user
UID: 0
GID: 0
Username: nonroot
Name:
HomeDir: /

У nonroot также UID 0. То есть это тот же root. Может быть, поможет флаг --user?

$ docker run --user nonroot --rm scratch-current-user
docker: Error response from daemon:
  unable to find user root:
  no matching entries in passwd file.

Нет, но Docker дал хорошую подсказку — есть ли вообще файл passwd? Итак, первое открытие: файлы /etc/passwd и /etc/group отсутствуют в scratch-контейнерах. Если их вручную добавить в конечный образ, проблема уходит:

FROM scratch

COPY <<EOF /etc/group
root:x:0:
nonroot:x:65532:
EOF

COPY <<EOF /etc/passwd
root:x:0:0:root:/root:/sbin/nologin
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin
EOF

COPY --from=builder /app/main /

CMD ["/main"]
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "os/user"
)

func main() {
  user, err := user.Current()
  if err != nil {
    panic(err)
  }

  fmt.Println("UID:", user.Uid)
  fmt.Println("GID:", user.Gid)
  fmt.Println("Username:", user.Username)
  fmt.Println("Name:", user.Name)
  fmt.Println("HomeDir:", user.HomeDir)
}
EOF

RUN CGO_ENABLED=0 go build main.go

# -=== Target image ===-
FROM scratch

COPY <<EOF /etc/group
root:x:0:
nonroot:x:65532:
EOF

COPY <<EOF /etc/passwd
root:x:0:0:root:/root:/sbin/nologin
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin
EOF

COPY --from=builder /app/main /

CMD ["/main"]

Для сборки используйте команду:

$ docker buildx build -t scratch-current-user-fixed .

$ docker run --user root --rm scratch-current-user-fixed
UID: 0
GID: 0
Username: root
Name: root
HomeDir: /root

$ docker run --user nonroot --rm scratch-current-user-fixed
UID: 65532
GID: 65532
Username: nonroot
Name: nonroot
HomeDir: /home/nonroot

Наконец всё заработало. Впрочем, вручную управлять пользователями неприкольно.

Подводный камень №2: в scratch-контейнерах отсутствуют нужные директории

Вот ещё один пример — довольно часто программа создаёт временные файлы и директории:

f, err := os.CreateTemp("", "sample")
if err != nil {
  panic(err)
}

fmt.Println("Temporary file:", f.Name())
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "os"
)

func main() {
  f, err := os.CreateTemp("", "sample")
  if err != nil {
    panic(err)
  }

  fmt.Println("Temporary file:", f.Name())
}
EOF

RUN CGO_ENABLED=0 go build main.go

# -=== Target image ===-
FROM scratch

COPY --from=builder /app/main /

CMD ["/main"]

Команда для сборки:

$ docker buildx build -t scratch-tmp-file .

Но создать временный файл с помощью приведённого выше фрагмента Go в scratch-контейнере не получится:

$ docker run --rm scratch-tmp-file
panic: open /tmp/sample386939664: no such file or directory

goroutine 1 [running]:
main.main()
  /app/main.go:11 +0xbc

Починить легко — достаточно сделать так, чтобы в запущенном контейнере была директория /tmp. Этого можно добиться разными способами, в том числе примонтировав папку «на лету». Однако вручную это делать не слишком удобно (бит sticky заставляет тщательно подходить к выбору режима для директории):

$ docker run --rm --mount 'type=tmpfs,dst=/tmp,tmpfs-mode=1777' scratch-tmp-file
Temporary file: /tmp/sample2333717960

Кроме того, другие важные директории — /home или /var — тоже могут отсутствовать.

Подводный камень №3: в «нулевых» контейнерах нет сертификатов СА

Другая ожидаемая проблема со scratch-контейнерами — взаимодействие с другими сервисами по HTTPS. Например, этот простой код скачивает главную страницу моего блога:

resp, err := http.Get("https://iximiuz.com/")
if err != nil {
  panic(err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
  panic(err)
}
fmt.Println("Response", body)
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "io"
  "net/http"
)

func main() {
  resp, err := http.Get("https://iximiuz.com/")
  if err != nil {
    panic(err)
  }
  defer resp.Body.Close()

  body, err := io.ReadAll(resp.Body)
  if err != nil {
    panic(err)
  }
  fmt.Println("Response", body)
}
EOF

RUN CGO_ENABLED=0 go build main.go

# -=== Target image ===-
FROM scratch

COPY --from=builder /app/main /

CMD ["/main"]

Команда для сборки:

$ docker buildx build -t scratch-https .

Если запустить его из scratch-контейнера, появится следующая ошибка:

$ docker run --rm scratch-https
panic: Get "https://iximiuz.com/": x509: certificate signed by unknown authority

goroutine 1 [running]:
main.main()
  /app/main.go:12 +0x144

Решение вполне очевидное — надо лишь забросить СА-сертификаты в нужную директорию в целевом контейнере. Например, можно скопировать актуальную директорию /etc/ssl/certs/ со стадии сборки. Но мало кто захочет делать это вручную.

Подводный камень №4: в scratch-образах нет информации о часовом поясе

Сколько времени сейчас в Амстердаме?

loc, err := time.LoadLocation("Europe/Amsterdam")
if err != nil {
  panic(err)
}

fmt.Println("Now in Amsterdam:", time.Now().In(loc))
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "time"
)

func main() {
  loc, err := time.LoadLocation("Europe/Amsterdam")
  if err != nil {
    panic(err)
  }

  fmt.Println("Now in Amsterdam:", time.Now().In(loc))
}
EOF

RUN CGO_ENABLED=0 go build main.go

# -=== Target image ===-
FROM scratch

COPY --from=builder /app/main /

CMD ["/main"]pp/main /

CMD ["/main"]

Команда для сборки:

$ docker buildx build -t scratch-tz .

Приведенный выше фрагмент на Go не поможет, если запустить его в «нулевом» контейнере:

$ docker run --rm scratch-tz
panic: unknown time zone Europe/Amsterdam

goroutine 1 [running]:
main.main()
  /app/main.go:11 +0x140

Как и с СА-сертификатами, информация о часовом поясе обычно хранится на диске (например, в /usr/share/zoneinfo). Программы обращаются к ней по мере необходимости. Поскольку она не может сама появиться в «нулевом» контейнере, её сначала необходимо добавить в образ (или примонтировать при запуске контейнера).

Завершаются ли на этом подводные камни, связанные с использованием scratch-контейнеров? Вряд ли. Но этого определённо хватило мне, чтобы задуматься об альтернативах.

Distroless-образы

Встречайте первый образ без дистрибутива — distroless/static

Подводя промежуточный итог, можно сказать, что в scratch-контейнерах:

  • нет механизмов управления пользователями;

  • отсутствуют важные директории (/tmp, /home, /var);

  • нет СА-сертификатов;

  • нет информации о часовом поясе.

Список наверняка этим не исчерпывается. Технически базовые образы остаются приемлемым вариантом для создания «тонких» контейнеров. В реальности их использование для рабочих нагрузок в production приведёт к значительным операционным издержкам, вызванным «неполнотой» получаемых контейнеров.

Впрочем, мне по-прежнему по душе идея вставлять в образы только необходимые вещи. Как показали приведённые выше эксперименты, не так уж сложно создать базовый образ с необходимыми файлами и структурой директорий, но без полноценного дистрибутива Linux с менеджером пакетов и десятками (или сотнями) системных библиотек. Просто это утомительно.

И тут на помощь как раз приходят distroless-образы.

Идея проекта GoogleContainerTools/distroless довольно проста — сделать несколько минимально жизнеспособных базовых образов (с минимумом компонентов) и автоматизировать процедуру их создания.

Начать знакомство с проектом лучше всего с базового образа distroless/static:

$ docker pull gcr.io/distroless/static

# Inspect it with github.com/wagoodman/dive
$ dive gcr.io/distroless/static

Вывод dive говорит нам о том, что:

  • образ основан на Debian (то есть в distroless-образе все же есть дистрибутив, но он ободран до костей);

  • он занимает всего ~2 МБ и имеет один слой (что просто замечательно);

  • структура директорий внутри типична для дистрибутивов Linux;

  • присутствуют файлы /etc/passwd, /etc/group и даже /etc/nsswitch.conf;

  • сертификаты и база данных часовых поясов тоже на месте;

  • все лицензии на месте (но я не эксперт).

И всё. То есть на 99,99% образ состоит из статики (ну и единственного исполняемого файла tzconfig). Ни пакетов, ни менеджера пакетов, ни следа libc.

Если бы я использовал gcr.io/distroless/static в качестве базового образа (вместо scratch-образа), всех вышеописанных проблем не возникло бы. Даже в случае nonroot-пользователя. Вот как выглядит файл /etc/passwd в distroless-образе:

root:x:0:0:root:/root:/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/sbin/nologin
nonroot:x:65532:65532:nonroot:/home/nonroot:/sbin/nologin

Не все программы скомпонованы статически (statically linked)

Приятный побочный продукт экспериментов с «нулевыми» контейнерами — возможность узнать, что на самом деле нужно для запуска программы. Для статически собранных исполняемых файлов достаточно конфигурационных файлов и правильной структуры директорий rootfs. Но что потребуется для динамически связанного приложения?

Попробую скомпилировать эту программу на Go с включённым CGO, а затем запустить её на полноценном дистрибутиве Ubuntu, чтобы посмотреть, какие динамически загружаемые библиотеки ей нужны:

package main

import (
  "fmt"
  "os/user"
)

func main() {
    u, err := user.Current()
    if err != nil {
        panic(err)
    }
    fmt.Println("Hello from", u.Username)
}
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM golang:1 as builder

WORKDIR /app

COPY <<EOF main.go
package main

import (
  "fmt"
  "os/user"
)

func main() {
    u, err := user.Current()
    if err != nil {
        panic(err)
    }
    fmt.Println("Hello from", u.Username)
}
EOF

RUN CGO_ENABLED=1 go build main.go

# -=== Target image ===-
FROM ubuntu

COPY --from=builder /app/main /

CMD ["/main"]

Команда для сборки:

$ docker buildx build -t go-cgo-ubuntu .

Поможет мне в этом могучая ldd:

$ docker run --rm go-cgo-ubuntu
Hello from root

$ docker run --rm go-cgo-ubuntu ldd /main
    linux-vdso.so.1 (0x0000ffffbe929000)
    libpthread.so.0 => /lib/aarch64-linux-gnu/libpthread.so.0 (0x0000ffffbe8d0000)
    libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffffbe720000)
    /lib/ld-linux-aarch64.so.1 (0x0000ffffbe8f0000)

В выводе стандартный набор общих библиотек, необходимых для динамически скомпонованного исполняемого файла Linux (в том числе libc). Конечно, ни одной из них нет в образе distroless/static.

Знакомьтесь: второй distroless-образ — distroless/base

Образ distroless/static — идеальный выбор, если программа представляет собой статически скомпонованный исполняемый файл на Go. Но что, если без CGO не обойтись, а нужные библиотеки статически скомпоновать невозможно (камень в огород glibc)? Или если вы пишете на Rust, C или любом другом компилируемом языке с менее совершенной поддержкой статических сборок, чем в Go?

Поможет образ distroless/base:

$ docker pull gcr.io/distroless/base

$ dive gcr.io/distroless/base

Вывод dive говорит нам о том, что:

  • он в 10 раз больше, чем distroless/static (но всё равно весит всего 20 MБ);

  • в нём два слоя (первый — distroless/static);

  • второй уровень включает кучу общих библиотек — прежде всего libc и openssl;

  • опять же, никаких типичных для дистрибутивов Linux излишеств.

Вот как настроить целевой образ Go на работу с новой distroless-базой:

# -=== Target image ===-
FROM gcr.io/distroless/base
COPY --from=builder /app/main /
CMD ["/main"]

Не все случаи динамической компоновки одинаковы

В предыдущем разделе я упомянул Rust из-за его популярности. Посмотрим, сможет ли он работать с образом distroless/base. Напишем простенькую программу:

fn main() {
  println!("Hello world! (Rust edition)");
}
Нажмите здесь, чтобы раскрыть весь сценарий.

Dockerfile:

# syntax=docker/dockerfile:1.4

# -=== Builder image ===-
FROM rust:1 as builder

WORKDIR /app

COPY <<EOF Cargo.toml
[package]
name = "hello-world"
version = "0.0.1"
EOF

COPY <<EOF src/main.rs
fn main() {
  println!("Hello world! (Rust edition)");
}
EOF

RUN cargo install --path .

# -=== Target image ===-
FROM gcr.io/distroless/base

COPY --from=builder /usr/local/cargo/bin/hello-world /

CMD ["/hello-world"]

Команда для сборки:

$ docker buildx build -t distroless-base-rust .

Попробуем её запустить:

$ docker run --rm distroless-base-rust
/hello-world: error while loading shared libraries:
libgcc_s.so.1: cannot open shared object file:
No such file or directory

Похоже, в образе distroless/base есть не все нужные библиотеки. По какой-то причине Rust нуждается в libgcc, но она в контейнере отсутствует.

Пришло время третьего distroless-образа — distroless/cc

Оказывается, Rust не так уж уникален в своих требованиях. Эта зависимость настолько распространена, что для неё был создан отдельный базовый образ — distroless/cc:

$ docker pull gcr.io/distroless/cc

$ dive gcr.io/distroless/cc

Вывод dive говорит о том, что:

  • это трёхслойный образ (на базе distroless/base);

  • новый слой весит всего 2 МБ;

  • новый слой содержит libstdc++, кучу статических активов и даже несколько скриптов Python (но без самого Python).

Поправим пример с Rust:

# -=== Target image ===-
FROM gcr.io/distroless/cc

COPY --from=builder /usr/local/cargo/bin/hello-world /

CMD ["/hello-world"]

Базовые образы для интерпретируемых языков или ВМ-языков

Некоторые языки, например Python, требуют наличия интерпретатора для запуска скрипта. Для других, допустим JavaScript или Java, требуется полноценная среда исполнения, например Node.js или JVM. Поскольку в рассмотренных до сих пор distroless-образах отсутствуют менеджеры пакетов, добавить в них Python, OpenJDK или Node.js непросто.

К счастью, distroless-проект поддерживает самые популярные рантаймы «из коробки»:

Вышеперечисленные базовые образы построены на базе distroless/cc — к нему добавляются один-два слоя с соответствующим рантаймом или интерпретатором.

Вот как выглядит итоговая иерархия образов:

Кто использует базовые distroless-образы

distroless/static — мой любимый базовый distroless-образ. И подобными образами пользуются Kubernetes (мотивация), Knative и Kubebuilder. Проекты ko и Jib также используют distroless-базу, то есть большинство их пользователей становятся косвенными потребителями distroless-образов.

Плюсы и минусы distroless-образов, а также альтернативы 

Distroless-образы маленькие, быстрые и более безопасные. Для меня это самый главный их плюс. Кроме того, поскольку их генерация детерминирована, теоретически можно закодировать SBOM(-подобную) информацию в каждой сборке, упростив жизнь сканерам уязвимостей. Но насколько мне известно, это ещё не сделано, и сканеры на самом деле выдают не особо значимые результаты для образов, основанных на distroless.

В то же время конкретно эта реализация distroless представляется недостаточно гибкой. Добавлять новые компоненты в distroless-базу сложно. Для изменения самой базы требуется знание bazel, а ещё, похоже, надо быть мейнтейнером форка. И позже добавление компонентов в образ осложняется отсутствием менеджеров пакетов. Выбор базовых образов определяется мейнтейнерами проекта. Если ни один из них вам не подходит, вы ничего не сможете сделать.

Базовые distroless-образы (автоматически) отслеживают upstream-выпуски Debian, поэтому CVE в них устраняются так же быстро, как в родительском дистрибутиве (делайте выводы) и рантайме соответствующего языка.

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

Если вам нравится идея создавать свои образы из некоей минимальной базы, стоит обратить внимание на:

  • Проект Distroless 2.0 — использует Alpine в качестве минималистичного и безопасного базового образа. С помощью пары инструментов, apko и melange, можно собрать образ для конкретного приложения, содержащий только нужные компоненты.

  • Chisel — напоминает вышеупомянутый проект, но от Canonical, следовательно, на базе Ubuntu. Проект очень новый, но Microsoft уже использует его в вашем production.

  • Многоступенчатые сборки Docker — можно начать «с нуля» и аккуратно скопировать только нужные компоненты из этапов сборки в целевой образ.

  • Buildah — мощный инструмент для создания образов контейнеров, который позволяет создавать контейнеры «с нуля», потенциально используя возможности хост-системы. Вот пример.

Всё ещё нужны минималистичные образы контейнеров, но нет времени на все эти премудрости? Есть решение:

  • DockerSlim — инструмент CLI, который автоматически преобразует «толстый» образ контейнера в «тонкий», анализируя рантайм целевого контейнера и выбрасывая всё лишнее.

Подробнее о трудностях создания хороших образов контейнеров можно прочитать в моей статье.

Заключение

Должен ли контейнер содержать полноценный дистрибутив Linux? Короткий ответ — «нет». Но на самом деле всё гораздо сложнее, чем просто «нет». Формально scratch-контейнеры работают, при этом часто в них отсутствуют важные элементы, которые, как мы подсознательно ожидаем, должны присутствовать всегда. Например, сертификаты CA или информация о часовых поясах. GoogleContainerTools — один из проектов, который пытается сделать scratch-образы пригодными для использования простыми смертными.

P. S.

Читайте также в нашем блоге:

Комментарии (2)


  1. amironov
    28.06.2024 07:30
    +2

    Спасибо, познавательно. Могу добавить в копилку редхатовский ubi-micro с размером 23.5 Mb, для расширения которого как раз хорошо подходит buildah.


  1. Kahelman
    28.06.2024 07:30

    Ещё есть вариант user space Linux.

    Единственное ограничение можно только Linux на Linux запускать. Хотя большинство докеров все равно на Linux крутятся