[Перевод] Как сократить время сборки образов Docker в GitLab CI

f8b85fa47af23e53791b93aaa457d046

Делаем контейнерные CI среды по-настоящему практичными, ускорив сборку образов Docker.

Современный цикл разработки программного обеспечения зачастую подразумевает, что ваши приложения регулярно упаковываются в контейнеры. Эта задача может занимать много времени, чем может значительно замедлять ваше тестирование или развертывание. Проблема становится особенно очевидной в контексте процесса непрерывной интеграции и развертывания, когда образы пересобираются при каждом изменении в коде

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

Локальная упаковка приложения

В качестве примера мы возьмем достаточно простое приложение Python Flask:

from flask import Flask

app = Flask(__name__)

@app.route('/')
def hello_world():
    return 'Hello, World!'

Написание Dockerfile

Давайте напишем соответствующий Dockerfile:

FROM python:3.7-alpine as builder

# установка зависимостей, необходимых для сборки пакетов python
RUN apk update && apk add --no-cache make gcc && pip install --upgrade pip


# настройка venv и загрузка или сборка зависимостей
ENV VENV="/venv"
ENV PATH="${VENV}/bin:${PATH}"

COPY requirements.txt .
RUN python -m venv ${VENV} \
    && pip install --no-cache-dir -r requirements.txt

FROM python:3.7-alpine

# настройки venv с зависимостями с этапа компоновки
ENV VENV="/venv"
ENV PATH="${VENV}/bin:$PATH"
COPY --from=builder ${VENV} ${VENV}

# копирование файлов приложения
WORKDIR /app
COPY app .

# запуск приложения
EXPOSE 5000
ENV FLASK_APP="hello.py"
CMD [ "flask", "run", "--host=0.0.0.0" ]

Здесь вы можете наблюдать классический многоступенчатый процесс сборки:

  • Мы начинаем с легкого базового образа, в который мы устанавливаем инструменты сборки и загружаем или компилируем зависимости в виртуальную среду Python.

  • На втором этапе мы копируем виртуальную среду с нашими зависимостями в целевой образ и, наконец, добавляем файлы приложения.

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

Запуск и тестирование образа.

Убедитесь, что все работает должным образом:

docker build -t hello .
docker run -d --rm -p 5000:5000 hello
curl localhost:5000
Hello, World!

Если вы запустите команду docker build во второй раз:

docker build -t hello .
...
Step 2/15 : RUN apk update && apk add --no-cache make gcc && pip install --upgrade pip
 ---> Using cache
 ---> 24d044c28dce
...

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

Отправка образа

Давайте опубликуем наш образ во внешнем реестре и посмотрим, что произойдет:

docker tag hello my-registry/hello:1.0
docker push my-registry/hello:1.0

The push refers to repository [my-registry/hello]
8388d558f57d: Pushed 
77a59788172c: Pushed 
673c6888b7ef: Pushed 
fdb8581dab88: Pushed
6360407af3e7: Pushed
68aa0de28940: Pushed
f04cc38c0ac2: Pushed
ace0eda3e3be: Pushed
latest: digest: sha256:d815c1694083ffa8cc379f5a52ea69e435290c9d1ae629969e82d705b7f5ea95 size: 1994

Обратите внимание, как каждый из промежуточных слоев идентифицируется хэшем. Мы можем насчитать 8 слоев, потому что у нас есть ровно 8 docker команд в Dockerfile поверх нашей последней инструкции FROM.

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

С локальной сборкой все достаточно просто, давайте теперь посмотрим, как это будет работать в CI среде.

Сборка образа Docker в контексте CI конвейера 

В реальных условиях сборка и отправка образов Docker не всегда выполняется локально, как мы только что это делали. Обычно она выполняется внутри платформы непрерывной интеграции и развертывания. Перед развертыванием приложения вам нужно собирать и пушить свой образ при каждом изменении в коде. Естественно время сборки имеет решающее значение, так как вам нужен максимально быстрый цикл обратной связи.

Тестовая CI среда 

Мы реализуем CI среду, используя:

Последний пункт важен, потому что наши CI задачи будут выполняться в контейнерной среде. Учитывая это, каждая задача создается в виде пода Kubernetes. Каждое современное CI решение использует контейнерные задачи, и при создании Docker контейнеров все они сталкиваются с одной и той же проблемой: вам нужно заставить Docker команды работать внутри Docker контейнера.

Чтобы все прошло гладко, у вас есть два пути:

  • Забиндить /var/run/docker.sock, который слушает демон Docker, сделав демон хоста доступным для нашего контейнера задач.

  • Использовать дополнительный контейнер, запускающий «Docker in Docker» (также известный как dind) вместе с вашей задачей. Dind — это особый вариант Docker, работающий с привилегиями и настроенный для работы внутри самого Docker?

Для нашего примера мы будем использовать второй вариант.

Реализация GitLab конвейера 

В GitLab конвейере обычно вы создаете служебные контейнеры, такие как DinD, с помощью ключевого слова service.

В приведенном ниже фрагменте конвейера и задача docker-build, и служебный dind контейнер будут выполняться в одном и том же поде Kubernetes. Когда в сценарии задачи используется docker, он отправляет команды вспомогательному dind контейнеру благодаря переменной среды DOCKER_HOST.

stages:
  - build
  - test
  - deploy

variables:
  # отключаем проверку Docker TLS
  DOCKER_TLS_CERTDIR: ""
  # адрес localhost используется как контейнером задачи, так и dind контейнером (поскольку они используют один и тот же под)
  # Таким образом, при выполнении команд Docker эта конфигурация делает службу dind нашим демоном Docker 
  DOCKER_HOST: "tcp://localhost:2375"

services:
  - docker:stable-dind

docker-build:
  image: docker:stable
  stage: build
  script:
      - docker build -t hello .
      - docker tag my-registry/hello:${CI_COMMIT_SHORT_SHA}
      - docker push my-registry/hello:${CI_COMMIT_SHORT_SHA}

Запуск конвейера

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

docker build -t hello .

Step 1/15 : FROM python:3.7-alpine as builder
...
Step 2/15 : RUN apk update && apk add --no-cache make gcc && pip install --upgrade pip
---> Running in ca50f59a21f8
fetch http://dl-cdn.alpinelinux.org/alpine/v3.12/main/x86_64/APKINDEX.tar.gz
...

Поскольку мы впервые создаем наш контейнер, каждый слой создается путем выполнения команд. Общее время выполнения задачи составляет около 1 минуты.

Если вы запустите конвейер во второй раз, ничего не меняя, вы должны увидеть то же самое: каждый слой перестраивается! Когда мы запускали наши команды сборки локально, кэшированные слои использовались повторно. А здесь — нет. Для такого простого образа это на самом деле не имеет значения, но в реальной жизни, где для создания некоторых образов могут потребоваться десятки минут, это может стать настоящей проблемой.

Почему так происходит? Просто потому, что в этом случае dind — это временный контейнер, который создается вместе с задачей и умирает после ее завершения, поэтому все кэшированные данные теряются. К сожалению, вы не можете легко сохранить данные между двумя запусками конвейера.

Как же нам получить выгоду от кэширования и по-прежнему использовать Dind-контейнер?

Использование кэша Docker вместе с Docker in Docker

Первое решение: Pull/Push танцы

Первое решение довольно простое: мы будем использовать наш удаленный реестр (тот, в который мы пушим) в качестве удаленного кэша для наших слоев.

Если быть точнее:

  • Мы начинаем с извлечения (pull) самого актуального образа (т. е. последнего) из удаленного реестра, который будет использоваться в качестве кэша для последующей docker команды сборки.

  • Затем мы создаем образ, используя извлеченный образ в качестве кэша (аргумент --cache-from), если он доступен. Мы помечаем эту новую сборку в качестве последней и коммитим SHA.

  • Наконец, мы помещаем оба образа с тегами в удаленный реестр, чтобы их также можно было использовать в качестве кэша для последующих сборок.

stages:
  - build
  - test
  - deploy
    
variables:
   # отключаем проверку Docker TLS
  DOCKER_TLS_CERTDIR: ""
  DOCKER_HOST: "tcp://localhost:2375"

services:
  - docker:stable-dind

docker-build:
  image: docker:stable
  stage: build
  script:
    - docker pull my-registry/hello:latest || true
    - docker build --cache-from my-registry/hello:latest -t hello:latest .

    - docker tag hello:latest my-registry/hello:${CI_COMMIT_SHORT_SHA}
    - docker tag hello:latest my-registry/hello:latest

    - docker push my-registry/hello:${CI_COMMIT_SHORT_SHA}
    - docker push my-registry/hello:latest

Если вы запустите этот новый конвейер два раза, разница от использования кэша все равно будет неудовлетворительной.

Все слои из базового образа компоновщика пересобираются. Только первые 2 слоя (8 и 9) заключительного этапа используют кэш, но последующие слои перестраиваются.

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

Затем, когда наш финальный образ собран (шаги с 8 по 15), первые два слоя присутствуют в образе, который мы извлекли и использовали в качестве кэша. Но на шаге 10 мы получаем зависимости образа компоновщика, которые изменились, поэтому все последующие шаги также строятся заново.

Подводя итог, можно сказать, что использование кэша в значительной степени ограничено: только 2 шага из 15 используют кэш! Чтобы исправить это, нам нужно отправлять образ промежуточного компоновщика в удаленный реестр, чтобы сохранить его слои:

stages:
  - build
  - test
  - deploy
    
variables:
    # отключаем проверку Docker TLS
  DOCKER_TLS_CERTDIR: ""
  DOCKER_HOST: "tcp://localhost:2375"

services:
  - docker:stable-dind

docker-build:
  image: docker:stable
  stage: build
  script:
    - docker pull my-registry/hello-builder:latest || true
    - docker pull my-registry/hello:latest || true

    - docker build --cache-from my-registry/hello-builder:latest --target builder -t hello-builder:latest .
    - docker build --cache-from my-registry/hello:latest --cache-from my-registry/hello-builder:latest -t hello:latest .

    - docker tag hello-builder:latest my-registry/hello-builder:latest    
    - docker tag hello:latest my-registry/hello:${CI_COMMIT_SHORT_SHA}
    - docker tag hello:latest my-registry/hello:latest

    - docker push my-registry/hello-builder:latest
    - docker push my-registry/hello:${CI_COMMIT_SHORT_SHA}
    - docker push my-registry/hello:latest

Мы создаем промежуточный этап нашего сборщика в качестве образ докера, используя опцию target. После этого мы пушим его в удаленный реестр, в конечном итоге извлекая его в качестве кэша для создания нашего финального образа. При запуске конвейера наше время сократилось до 15 секунд!

Как видите, сборка постепенно усложняется. Если вы уже начинаете путаться, тогда представьте образ с 3 или 4 промежуточными стадиями! Но это метод работает. Другой недостаток заключается в том, что вам придется каждый раз загружать и выгружать все эти слои, что может быть довольно дорогостоящим с точки зрения затрат на хранение и передачу.

Второе решение: внешняя служба dind

Нам нужно запустить службу dind для выполнения нашей сборки docker. В нашем предыдущем решении dind был встроен в каждую задачу и разделял ее жизненный цикл, что делало невозможным создание надлежащего кэша.

Почему бы не сделать Dind гражданином первого класса, создав службу Dind в нашем кластере Kubernetes? Она будет работать с подключенным PersistentVolume для обработки кэшированных данных, и каждая задача может отправлять свои docker команды в эту общую службу.

Создать такую службу в Kubernetes достаточно просто:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    app: docker-dind
  name: dind
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 500Gi

---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: docker-dind
  name: dind
spec:
  replicas: 1
  selector:
    matchLabels:
      app: docker-dind
  template:
    metadata:
      labels:
        app: docker-dind
    spec:
      containers:
        - image: docker:19.03-dind
          name: docker-dind
          env:
            - name: DOCKER_HOST
              value: tcp://0.0.0.0:2375
            - name: DOCKER_TLS_CERTDIR
              value: ""
          volumeMounts:
            - name: dind-data
              mountPath: /var/lib/docker/
          ports:
            - name: daemon-port
              containerPort: 2375
              protocol: TCP
          securityContext:
            privileged: true # Требуется для работы dind контейнера.
      volumes:
        - name: dind-data
          persistentVolumeClaim:
            claimName: dind
            
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: docker-dind
  name: dind
spec:
  ports:
    - port: 2375
      protocol: TCP
      targetPort: 2375
  selector:
    app: docker-dind

Затем мы немного изменим наш исходный GitLab конвейер, чтобы он указывал на эту новую внешнюю службу, и удалим встроенные dind службы:

stages:
  - build
  - test
  - deploy
    
variables:
   # отключаем проверку Docker TLS
  DOCKER_TLS_CERTDIR: ""
   # здесь имя хоста dind разрешается как dind служба Kubernetes с помощью kube dns
  DOCKER_HOST: "tcp://dind:2375"

docker-build:
  image: docker:stable
  stage: build
  script:
    - docker build -t hello .
    - docker tag hello:latest my-registry/hello:{CI_COMMIT_SHORT_SHA}
    - docker push my-registry/hello:{CI_COMMIT_SHORT_SHA}

Если вы запустите конвейер дважды, второй раз сборка должна занять 10 секунд, что даже лучше, чем в нашем предыдущем решении. Для «большого» образа, для построения которого требуется около 10 минут, эта стратегия также сокращает время построения до нескольких секунд, если слои не изменялись.

И последний вариант: использование Kaniko

Последним вариантом может быть использование Kaniko. С его помощью вы можете создавать образы Docker без использования демона Docker, делая все, что мы сейчас делали, без особых проблем.

Однако обратите внимание, что при этом вы не можете использовать расширенные BuildKit опции, такие как, например, внедрение секретов при создании образа. По этой причине я сконцентрировался на другом решении.

Заключение

Поскольку при разработке программного обеспечения контейнеры широко используются практически повсюду, их эффективное создание является ключевым моментом в конвейере релиза. Как мы видели, проблема может стать довольно сложной, и каждое решение имеет свои компромиссы. Предлагаемые здесь решения проиллюстрированы с использованием GitLab, но стоит отметить, что они будут работать и в других контейнерных CI средах.

А прямо сейчас приглашаем ва ознакомиться с программой супер-интенсива «CI/CD или Непрерывная поставка с Docker и Kubernetes», а таже записаться на день открытых дверей.

© Habrahabr.ru