Три простых приема для уменьшения Docker-образов

в 4:32, , рубрики: devops, docker, kubernetes, node.js, администрирование веб-серверов, Блог компании Nixys, оптимизация, Серверное администрирование, системное администрирование

image

Когда дело доходит до создания Docker-контейнеров, лучше всегда стремиться к минимизации размера образов. Образы, которые используют одни и те же слои и весят меньше — быстрее переносятся и деполятся.

Но как контролировать размер, когда каждое выполнение оператора RUN создает новый слой? Плюс, еще нужны промежуточные артефакты до создания самого образа...

Возможно, вы знаете, что большинство Docker-файлов имеют свои, довольно странные, особенности, например:

FROM ubuntu
RUN apt-get update && apt-get install vim

Ну зачем тут &&? Разве не проще запустить два оператора RUN, как здесь?

FROM ubuntu
RUN apt-get update
RUN apt-get install vim

Начиная с версии Docker 1.10, операторы COPY, ADD и RUN добавляют новый слой к образу. В предыдущем примере были созданы два слоя вместо одного.

image

Слои как Git-коммиты.

Docker-слои сохраняют различия между предыдущей и текущей версией образа. И как Git-коммиты, они удобны, если вы делитесь ими с другими репозиториями или образами. Фактически, при запросе образа из реестра, загружаются только недостающие слои, что упрощает разделение образов между контейнерами.

Но при этом, каждый слой занимает место, и чем их больше, тем тяжелее итоговый образ. Git-репозитории в этом отношении схожи: размер репозитория растет вместе с количеством слоев, потому что должен хранить все изменения между коммитами. Раньше была хорошая практика объединять несколько операторов RUN в одной строке, как в первом примере. Но теперь, увы, нет.

1. Объединяем нескольких слоев в один с помощью поэтапной сборки Docker-образов

Когда Git-репозиторий разрастается, можно просто свести всю историю изменений в один commit и забыть о нем. Оказалось, что нечто подобное можно реализовать и в Docker — посредством поэтапной сборки.

Давайте создадим контейнер Node.js.

Начнем с index.js:

const express = require('express')
const app = express()
app.get('/', (req, res) => res.send('Hello World!'))
app.listen(3000, () => {
 console.log(`Example app listening on port 3000!`)
})

и package.json:

{
 "name": "hello-world",
 "version": "1.0.0",
 "main": "index.js",
 "dependencies": {
   "express": "^4.16.2"
 },
 "scripts": {
   "start": "node index.js"
 }
}

Упакуем приложение со следующим Dockerfile:

FROM node:8
EXPOSE 3000
WORKDIR /app
COPY package.json index.js ./
RUN npm install
CMD ["npm", "start"]

Создадим образ:

$ docker build -t node-vanilla

Проверим, что все работает:

$ docker run -p 3000:3000 -ti --rm --init node-vanilla

Теперь можно пройти по ссылке: http://localhost:3000 и увидеть там «Hello World!».

В Dockerfile теперь у нас есть операторы COPY и RUN, так что фиксируем увеличение как минимум на два слоя, по сравнению с исходным образом:

$ docker history node-vanilla
IMAGE          CREATED BY                                      SIZE
075d229d3f48   /bin/sh -c #(nop)  CMD ["npm" "start"]          0B
bc8c3cc813ae   /bin/sh -c npm install                          2.91MB
bac31afb6f42   /bin/sh -c #(nop) COPY multi:3071ddd474429e1…   364B
500a9fbef90e   /bin/sh -c #(nop) WORKDIR /app                  0B
78b28027dfbf   /bin/sh -c #(nop)  EXPOSE 3000                  0B
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

Как видим, итоговый образ возрос на пять новых слоев: по одному для каждого оператора в нашем Dockerfile. Давайте теперь опробуем поэтапную Docker-сборку. Используем тот же самый Dockerfile, состоящий из двух частей:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Первая часть Dockerfile создает три слоя. Затем слои объединяются и копируются на второй и заключительный этапы. Сверху в образ добавляются еще два слоя. В итоге имеем три слоя.

image

Давайте пробовать. Сначала создаем контейнер:

$ docker build -t node-multi-stage

Проверяем историю:

$ docker history node-multi-stage
IMAGE          CREATED BY                                      SIZE
331b81a245b1   /bin/sh -c #(nop)  CMD ["index.js"]             0B
bdfc932314af   /bin/sh -c #(nop)  EXPOSE 3000                  0B
f8992f6c62a6   /bin/sh -c #(nop) COPY dir:e2b57dff89be62f77…   1.62MB
b87c2ad8344d   /bin/sh -c #(nop)  CMD ["node"]                 0B
<missing>      /bin/sh -c set -ex   && for key in     6A010…   4.17MB
<missing>      /bin/sh -c #(nop)  ENV YARN_VERSION=1.3.2       0B
<missing>      /bin/sh -c ARCH= && dpkgArch="$(dpkg --print…   56.9MB
<missing>      /bin/sh -c #(nop)  ENV NODE_VERSION=8.9.4       0B
<missing>      /bin/sh -c set -ex   && for key in     94AE3…   129kB
<missing>      /bin/sh -c groupadd --gid 1000 node   && use…   335kB
<missing>      /bin/sh -c set -ex;  apt-get update;  apt-ge…   324MB
<missing>      /bin/sh -c apt-get update && apt-get install…   123MB
<missing>      /bin/sh -c set -ex;  if ! command -v gpg > /…   0B
<missing>      /bin/sh -c apt-get update && apt-get install…   44.6MB
<missing>      /bin/sh -c #(nop)  CMD ["bash"]                 0B
<missing>      /bin/sh -c #(nop) ADD file:1dd78a123212328bd…   123MB

Смотрим, изменился ли размер файла:

$ docker images | grep node-
node-multi-stage   331b81a245b1   678MB
node-vanilla       075d229d3f48   679MB

Да, он стал меньше, но пока не значительно.

2. Сносим все лишнее из контейнера с помощью distroless

Текущий образ предоставляет нам Node.js, yarn, npm, bash и много других полезных бинарников. Также, он создан на базе Ubuntu. Таким образом, развернув его, мы получаем полноценную операционную систему со множеством полезных бинарников и утилит.

При этом они не нужны нам для запуска контейнера. Единственная нужная зависимость — это Node.js.

Docker-контейнеры должны обеспечивать работу одного процесса и содержать минимально необходимый набор инструментов для его запуска. Целая операционная система для этого не требуется.

Таким образом, мы можем вынести из него все, кроме Node.js.

Но как?

В Google уже пришли к подобному решению — GoogleCloudPlatform/distroless.

Описание к репозиторию гласит:

Distroless-образы содержат только приложение и зависимости для его работы. Там нет менеджеров пакетов, shell'ов и других программ, которые обычно есть в стандартном дистрибутиве Linux.

Это то, что нужно!

Запускаем Dockerfile, чтобы получить новый образ:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM gcr.io/distroless/nodejs
COPY --from=build /app /
EXPOSE 3000
CMD ["index.js"]

Собираем образ как обычно:

$ docker build -t node-distroless

Приложение должно нормально заработать. Чтобы проверить, запускаем контейнер:

$ docker run -p 3000:3000 -ti --rm --init node-distroless

И идем на http://localhost:3000. Стал ли образ легче без лишних бинарников?

$ docker images | grep node-distroless
node-distroless   7b4db3b7f1e5   76.7MB

Еще как! Теперь он весит всего 76,7 МБ, на целых 600 Мб меньше!

Все круто, но есть один важный момент. Когда контейнер запущен, и надо его проверить, подключиться можно с помощью:

$ docker exec -ti <insert_docker_id> bash

Подключение к работающему контейнеру и запуск bash очень похоже на создание SSH-сессии.

Но поскольку distroless — это урезанная версия исходной операционной системы, там нет ни дополнительных бинарников, ни, собственно, shell!

Как подключиться к запущенному контейнеру, если нет shell?

Самое интересное, что никак.

Это не очень хорошо, так как исполнять в контейнере можно только бинарники. И единственный, который можно запустить — это Node.js:

$ docker exec -ti <insert_docker_id> node

На самом деле в этом есть и плюс, ведь если вдруг какой-то злоумышленник сможет получить доступ к контейнеру, он причинит намного меньше вреда, чем если бы у него был доступ к shell. Иными словами, меньше бинарников — меньше вес и выше безопасность. Но, правда, ценой более сложной отладки.

Тут надо бы оговориться, что подключать и отлаживать контейнеры на prod-окружении не стоит. Лучше положиться на правильно настроенные системы логирования и мониторинга.

Но что, если нам-таки нужен дебаггинг, и при этом мы хотим, чтобы docker-образ имел наименьший размер?

3. Уменьшаем базовые образы с помощью Alpine

Можно заменить distorless на Alpine-образ.

Alpine Linux — это ориентированный на безопасность, легкий дистрибутив на основе musl libc и busybox. Но не будем верить на слово, а лучше проверим.

Запускаем Dockerfile с использованием node:8-alpine:

FROM node:8 as build
WORKDIR /app
COPY package.json index.js ./
RUN npm install
FROM node:8-alpine
COPY --from=build /app /
EXPOSE 3000
CMD ["npm", "start"]

Создаем образ:

$ docker build -t node-alpine

Проверяем размер:

$ docker images | grep node-alpine
node-alpine   aa1f85f8e724   69.7MB

На выходе имеем 69.7MB — это даже меньше, чем distroless-образ.

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

Запускаем контейнер:

$ docker run -p 3000:3000 -ti --rm --init node-alpine
Example app listening on port 3000!

И подключаемся:

$ docker exec -ti 9d8e97e307d7 bash
OCI runtime exec failed: exec failed: container_linux.go:296: starting container process caused "exec: "bash": executable file not found in $PATH": unknown

Неудачно. Но, возможно, у контейнера есть sh'ell…:

$ docker exec -ti 9d8e97e307d7 sh / #

Отлично! У нас получилось подключиться к контейнеру, и при этом его образ имеет ещё и меньший размер. Но и тут не обошлось без нюансов.

Alpine-образы основаны на muslc — альтернативной стандартной библиотеке для C. В то время, как большинство Linux-дистрибутивов, таких как Ubuntu, Debian и CentOS, основаны на glibc. Считается, что обе эти библиотеки предоставляют одинаковый интерфейс для работы с ядром.

Однако у них разные цели: glibc является наиболее распространенной и быстрой, muslc же занимает меньше места и написана с уклоном в безопасность. Когда приложение компилируется, как правило, оно компилируется под какую-то определенную библиотеку C. Если потребуется использовать его с другой библиотекой, придется перекомпилировать.

Другими словами, сборка контейнеров на Alpine-образах может привести к неожиданному развитию событий, поскольку используемая в ней стандартная библиотека C отличается. Разница будет заметна при работе с прекомпилируемыми бинарниками, такими как расширения Node.js для C++.

Например, готовый пакет PhantomJS не работает на Alpine.

Так какой же базовый образ выбрать?

Alpine, distroless или ванильный образ — решать, конечно, лучше по ситуации.

Если имеем дело с prod-ом и важна безопасность, возможно, наиболее уместным будет distroless.

Каждый бинарник, добавленный к Docker-образу, добавляет определенный риск к стабильности всего приложения. Этот риск можно уменьшить, имея только один бинарник, установленный в контейнере.

Например, если злоумышленник смог найти уязвимость в приложении, запущенном на базе distroless-образа, он не сможет запустить в контейнере shell, потому что его там нет!

Если же по каким-то причинам размер docker-образа для вас крайне важен, определенно стоит присмотреться к образам на основе Alpine.

Они реально маленькие, но, правда, ценой совместимости. Alpine использует немного другую стандартную библиотеку C — muslc, так что иногда будут всплывать какие-то проблемы. С примерами можно ознакомиться по ссылкам: https://github.com/grpc/grpc/issues/8528 и https://github.com/grpc/grpc/issues/6126.

Ванильные же образы идеально подойдут для тестирования и разработки.

Да, они большие, но зато максимально похожи на полноценную машину с установленной Ubuntu. Кроме того, доступны все бинарники в ОС.

Подытожим размер полученных Docker-образов:

node:8 681MB
node:8 с пошаговой сборкой 678MB
gcr.io/distroless/nodejs 76.7MB
node:8-alpine 69.7MB

Напутствие от переводчика

Читайте другие статьи в нашем блоге:

Бэкапы Stateful в Kubernetes

Резервное копирование большого количества разнородных web-проектов

Telegram-бот для Redmine. Как упростить жизнь себе и людям

Автор: mikesidd

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js