company_banner

Несколько советов о том, как ускорить сборку Docker-образов. Например, до 30 секунд

    Прежде чем фича попадет на прод, в наше время сложных оркестраторов и CI/CD предстоит пройти долгий путь от коммита до тестов и доставки. Раньше можно было кинуть новые файлы по FTP (так больше никто не делает, верно?), и процесс «деплоя» занимал секунды. Теперь же надо создать merge request и ждать немалое время, пока фича доберётся до пользователей.


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



    У нас неплохой опыт создания и поддержки сайтов СМИ: ТАСС, The Bell, "Новая газета", Republic… Не так давно мы пополнили портфолио, выпустив в прод сайт Reminder. И пока быстро допиливали новые фичи и чинили старые баги, медленный деплой стал большой проблемой.


    Деплой мы делаем на GitLab. Собираем образы, пушим в GitLab Registry и раскатываем на проде. В этом списке самое долгое — это сборка образов. Для примера: без оптимизации каждая сборка бэкенда занимала 14 минут.



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



    Для данной статьи, чтобы не привязываться к окружению Reminder'а, рассмотрим пример сборки пустого приложения на Angular. Итак, создаём наше приложение:


    ng n app

    Добавляем в него PWA (мы же прогрессивные):


    ng add @angular/pwa --project app

    Пока скачивается миллион npm-пакетов, давайте разберемся, как устроен docker-образ. Docker предоставляет возможность упаковывать приложения и запускать их в изолированном окружении, которое называется контейнер. Благодаря изоляции можно запускать одновременно много контейнеров на одном сервере. Контейнеры значительно легче виртуальных машин, поскольку выполняются напрямую на ядре системы. Чтобы запустить контейнер с нашим приложением, нам нужно сначала создать образ, в котором мы упакуем всё, что необходимо для работы нашего приложения. По сути образ — это слепок файловой системы. К примеру, возьмём Dockerfile:


    FROM node:12.16.2
    WORKDIR /app
    COPY . .
    RUN npm ci
    RUN npm run build --prod

    Dockerfile — это набор инструкций; выполняя каждую из них, Docker будет сохранять изменения в файловой системе и накладывать их на предыдущие. Каждая команда создаёт свой слой. А готовый образ — это объединённые вместе слои.


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


    1. Удаляем образы локально, чтобы предыдущие запуски не влияли на тест.
      docker rmi $(docker images -q)
    2. Запускаем билд первый раз.
      time docker build -t app .
    3. Меняем файл src/index.html — имитируем работу программиста.
    4. Запускаем билд второй раз.
      time docker build -t app .

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


    Положим Dockerfile, описанный выше, в папку с проектом и запустим сборку. Все приведённые листинги сокращены для удобства чтения.


    $ time docker build -t app .
    Sending build context to Docker daemon 409MB
    Step 1/5 : FROM node:12.16.2
    Status: Downloaded newer image for node:12.16.2
    Step 2/5 : WORKDIR /app
    Step 3/5 : COPY . .
    Step 4/5 : RUN npm ci
    added 1357 packages in 22.47s
    Step 5/5 : RUN npm run build --prod
    Date: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Time: 37581ms
    Successfully built c8c279335f46
    Successfully tagged app:latest
    
    real 5m4.541s
    user 0m0.000s
    sys 0m0.000s

    Меняем содержимое src/index.html и запускаем второй раз.


    $ time docker build -t app .
    Sending build context to Docker daemon 409MB
    Step 1/5 : FROM node:12.16.2
    Step 2/5 : WORKDIR /app
     ---> Using cache
    Step 3/5 : COPY . .
    Step 4/5 : RUN npm ci
    added 1357 packages in 22.47s
    Step 5/5 : RUN npm run build --prod
    Date: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Time: 37902ms
    Successfully built 79f335df92d3
    Successfully tagged app:latest
    
    real 3m33.262s
    user 0m0.000s
    sys 0m0.000s

    Чтобы посмотреть, получился ли у нас образ, выполним команду docker images:


    REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
    app          latest   79f335df92d3   About a minute ago   1.74GB

    Перед сборкой докер берет все файлы в текущем контексте и отправляет их своему демону Sending build context to Docker daemon 409MB. Контекст для сборки указывается последним аргументом команды build. В нашем случае это текущая директория — «.», — и докер тащит всё, что есть у нас в этой папке. 409 Мбайт — это много: давайте думать, как это исправить.


    Уменьшаем контекст


    Чтобы уменьшить контекст, есть два варианта. Либо положить все файлы, нужные для сборки, в отдельную папку и указывать контекст докеру именно на эту папку. Это может быть не всегда удобно, поэтому есть возможность указать исключения: что не надо тащить в контекст. Для этого положим в проект файл .dockerignore и укажем, что не нужно для сборки:


    .git
    /node_modules

    и запустим сборку ещё раз:


    $ time docker build -t app .
    Sending build context to Docker daemon 607.2kB
    Step 1/5 : FROM node:12.16.2
    Step 2/5 : WORKDIR /app
     ---> Using cache
    Step 3/5 : COPY . .
    Step 4/5 : RUN npm ci
    added 1357 packages in 22.47s
    Step 5/5 : RUN npm run build --prod
    Date: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Time: 37313ms
    Successfully built 4942f010792a
    Successfully tagged app:latest
    
    real 1m47.763s
    user 0m0.000s
    sys 0m0.000s

    607.2 Кбайт — намного лучше, чем 409 Мбайт. А ещё мы уменьшили размер образа с 1.74 до 1.38Гбайт:


    REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
    app          latest   4942f010792a   3 minutes ago   1.38GB

    Давайте попробуем ещё уменьшить размер образа.


    Используем Alpine


    Ещё один способ сэкономить на размере образа — использовать маленький родительский образ. Родительский образ — это образ, на основе которого готовится наш образ. Нижний слой указывается командой FROM в Dockerfile. В нашем случае мы используем образ на основе Ubuntu, в котором уже стоит nodejs. И весит он …


    $ docker images -a | grep node
    node 12.16.2 406aa3abbc6c 17 minutes ago 916MB

    … почти гигабайт. Изрядно сократить объем можно, используя образ на основе Alpine Linux. Alpine — это очень маленький линукс. Докер-образ для nodejs на основе alpine весит всего 88.5 Мбайт. Поэтому давайте заменим наш жиииирный вдомах образ:


    FROM node:12.16.2-alpine3.11
    RUN apk --no-cache --update --virtual build-dependencies add \
        python \
        make \
        g++
    WORKDIR /app
    COPY . .
    RUN npm ci
    RUN npm run build --prod

    Нам пришлось установить некоторые штуки, которые необходимы для сборки приложения. Да, Angular не собирается без питона ¯(°_o)/¯


    Но зато размер образа сбросил 619 Мбайт:


    REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
    app          latest   aa031edc315a   22 minutes ago   761MB

    Идём ещё дальше.


    Мультистейдж сборка


    Не всё, что есть в образе, нужно нам в продакшене.


    $ docker run app ls -lah
    total 576K
    drwxr-xr-x 1 root root 4.0K Apr 16 19:54 .
    drwxr-xr-x 1 root root 4.0K Apr 16 20:00 ..
    -rwxr-xr-x 1 root root 19 Apr 17 2020 .dockerignore
    -rwxr-xr-x 1 root root 246 Apr 17 2020 .editorconfig
    -rwxr-xr-x 1 root root 631 Apr 17 2020 .gitignore
    -rwxr-xr-x 1 root root 181 Apr 17 2020 Dockerfile
    -rwxr-xr-x 1 root root 1020 Apr 17 2020 README.md
    -rwxr-xr-x 1 root root 3.6K Apr 17 2020 angular.json
    -rwxr-xr-x 1 root root 429 Apr 17 2020 browserslist
    drwxr-xr-x 3 root root 4.0K Apr 16 19:54 dist
    drwxr-xr-x 3 root root 4.0K Apr 17 2020 e2e
    -rwxr-xr-x 1 root root 1015 Apr 17 2020 karma.conf.js
    -rwxr-xr-x 1 root root 620 Apr 17 2020 ngsw-config.json
    drwxr-xr-x 1 root root 4.0K Apr 16 19:54 node_modules
    -rwxr-xr-x 1 root root 494.9K Apr 17 2020 package-lock.json
    -rwxr-xr-x 1 root root 1.3K Apr 17 2020 package.json
    drwxr-xr-x 5 root root 4.0K Apr 17 2020 src
    -rwxr-xr-x 1 root root 210 Apr 17 2020 tsconfig.app.json
    -rwxr-xr-x 1 root root 489 Apr 17 2020 tsconfig.json
    -rwxr-xr-x 1 root root 270 Apr 17 2020 tsconfig.spec.json
    -rwxr-xr-x 1 root root 1.9K Apr 17 2020 tslint.json

    С помощью docker run app ls -lah мы запустили контейнер на основе нашего образа app и выполнили в нем команду ls -lah, после чего контейнер завершил свою работу.


    На проде нам нужна только папка dist. При этом файлы как-то нужно отдавать наружу. Можно запустить какой-нибудь HTTP-сервер на nodejs. Но мы сделаем проще. Угадайте русское слово, в котором четыре буквы «ы». Правильно! Ынжыныксы. Возьмём образ с nginx, положим в него папку dist и небольшой конфиг:


    server {
        listen 80 default_server;
        server_name localhost;
        charset utf-8;
        root /app/dist;
    
        location / {
            try_files $uri $uri/ /index.html;
        }
    }

    Это всё провернуть нам поможет multi-stage build. Изменим наш Dockerfile:


    FROM node:12.16.2-alpine3.11 as builder
    RUN apk --no-cache --update --virtual build-dependencies add \
        python \
        make \
        g++
    WORKDIR /app
    COPY . .
    RUN npm ci
    RUN npm run build --prod
    
    FROM nginx:1.17.10-alpine
    RUN rm /etc/nginx/conf.d/default.conf
    COPY nginx/static.conf /etc/nginx/conf.d
    COPY --from=builder /app/dist/app .

    Теперь у нас две инструкции FROM в Dockerfile, каждая из них запускает свой этап сборки. Первый мы назвали builder, а вот начиная с последнего FROM будет готовиться наш итоговый образ. Последним шагом копируем артефакт нашей сборки в предыдущем этапе в итоговый образ с nginx. Размер образа существенно уменьшился:


    REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
    app          latest   2c6c5da07802   29 minutes ago   36MB

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


    docker run -p8080:80 app

    Опцией -p8080:80 мы пробросили порт 8080 на нашей хостовой машине до порта 80 внутри контейнера, где крутится nginx. Открываем в браузере http://localhost:8080/ и видим наше приложение. Всё работает!



    Уменьшение размера образа с 1.74 Гбайт до 36 Мбайт значительно сокращает время доставки вашего приложения в прод. Но давайте вернёмся ко времени сборки.


    $ time docker build -t app .
    Sending build context to Docker daemon 608.8kB
    Step 1/11 : FROM node:12.16.2-alpine3.11 as builder
    Step 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
     ---> Using cache
    Step 3/11 : WORKDIR /app
     ---> Using cache
    Step 4/11 : COPY . .
    Step 5/11 : RUN npm ci
    added 1357 packages in 47.338s
    Step 6/11 : RUN npm run build --prod
    Date: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Time: 39948ms
     ---> 27f1479221e4
    Step 7/11 : FROM nginx:stable-alpine
    Step 8/11 : WORKDIR /app
     ---> Using cache
    Step 9/11 : RUN rm /etc/nginx/conf.d/default.conf
     ---> Using cache
    Step 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
     ---> Using cache
    Step 11/11 : COPY --from=builder /app/dist/app .
    Successfully built d201471c91ad
    Successfully tagged app:latest
    
    real 2m17.700s
    user 0m0.000s
    sys 0m0.000s

    Меняем порядок слоёв


    Первые три шага у нас были закэшированы (подсказка Using cache). На четвёртом шаге копируются все файлы проекта и на пятом шаге ставятся зависимости RUN npm ci — целых 47.338s. Зачем каждый раз заново ставить зависимости, если они меняются очень редко? Давайте разберемся, почему они не закэшировались. Дело в том, что докер проверят слой за слоем, не поменялась ли команда и файлы, связанные с ней. На четвёртом шаге мы копируем все файлы нашего проекта, и среди них, конечно же, есть изменения, поэтому докер не только не берет из кэша этот слой, но и все последующие! Давайте внесём небольшие изменения в Dockerfile.


    FROM node:12.16.2-alpine3.11 as builder
    RUN apk --no-cache --update --virtual build-dependencies add \
        python \
        make \
        g++
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci
    COPY . .
    RUN npm run build --prod
    
    FROM nginx:1.17.10-alpine
    RUN rm /etc/nginx/conf.d/default.conf
    COPY nginx/static.conf /etc/nginx/conf.d
    COPY --from=builder /app/dist/app .

    Сначала копируются package.json и package-lock.json, затем ставятся зависимости, а только после этого копируется весь проект. В результате:


    $ time docker build -t app .
    Sending build context to Docker daemon 608.8kB
    Step 1/12 : FROM node:12.16.2-alpine3.11 as builder
    Step 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
     ---> Using cache
    Step 3/12 : WORKDIR /app
     ---> Using cache
    Step 4/12 : COPY package*.json ./
     ---> Using cache
    Step 5/12 : RUN npm ci
     ---> Using cache
    Step 6/12 : COPY . .
    Step 7/12 : RUN npm run build --prod
    Date: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Time: 38287ms
     ---> 1b9448c73558
    Step 8/12 : FROM nginx:stable-alpine
    Step 9/12 : WORKDIR /app
     ---> Using cache
    Step 10/12 : RUN rm /etc/nginx/conf.d/default.conf
     ---> Using cache
    Step 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
     ---> Using cache
    Step 12/12 : COPY --from=builder /app/dist/app .
    Successfully built a44dd7c217c3
    Successfully tagged app:latest
    
    real 0m46.497s
    user 0m0.000s
    sys 0m0.000s

    46 секунд вместо 3 минут — значительно лучше! Важен правильный порядок слоёв: сначала копируем то, что не меняется, затем то, что редко меняется, а в конце — то, что часто.


    Далее немного слов о сборке образов в CI/CD системах.


    Использование предыдущих образов для кэша


    Если мы используем для сборки какое-то SaaS-решение, то локальный кэш докера может оказаться чист и свеж. Чтобы докеру было откуда взять испеченные слои, дайте ему предыдущий собранный образ.


    Рассмотрим для примера сборку нашего приложения в GitHub Actions. Используем такой конфиг


    on:
      push:
        branches:
          - master
    
    name: Test docker build
    
    jobs:
      deploy:
        name: Build
        runs-on: ubuntu-latest
        env:
          IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
          IMAGE_TAG: ${{ github.sha }}
    
        steps:
        - name: Checkout
          uses: actions/checkout@v2
    
        - name: Login to GitHub Packages
          env:
            TOKEN: ${{ secrets.GITHUB_TOKEN }}
          run: |
            docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN
    
        - name: Build
          run: |
            docker build \
              -t $IMAGE_NAME:$IMAGE_TAG \
              -t $IMAGE_NAME:latest \
              .
    
        - name: Push image to GitHub Packages
          run: |
            docker push $IMAGE_NAME:latest
            docker push $IMAGE_NAME:$IMAGE_TAG
    
        - name: Logout
          run: |
            docker logout docker.pkg.github.com

    Образ собирается и пушится в GitHub Packages за две минуты и 20 секунд:



    Теперь изменим сборку так, чтобы использовался кэш на основе предыдущих собранных образов:


    on:
      push:
        branches:
          - master
    
    name: Test docker build
    
    jobs:
      deploy:
        name: Build
        runs-on: ubuntu-latest
        env:
          IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
          IMAGE_TAG: ${{ github.sha }}
    
        steps:
        - name: Checkout
          uses: actions/checkout@v2
    
        - name: Login to GitHub Packages
          env:
            TOKEN: ${{ secrets.GITHUB_TOKEN }}
          run: |
            docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN
    
        - name: Pull latest images
          run: |
            docker pull $IMAGE_NAME:latest || true
            docker pull $IMAGE_NAME-builder-stage:latest || true
    
        - name: Images list
          run: |
            docker images
    
        - name: Build
          run: |
            docker build \
              --target builder \
              --cache-from $IMAGE_NAME-builder-stage:latest \
              -t $IMAGE_NAME-builder-stage \
              .
            docker build \
              --cache-from $IMAGE_NAME-builder-stage:latest \
              --cache-from $IMAGE_NAME:latest \
              -t $IMAGE_NAME:$IMAGE_TAG \
              -t $IMAGE_NAME:latest \
              .
    
        - name: Push image to GitHub Packages
          run: |
            docker push $IMAGE_NAME-builder-stage:latest
            docker push $IMAGE_NAME:latest
            docker push $IMAGE_NAME:$IMAGE_TAG
    
        - name: Logout
          run: |
            docker logout docker.pkg.github.com

    Для начала нужно рассказать, почему запускается две команды build. Дело в том, что в мультистейдж-сборке результирующим образом будет набор слоёв из последнего стейджа. При этом слои из предыдущих стейджей не попадут в образ. Поэтому при использовании финального образа с предыдущей сборки Docker не сможет найти готовые слои для сборки образа c nodejs (стейдж builder). Для того чтобы решить эту проблему, создаётся промежуточный образ $IMAGE_NAME-builder-stage и отправляется в GitHub Packages, чтобы его можно было использовать в последующей сборке как источник кэша.



    Общее время сборки сократилось до полутора минут. Полминуты тратится на подтягивание предыдущих образов.


    Предварительное создание образов


    Ещё один способ решить проблему чистого кэша докера — часть слоёв вынести в другой Dockerfile, собрать его отдельно, запушить в Container Registry и использовать как родительский.


    Создаём свой образ nodejs для сборки Angular-приложения. Создаём в проекте Dockerfile.node


    FROM node:12.16.2-alpine3.11
    RUN apk --no-cache --update --virtual build-dependencies add \
        python \
        make \
        g++

    Собираем и пушим публичный образ в Docker Hub:


    docker build -t exsmund/node-for-angular -f Dockerfile.node .
    docker push exsmund/node-for-angular:latest

    Теперь в нашем основном Dockerfile используем готовый образ:


    FROM exsmund/node-for-angular:latest as builder
    ...

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



    Мы рассмотрели несколько методов ускорения сборки докер-образов. Если хочется, чтобы деплой проходил быстро, попробуйте применить в своём проекте:


    • уменьшение контекста;
    • использование небольших родительских образов;
    • мультистейдж-сборку;
    • изменение порядка инструкций в Dockerfile, чтобы эффективно использовать кэш;
    • настройку кэша в CI/CD-системах;
    • предварительное создание образов.

    Надеюсь, на примере станет понятнее, как работает Docker, и вы сможете оптимально настроить ваш деплой. Для того, чтобы поиграться с примерами из статьи, создан репозиторий https://github.com/devopsprodigy/test-docker-build.

    ITSumma
    Собираем безумных людей и вместе спасаем интернет

    Комментарии 33

      0
      > Пока скачивается миллион npm-пакетов

      А смотрели возможность использовать yarn для установки пакетов? Когда столкнулся с тем что npm i занимает значительную часть pipeline, это позволило заметно оптимизировать процесс по времени.
        +4
        Вместо `npm install` следует использовать `npm ci` в связке с `package-lock.json`. Отрабатывает гораздо быстрее из-за пропуска этапа анализа зависимостей.
          +1
          В данном примере я заменил npm на yarn, и время первой сборки уменьшилось до 2m4.134s, что незначительно. Исследования работы с именно нодой не вошли в данную статью, здесь больше речь про работу докера, но тема очень интересная.
            0
            yarn не панацея, в разное время он то быстрее, то нет. Если у вас много nodejs — мониторьте изменения в npm и yarn, тестируйте на своих проектах регулярно.
            +2
            Спасибо за полезный пост. Хорошая выжимка best practices с разъяснениями и иллюстрациями
              +3

              Хорошая статья! Делал такое для golang, но столкнулся с тем, что при активной разработке go.mod меняется всё равно не редко (особенно если сервисов много). И тут на помощь пришел BuldKit.
              С ним становится ещё проще, поскольку можно указать напрямую какие директории можно шарить между разными запусками даже если поменялось что-то из слоёв. Работает как если бы шареная директория была отдельным volume который подключается на этапе сборки, можно указывать политики разделения этой директории.

                0

                А можно чуть подробнее описать что и как делалось, и какой выигрыш это дало?


                Просто по моим наблюдениям с Go всё и так летает, достаточно кешировать на CI каталоги с родными кешами Go, примерно так (для CircleCI):


                - save_cache:
                    when: always
                    key: v4-{{ checksum "/tmp/tools.ver" }}-{{ checksum "go.mod" }}-{{ .Branch }}
                    paths:
                        - ~/go/bin/
                        - ~/go/pkg/
                        - ~/go/src/
                        - ~/.cache/go-build/
                        - ~/.cache/golangci-lint/

                И при наличии этих кешей сборка и тесты Go реально летают, даже на немаленьком проекте.

                  0

                  Да, примерно это и делалось — маунтились кэши в контейнеры.
                  Из плюсов BuildKit’а — можно явно указать разрешено ли совместное использование и изменение, или нет. Я не рискнул просто маунтить директорию поскольку не уверен в безопасности параллельных сборок в таком случае. С BuildKit можно запускать параллельно, просто результат докачки одного из запусков пропадёт.

                +3

                Не до конца разобрались.


                1. Начните использовать build args. Я изучал это по примеру образа docker-compose. За один этап build всё собирается
                2. Виртуальные зависимости в альпайне нужны для временной установки. Зайдите в образ ноды и посмотрите как там виртуальные зависимости используются.
                3. Для того же npm можно использовать кэш. Указать отдельную папку с кэшем и залить в артефакты. И не забываем удалить, чтоб уменьшить слой.
                4. Оставлять дев зависимости в прод образе не стоит.
                  0
                  Спасибо за уточнение!

                  1.-2. А можно, пожалуйста, ткнуть ссылочками?

                  3. С учетом п.4 у меня будет папка `node_modules_production` (так ее назовем для понимания). Не совсем понял, что с ней надо сделать. Почему ее просто не оставить в образе (и даже не `COPY` ее, как автор предлагает), она ведь нужна для runtime? И что именно «удалить, чтоб уменьшить слой»?

                  4. Это понимаю.
                    0
                    Ниже объяснили, что `node_modules_production` не будет копироваться при `COPY`. У нас она в образе не будет и не нужна — всегда чистая `npm ci`.

                    Остальное актуально.
                    0
                    По пункту №3 — лучше локальный прокси репозитория, Nexus или аналогичный, а не папка с кэшем. Качать с локального же сервера не сильно дольше, зато намного больше гибкость + приватный репозиторий автоматом появляется.
                    0

                    А производительность не просела в результате перехода на alpine?

                      0

                      только сегодня разработчики жаловались, что на сборку проекта уходит полтора часа… полтора часа Карл…
                      если кто-то Квартус ускорит в сто раз, нужно будет отлить ему памятник в золоте…
                      сорри… о наболевшем(

                        0

                        Можно ещё вынести процесс сборки Angular-приложения за пределы докера (в команды непосредственно для CI-системы) и кешировать его с помощью механизмов CI-системы; докеру останется только закинуть артефакт в образ Nginx.


                        Можно не создавать новый образ на основе node:12.16.2-alpine3.11, а использовать его вместе с docker run, чтобы собрать Angular-приложение напрямую в CI-системе. Но обычно CI-системы запускаются в докер-контейнере, и у разработчиков есть возможность выбрать образ контейнера, поэтому нет необходимости запускать отдельно образ с Node.js.


                        Эти решения выходят за рамки просто докерфайла и реализация может быть завязана на конкретную CI-систему.


                        P.S. Спасибо за описание поэтапной эволюции решения. Мне как новичку в докере это сильно помогает.

                          +2
                          Меняем файл src/index.html — имитируем работу программиста.

                          Постоянно это делаю)
                            0
                            При добавлении образа с базой, и др необходимых для прода на сколько увеличится сложность сборки?
                              0
                              Спасибо за статью!

                              Для этого положим в проект файл `.dockerignore` и укажем, что не нужно для сборки: `/node_modules`.

                              Вот тут я не совсем понял. В сборке есть `npm ci`, `npm run`, неужели им не нужен `/node_modules`? Или он нужен, но мы его копируем с помощью `COPY. .`?
                                +1
                                Кажется, я понял. Я не знал, что такое `npm ci`. Полагал, это «Continuous Integration», а не «Clean Install». И `/node_modules` нам был не нужен для сборки на первых шагах статьи потому, что, во-первых, это первые шаги, а, во-вторых, install у нас «clean». Каюсь, надо было сначала прочитать еще раз и подумать.

                                Но, пока что, остается такой вопрос: почему `/node_modules` надо `COPY`, а не просто оставлять в кэше? Мол, такое копирование быстрее?
                              +1

                              Привет! Спасибо за статью! Для новичков она будет полезна. Но стоит и рассказать о подводных камнях. Попробую систематизировать


                              1. alpine — уже обсуждали тут на хабре риски связанные с этим.
                                https://habr.com/ru/post/486202/
                                https://habr.com/ru/post/415513/


                              2. docker build --from-cache регулярно ломается, да и кэширование слоев докера не очень надежно — я слышал, что бывают конфликты хэшей.


                              3. в вашем пайлплайне, если изменился базовый образ, то эти изменения не будут замечены. Это актуально для процесса разработки, когда ты наследуешься от какого-нибудь неуточненного тега типа python:3 В проде же наоборот лучше стабильность и это ок.


                              4. не рассмотрен вопрос, когда COPY стопицот файлов на overlay2 работает медленно.


                              5. докер проверяет изменился ли результат сравнением команды. Т.е. строка в докерфайле apk --no-cache --update --virtual build-dependencies add python make g++ не изменяется и докер не увидит изменений, если в репозитории ОС появились новые пакеты, и об этом нужно думать отдельно.



                              без оптимизации каждая сборка бэкенда занимала 14 минут.

                              соглашусь, что ДЛЯ РАЗРАБОТКИ это непозволительно долго. Очень долго ждешь изменений. Для выката на продакшен — это ОТЛИЧНО. 14 минут? Да ну — там пока кубернетес прочухается, пока образы зальются… Ну, вы поняли )))

                                0
                                3. Не понял, почему. Это про случай, когда Python задеплоит 3.8.3 вместо 3.8.2? Но почему «эти изменения не будут замечены», разве образ `python:3` не изменится (его хэш и, следовательно, он сам), Docker это не проверит?

                                4. Спросил у автора, спрошу и у Вас, на всякий случай :) А какой вообще резон включать `node_modules` в `.dockerignore`, а потом — его `COPY`?

                                Спасибо.
                                  0
                                  1. Не понял, почему. Это про случай, когда Python задеплоит 3.8.3 вместо 3.8.2? Но почему «эти изменения не будут замечены», разве образ python:3 не изменится (его хэш и, следовательно, он сам), Docker это не проверит?

                                  Объясню как это работает.


                                  1. Кэш докера пустой. Он качает с регистри python:3 последней версии. Сборка идет с 3.8.3.
                                  2. В кэше докера есть python:3 (с 3.8.2), в репозитории уже он изменен (3.8.3). Докер не ИЩЕТ в регистри новую версию. Собирает с python:3 (с питоном 3.8.2).
                                  3. Собираем с ключом

                                        --pull                    Always attempt to pull a newer version of the image

                                  Тогда идет проверка новой версии. И скачивается она.


                                  Очень очевидное и понятное поведение, ога.


                                  1. Спросил у автора, спрошу и у Вас, на всякий случай :) А какой вообще резон включать node_modules в .dockerignore, а потом — его COPY?

                                  Не знаю такого смысла — потому что если что-то включено в .dockerignore, его потом в COPY скопировать уже нельзя )))

                                    +1
                                    3. А, для этого отдельный ключ, `--pull`, спасибо.

                                    4. То есть `COPY. .` вовсе не копирует `node_modules`? (Не ругайтесь, пожалуйста, — я новичок. Да и замысел автора… Там и если копирует — мне логика не до конца ясна, и если нет.) `.dockerignore` влияет не только на набор файлов, с которых считается хэш-сумма, но и на `COPY`?
                                      0
                                      1. То есть COPY. . вовсе не копирует node_modules? (Не ругайтесь, пожалуйста, — я новичок. Да и замысел автора… Там и если копирует — мне логика не до конца ясна, и если нет.)

                                      Это не так работает. Осознайте концепцию контекста докера. Как это работает? Докер берет каталог из команды docker build <directory>, исключает из него файлы при помощи .dockerignore и кладет во временный каталог. Далее из временного каталога идет сборка. Соответственно, COPY выбирает файлы из этого временного каталога. COPY . . копирует, получается, все файлы из временного каталога.

                                  0
                                  docker build --from-cache регулярно ломается

                                  А зачем вообще нужен --from-cache? Ведь если мы сделали docker pull и образы с кешами уже доступны локально, то докер же сам их найдёт… в чём смысл явно ему указывать в каком образе их искать?

                                    0
                                    Ведь если мы сделали docker pull и образы с кешами уже доступны локально, то докер же сам их найдёт…

                                    потому что это так не работает. Без --from-cache у вас слои не подтягиваются из старой версии образа.

                                      0

                                      В смысле? Он же при сборке пишет, что берёт их из кеша. Вот, прямо в статье пример:


                                      $ time docker build -t app .
                                      Sending build context to Docker daemon 409MB
                                      Step 1/5 : FROM node:12.16.2
                                      Step 2/5 : WORKDIR /app
                                       ---> Using cache
                                        0

                                        Это зависит от фаз луны. Допускаю, что это работает в случае, если


                                        а) изначальный базовый образ остался тот же самый и не изменялся в рамках того же тега
                                        б) окончательный образ имеет тот же самый тег, что и собираемый


                                        Короче — с --from-cache вероятность кэш-промаха СУЩЕСТВЕННО ниже

                                          0

                                          Судя по официальной документации, дела обстоят несколько иначе. Никакой "вероятности промаха" не существует — по крайней мере я никогда ничего подобного не замечал, если образ есть локально, то он будет использован в качестве кеша всегда автоматически. А --cache-from нужен для того, чтобы не делать docker pull в надежде, что скачанный образ пригодится, вместо этого мы указываем аргументом для --cache-from имя образа в удалённом репозитории, и он на ходу сам выясняет, содержит ли этот образ нужные для кеша слои и скачивает их по необходимости. https://docs.docker.com/engine/reference/commandline/build/#specifying-external-cache-sources


                                          P.S. Помимо экономии на возможно бесполезном docker pull перед сборкой --cache-from должен дать дополнительную экономию на том, что скачает не весь образ с кешем, а только те его слои, которые реально нужны для кеша.

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

                                            Ой, как я боюсь этой уличной магии…
                                            Допускаю, что Вы и правы, но я наблюдал странные граничные случаи. Поэтому мне проще сразу docker pull старый образ и не ломать голову )

                                  +1

                                  Следующий этап: начать пользоваться фичами гитлаба и задолбаться с докерфайлом.
                                  Кэш докера — неуправляемое чудовище. Лучше кэшировать зависимости гитлабом. Делается в две строчки, зато есть контроль.
                                  А еще гитлаб позволяет удобно смотреть на этапы сборки. И видеть что упал job с тестами, или job с линтером, или… Но если вы все пихаете в докерфайл, вы отказываетесь от этих фич. Решение — gitkab docker runner. Указываем ему, в каком образе запускать команды, и забываем про докер до самого конца, когда надо именно образ собирать.

                                    0

                                    Пацанам пофиг — у них все собирается в докерах и в докерах же уезжает в продакшен. Никаких других способов доставки приложения у них нет. Если это какой-нибудь веб, то это ок. Но для "коробочного" софта это не годится — там придется и deb, и rpm билдить и под разные операционные системы и все такое. И тогда я полностью согласен — начинает выгоднее быть пользование кэшем гитлаба, а не все эти сумасшедшие мультистадийные сборки… А докер… А докер в этом случае хорошо подходит для задач "заморозки" окружения для билда и для тестов.

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

                                  Самое читаемое