Найкращі практики


Дотримуймося офіційних найкращих практик

Офіційні найкращі практики для Dockerfile містять багато чудового матеріалу про те, як покращити свої Dockerfile.

Швидкодія

Головно варто оптимізувати саме швидкодію (особливо для тест-раннерів). Це гарантує, що наш інструментарій працюватиме якомога швидше і не вичерпає ліміт часу.

Вимірюймо

Часте вимірювання часу виконання - чудовий спосіб відчути швидкодію інструментарію. Візьмімо за звичку вимірювати час виконання і після і перед зміною. Навіть коли здається, що зміна «точно» покращить швидкодію, час виконання все одно варто виміряти.

Скрипти

Коли можливо, створюймо скрипти для автоматичного вимірювання швидкодії (це ще називають бенчмаркінгом). Дуже корисний інструмент командного рядка - це hyperfine, але вільно використовуймо той, що найкраще пасує для нашого інструментарію.

Новіші репозиторії інструментарію треків матимуть доступ до таких двох скриптів:

  1. ./bin/benchmark.sh: вимірювання швидкодії коду інструментарію треку (джерельний код)
  2. ./bin/benchmark-in-docker.sh: вимірювання швидкодії образу Docker інструментарію треку (джерельний код)
Note

Якщо ми працюємо над репозиторієм інструментарію треку, у якому цих файлів немає, їх можна вільно скопіювати до себе в репозиторій за наведеними вище посиланнями на джерельний код.

Caution

Скрипти бенчмаркінгу допомагають оцінити швидкодію інструментарію. Але маймо на увазі, що швидкодія на продакшн-серверах Exercism часто нижча.

Експериментуймо з різними базовими образами

Спробуймо поекспериментувати з різними базовими образами (наприклад, Alpine замість Ubuntu), щоб побачити, чи один (суттєво) перевершує інший. Якщо швидкодія приблизно однакова, берімо найменший образ.

Спробуймо внутрішню мережу

Перевірмо, чи використання мережі internal замість none покращує швидкодію. Більше інформації в документації про мережі.

Віддаваймо перевагу командам часу збірки, а не часу виконання

Інструментарій треку запускає одноразовий, недовговічний контейнер Docker, який виконує такі кроки.

  1. Створюється контейнер Docker.
  2. Контейнер Docker запускається з правильними аргументами.
  3. Контейнер Docker знищується.

Тому код, який виконується на кроці 2, виконується під час кожного запуску інструментарію. Тож скорочення обсягу коду, який виконується на кроці 2, - чудовий спосіб покращити швидкодію. Один зі способів це зробити - перенести код із часу виконання до часу збірки. Тоді як код часу виконання виконується під час кожного запуску інструментарію, код часу збірки виконується лише один раз (коли збирається образ Docker).

Код часу збірки виконується один раз у складі робочого процесу GitHub Actions. Тож не страшно, якщо код, який виконується під час збірки, буде (відносно) повільним.

Приклад: попередня компіляція бібліотек

Коли тест-раннер для Haskell запускає тести, потрібно, щоб деякі базові бібліотеки були скомпільовані. Оскільки кожен запуск тестів відбувається в новому контейнері, це означає, що компіляція виконувалася під час кожного запуску тестів! Щоб обійти це, Dockerfile тест-раннера для Haskell містить такі дві команди:

COPY pre-compiled/ .
RUN stack build --resolver lts-20.18 --no-terminal --test --no-run-tests

Спочатку каталог pre-compiled копіюється в образ. Цей каталог налаштовано як тестову вправу, і він залежить від тих самих базових бібліотек, від яких залежить справжня вправа. Потім ми запускаємо тести для цього каталогу, що схоже на запуск тестів для справжньої вправи. Запуск тестів призведе до компіляції базових бібліотек, але різниця в тому, що це відбувається під час збірки. Отже, в отриманому образі Docker базові бібліотеки вже скомпільовані. Це означає, що компіляція не потрібна під час виконання, що дає (значно) швидше виконання.

Приклад: попередня компіляція бінарних файлів

Деякі мови дають змогу компілювати код заздалегідь або на льоту. Це компроміс між часом збірки та часом виконання, і знову ж, з міркувань швидкодії ми віддаємо перевагу виконанню під час збірки.

Dockerfile тест-раннера для C# використовує цей підхід: тест-раннер компілюється в бінарний файл заздалегідь (під час збірки), а не компілює код на льоту (під час виконання). Це означає, що під час виконання залишається менше роботи, що має допомогти підвищити швидкодію.

Розмір

Варто намагатися зменшити розмір образу, завдяки чому він:

  • Швидше розгортатиметься
  • Зменшить витрати для нас
  • Скоротить час запуску кожного контейнера

Спробуймо різні дистрибутиви

Різні образи дистрибутивів матимуть різний розмір. Наприклад, образ alpine:3.20.2 у десять разів менший за образ ubuntu:24.10:

REPOSITORY   TAG       SIZE
alpine       3.20.2    8.83MB
ubuntu       24.10     101MB

Загалом образи на основі Alpine - серед найменших, тож багато образів інструментарію базуються на Alpine.

Спробуймо полегшені образи

Деякі образи мають спеціальні «slim»-варіанти, у яких частину можливостей прибрано, що дає менший розмір образу. Наприклад, образ node:20.16.0-slim у пʼять разів менший за образ node:20.16.0:

REPOSITORY   TAG            SIZE
node         20.16.0        1.09GB
node         20.16.0-slim   219MB

«Slim»-варіанти менші, бо мають менше можливостей. Можливо, образу не потрібні додаткові можливості, і якщо так, розгляньмо «slim»-варіант.

Вилучення непотрібного

Очевидний, але чудовий спосіб зменшити розмір образу - вилучити все, що не потрібне. Це може бути, наприклад:

  • Файли з початковим кодом, які більше не потрібні після збирання з них бінарного файлу
  • Файли, призначені для інших архітектур, ніж образ Docker
  • Документація

Вилучімо файли менеджера пакетів

Більшості образів Docker потрібно встановлювати додаткові пакети, що зазвичай робиться через менеджер пакетів. Ці пакети потрібно встановлювати під час збірки (бо під час виконання немає доступу до інтернету). Тож будь-які кешувальні чи службові файли менеджера пакетів слід вилучати після встановлення додаткових пакетів.

apk

Дистрибутиви, які використовують менеджер пакетів apk (наприклад, Alpine), мають застосовувати прапорець --no-cache під час встановлення пакетів командою apk add:

RUN apk add --no-cache curl
apt-get/apt

Дистрибутиви, які використовують менеджер пакетів apt-get/apk (наприклад, Ubuntu), мають виконати команди apt-get autoremove -y та rm -rf /var/lib/apt/lists/* після встановлення пакетів і в тій самій команді RUN:

RUN apt-get update && \
    apt-get install curl -y && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*

Використовуймо багатоетапне збирання

У Docker є можливість під назвою багатоетапне збирання. Вони дають змогу розбити Dockerfile на окремі етапи, причому до отриманого образу Docker потрапляє лише останній етап (решта існує тільки для підтримки збирання останнього етапу). Кожен етап можна уявляти як власний міні-Dockerfile; етапи можуть використовувати різні базові образи.

Багатоетапне збирання особливо корисне, коли Dockerfile потребує встановлення пакетів, потрібних лише під час збірки. У цій ситуації загальна структура Dockerfile має такий вигляд:

  1. Визначмо новий етап (назвімо його етапом «build»). Цей етап використовуватиметься лише під час збірки.
  2. Встановімо потрібні додаткові пакети (в етап «build»).
  3. Виконаймо команди, яким потрібні додаткові пакети (в межах етапу «build»).
  4. Визначмо новий етап (назвімо його етапом «runtime»). Цей етап сформує отриманий образ Docker і виконуватиметься під час запуску.
  5. Скопіюймо результат(и) команд, виконаних на кроці 3 (в етапі «build»), до цього етапу (етапу «runtime»).

За такого налаштування додаткові пакети встановлюються лише в етапі «build», а не в етапі «runtime», тож вони не потраплять до образу Docker, який отримується.

Приклад: завантаження файлів

Тест-раннеру для Fortran потрібен curl, щоб завантажити деякі файли. Однак його образ часу виконання не потребує curl, що робить це чудовим прикладом для багатоетапного збирання.

Спочатку його Dockerfile визначає етап (з назвою «build»), у якому встановлюється пакет curl. Потім він за допомогою curl завантажує файли в цей етап.

FROM alpine:3.15 AS build

RUN apk add --no-cache curl

WORKDIR /opt/test-runner
COPY bust_cache .

WORKDIR /opt/test-runner/testlib
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/testlib/CMakeLists.txt
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/testlib/TesterMain.f90

WORKDIR /opt/test-runner
RUN curl -R -O https://raw.githubusercontent.com/exercism/fortran/main/config/CMakeLists.txt

Друга частина Dockerfile визначає новий етап і копіює завантажені файли з етапу «build» до власного етапу командою COPY:

FROM alpine:3.15

RUN apk add --no-cache coreutils jq gfortran libc-dev cmake make

WORKDIR /opt/test-runner
COPY --from=build /opt/test-runner/ .

COPY . .
ENTRYPOINT ["/opt/test-runner/bin/run.sh"]
Приклад: встановлення бібліотек

Тест-раннеру для Ruby потрібно, щоб перед встановленням його необхідних бібліотек (gem-ів) були встановлені пакети git, openssh, build-base, gcc і wget. Його Dockerfile починається з етапу (з назвою build), який встановлює ці пакети (через apk add), а потім встановлює залежності (через bundle install):

FROM ruby:3.2.2-alpine3.18 AS build

RUN apk update && apk upgrade && \
    apk add --no-cache git openssh build-base gcc wget git

COPY Gemfile Gemfile.lock .

RUN gem install bundler:2.4.18 && \
    bundle config set without 'development test' && \
    bundle install

Потім він визначає етап, який сформує отриманий образ Docker. Цей етап не встановлює залежності, які встановлював попередній етап: натомість він командою COPY копіює встановлені бібліотеки з етапу збірки до власного етапу:

FROM ruby:3.2.2-alpine3.18

RUN apk add --no-cache bash

WORKDIR /opt/test-runner

COPY --from=build /usr/local/bundle /usr/local/bundle

COPY . .

ENTRYPOINT [ "sh", "/opt/test-runner/bin/run.sh" ]
Note

Dockerfile тест-раннера для C# робить щось подібне, лише тут етап збірки може скористатися наявним образом Docker, у якому вже встановлено додаткові пакети, потрібні для встановлення бібліотек.

Тестування

Використовуймо інтеграційні тести

Модульні тести бувають дуже корисними, але радимо зосередитися на написанні інтеграційних тестів. Їхня головна перевага в тому, що вони краще перевіряють, як інструментарій працює в продакшені, і тим самим підвищують довіру до реалізації інструментарію.

Використовуймо Docker

Щоб якнайкраще відтворити продакшн-середовище, інтеграційні тести мають запускати інструментарій так само, як у продакшн-середовищі. Це означає зібрати образ Docker, а потім запустити зібраний образ на рішенні, щоб перевірити його вихідні дані.

Використовуймо golden-тести

Інтеграційні тести слід визначати як golden-тести, тобто тести, у яких очікувані вихідні дані зберігаються у файлі. Це ідеально підходить для інтеграційних тестів інструментарію треку, адже вихідні дані інструментарію - це теж файли.

Приклад: тест-раннер

Коли тест-раннер запускається на рішенні, на виході отримуємо файл results.json. Тоді ми можемо порівняти цей файл із «еталонним» (тобто «очікуваним») файлом вихідних даних (з назвою expected_results.json), щоб перевірити, чи тест-раннер працює як задумано.

Безпека

Безпека - головна причина, чому ми використовуємо контейнери Docker для запуску інструментарію.

Віддаваймо перевагу офіційним образам

На Docker Hub є багато образів Docker, але намагаймося використовувати офіційні. Ці образи ретельно відібрані, і ймовірність, що вони небезпечні, (набагато) менша.

Фіксуймо версії

Щоб збирання було стабільним (тобто не ламалося раптово), базові образи завжди варто фіксувати на конкретних тегах. Це означає, що замість:

FROM alpine:latest

варто використовувати:

FROM alpine:3.20.2

З останнім варіантом збирання завжди використовуватиме ту саму версію.

Запускаймо як непривілейований користувач

Типово багато образів запускаються від імені користувача з правами root. Варто розглянути запуск як непривілейований користувач.

FROM alpine

RUN groupadd -r myuser && useradd -r -g myuser myuser

# RUN <COMMANDS THAT REQUIRE ROOT USER, E.G. INSTALLING PACKAGES>

USER myuser

Оновлюймо репозиторії пакетів до найновішої версії

(Майже) завжди добре встановлювати найновіші версії

RUN apt-get update && \
    apt-get install curl

Підтримуймо файлову систему лише для читання

Радимо писати Dockerfile-и з розрахунку на файлову систему лише для читання. Єдині каталоги, які можна вважати доступними для запису:

  • Каталог рішення (передається другим аргументом)
  • Каталог вихідних даних (передається третім аргументом)
  • Каталог /tmp
Caution

Наше продакшн-середовище наразі не вимагає файлової системи лише для читання, але в майбутньому може. Тому базовий шаблон нового тест-раннера, аналізатора чи репрезентера починається з файлової системи лише для читання. Якщо не вдається змусити все працювати з файловою системою лише для читання, можна (поки що) вважати файлову систему доступною для запису.