Пошук уроків, статей та іншого контенту
Зменшите розмір образів за допомогою multi-stage builds, кешування шарів і запуску процесів без root.
Менший production-образ має кілька переваг:
швидше завантажується на сервер або в Kubernetes;
займає менше місця в registry та на диску;
містить менше потенційно вразливих компонентів;
швидше запускається;
зменшує обсяг даних, який потрібно передати під час деплою.
Розмір образу визначається не лише базовим образом. На нього також впливають:
файли, скопійовані в контекст збірки;
кількість і порядок шарів;
інструменти компіляції;
кеші пакетних менеджерів;
зайві залежності та артефакти збірки;
запуск процесу від імені root.
Оптимізація має зберігати відтворюваність збірки. Не слід просто видаляти файли або вимикати перевірки заради кількох мегабайт.
Multi-stage build дає змогу використовувати різні образи для збірки та запуску застосунку.
У першому етапі можна мати:
компілятор;
менеджер залежностей;
заголовкові файли;
інструменти тестування;
У фінальний етап копіюються лише файли, потрібні для запуску.
Замість одного великого образу:
FROM golang:1.24
WORKDIR /app
COPY . .
RUN go build -o server ./cmd/server
CMD ["./server"]можна використати окремий етап збірки та мінімальний runtime-образ:
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w" \
-o /out/server \
./cmd/server
FROM alpine:3.22
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/server /usr/local/bin/server
USER app
ENTRYPOINT ["/usr/local/bin/server"]Назва build після AS — це ім’я етапу. Інструкція COPY --from=build копіює файл саме з цього етапу, а не з локальної файлової системи.
golang:1.24-alpine містить компілятор, але не потрапляє у фінальний образ.
CGO_ENABLED=0 створює статично скомпільований бінарний файл.
-trimpath прибирає локальні шляхи з бінарного файлу.
-ldflags="-s -w" видаляє частину символів налагодження.
У фінальний образ копіюється лише виконуваний файл.
Користувач app не має привілеїв root.
Структура проєкту:
.
├── .dockerignore
├── Dockerfile
├── go.mod
└── cmd
└── server
└── main.goФайл go.mod:
module example.com/optimized-server
go 1.24Файл cmd/server/main.go:
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from a non-root container")
})
addr := ":" + port
log.Printf("server is listening on %s", addr)
if err := http.ListenAndServe(addr, nil); err != nil {
log.Fatal(err)
}
}Файл Dockerfile:
# syntax=docker/dockerfile:1
FROM golang:1.24-alpine AS build
WORKDIR /src
# Окремо копіюємо файли залежностей для повторного використання кешу
COPY go.mod go.sum ./
# Кеш зберігається між збірками, але не потрапляє у фінальний образ
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build \
-trimpath \
-ldflags="-s -w" \
-o /out/server \
./cmd/server
FROM alpine:3.22
# Створюємо окрему групу та користувача без root-привілеїв
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/server /usr/local/bin/server
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]Файл .dockerignore:
.git
.gitignore
Dockerfile
README.md
tmp
bin
coverage
*.logЗбірка та запуск:
docker build -t optimized-server .
docker run --rm -p 8080:8080 optimized-serverПеревірка:
curl http://localhost:8080Очікувана відповідь:
Hello from a non-root containerПеревірити користувача всередині контейнера можна так:
docker run --rm optimized-server idВивід міститиме користувача app, а не root.
Docker кешує результати інструкцій Dockerfile. Якщо інструкція та її залежності не змінилися, Docker може використати готовий шар замість повторного виконання.
Наприклад:
COPY . .
RUN go build ./cmd/serverУ такому випадку зміна будь-якого файлу в контексті може інвалідувати кеш для RUN go build.
Краще розділяти файли, які змінюються рідко, і файли, які змінюються часто:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ./cmd/serverТепер зміна main.go не змушує повторно завантажувати всі Go-залежності. Кеш шару з go mod download буде використано, доки не зміняться go.mod або go.sum.
Зазвичай варто розміщувати інструкції в такому порядку:
стабільний базовий образ;
системні пакети;
файли опису залежностей;
встановлення залежностей;
файли вихідного коду;
компіляція або збірка;
runtime-конфігурація.
Часто змінювані файли слід копіювати якомога пізніше. Це збільшує ймовірність повторного використання попередніх шарів.
Звичайний шар Docker-кешу прив’язаний до результату конкретної інструкції. BuildKit також підтримує кеш-монтування для менеджерів пакетів і компіляторів:
RUN --mount=type=cache,target=/go/pkg/mod \
go mod downloadВміст /go/pkg/mod використовується наступними збірками, але не записується у фінальний шар образу.
Для Go зазвичай корисні два окремі кеші:
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
RUN --mount=type=cache,target=/root/.cache/go-build \
go build ./cmd/serverПерший кеш містить завантажені модулі, другий — результати компіляції.
Збірка через BuildKit:
DOCKER_BUILDKIT=1 docker build -t optimized-server .Або через buildx:
docker buildx build --load -t optimized-server .Кеш прискорює наступні збірки, але не робить образ більшим. Він не замінює фіксацію версій залежностей у go.mod, go.sum або аналогічних файлах.
.dockerignoreПід час docker build Docker передає daemon або BuildKit контекст збірки. Якщо запускати:
docker build -t optimized-server .контекстом є поточний каталог.
Без .dockerignore у контекст можуть потрапити:
.git;
локальні залежності;
результати попередніх збірок;
логи;
секрети;
великі тестові файли.
Це збільшує час передавання контексту та може ненавмисно додати конфіденційні дані до збірки.
.dockerignore потрібно підтримувати так само уважно, як і .gitignore. Не варто бездумно виключати файли, які потрібні для компіляції. Наприклад, якщо застосунок використовує шаблони або статичні ресурси, їх не можна приховувати від Docker.
За замовчуванням процес у контейнері може запускатися від імені root. Це не означає, що процес має необмежені можливості на хості, але наслідки вразливості стають серйознішими.
У фінальному образі створіть окремого користувача:
RUN addgroup -S app && adduser -S -G app app
USER appУсі інструкції до USER app виконуються від імені root, тому користувача потрібно створити до перемикання.
Якщо застосунку потрібні файли, доступні для запису, створіть каталог і передайте його власнику під час складання:
RUN addgroup -S app \
&& adduser -S -G app app \
&& mkdir -p /var/lib/app \
&& chown -R app:app /var/lib/app
USER app
WORKDIR /var/lib/appНе слід використовувати chmod 777 як універсальне вирішення. Краще видати власнику або групі лише необхідні права.
Для узгодження прав між контейнером та оркестратором можна вказати числові ідентифікатори:
RUN addgroup -S -g 10001 app \
&& adduser -S -D -u 10001 -G app app
USER 10001:10001Це може бути корисно для volume або середовищ, де політики безпеки вимагають запуску з конкретним UID.
Водночас UID може конфліктувати з вимогами базового образу або платформою запуску. Якщо конкретний UID не потрібен, звичайного непривілейованого користувача достатньо.
Чим менший runtime-образ, тим менше в ньому непотрібних компонентів. Варіанти потрібно обирати з урахуванням вимог застосунку.
FROM alpine:3.22Переваги:
невеликий розмір;
доступні базові інструменти;
можна додати сертифікати або системні бібліотеки за потреби.
Недоліки:
застосунку можуть знадобитися бібліотеки, яких немає за замовчуванням;
поведінка може відрізнятися від Debian-подібних образів.
Якщо застосунок звертається до HTTPS-сервісів, часто потрібно додати кореневі сертифікати:
RUN apk add --no-cache ca-certificatesscratchДля повністю статично скомпільованих програм можна використати порожній образ:
FROM scratch
COPY --from=build /out/server /server
USER 10001:10001
ENTRYPOINT ["/server"]scratch не містить shell, сертифікатів, timezone database та інших системних файлів. Це мінімізує поверхню атаки, але покладає на розробника відповідальність за всі runtime-залежності.
Для застосунку, який використовує HTTPS, у scratch потрібно явно скопіювати сертифікати з етапу збірки:
FROM alpine:3.22 AS certificates
RUN apk add --no-cache ca-certificates
FROM scratch
COPY --from=build /out/server /server
COPY --from=certificates /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 10001:10001
ENTRYPOINT ["/server"]scratch не має команди sh, тому такий контейнер не можна перевірити через:
docker run --rm -it image shДля діагностики краще тимчасово використовувати Alpine-фінальний образ або окремий debug-образ.
Подивитися розмір образу:
docker image ls optimized-serverПодивитися шари:
docker history optimized-serverУ production-образі не повинні залишатися:
компілятори;
вихідний код, якщо він не потрібен runtime;
package manager cache;
.git;
тестові артефакти;
секрети;
файли з локального середовища.
Перевірте, від імені якого користувача запускається процес:
docker run --rm optimized-server idПеревірте, що застосунок справді працює без доступу до root-директорій:
docker run --rm -p 8080:8080 optimized-serverРозмір образу не є єдиним критерієм. Образ має залишатися:
відтворюваним;
сумісним із runtime-середовищем;
придатним для діагностики;
безпечним;
достатнім для роботи застосунку.
Поганий порядок:
COPY . .
RUN go mod downloadБудь-яка зміна вихідного коду може зламати кеш встановлення залежностей.
Краще:
COPY go.mod go.sum ./
RUN go mod download
COPY . .Якщо в production-образі залишаються компілятор, shell і build tools, multi-stage build використано не повністю.
Перевіряйте, що фінальний етап починається з нового FROM, а в нього копіюються лише runtime-файли.
Такий варіант не завжди зменшує фінальний образ:
RUN apt-get update && apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*Файли, створені в попередньому шарі, можуть залишатися в історії образу. Для build-залежностей надійніше використовувати окремий етап і не переносити цей етап у production.
rootДодавання USER app лише в development-образі не захищає production. Переконайтеся, що USER оголошено саме у фінальному етапі.
latestТег latest не гарантує відтворюваність. Краще явно вказувати версію базового образу:
FROM golang:1.24-alpine AS build
FROM alpine:3.22У production також контролюйте оновлення базових образів і залежностей окремим процесом, а не покладайтеся на неявну зміну тегу.
Не копіюйте токени, паролі та ключі в образ:
COPY .env .
ARG API_TOKEN=secretТакі значення можуть залишитися в шарах або історії збірки. Секрети не повинні потрапляти в контекст збірки та фінальний образ.
Використовуйте multi-stage builds, щоб не переносити компілятори та build-залежності у production.
Копіюйте файли залежностей до вихідного коду, щоб ефективно використовувати кеш шарів.
Використовуйте BuildKit cache mounts для залежностей і результатів компіляції.
Додавайте .dockerignore, щоб зменшити контекст збірки та не передати зайві або конфіденційні файли.
Вибирайте мінімальний фінальний образ, але враховуйте сертифікати, системні бібліотеки та можливості діагностики.
Створюйте непривілейованого користувача й оголошуйте USER у фінальному етапі.
Перевіряйте результат через docker image ls, docker history і запуск контейнера від імені не-root користувача.