«Контейнер скомпрометирован». С этих слов начался трёхчасовой ночной кошмар с утечкой данных и полным отчётом об инциденте. Всё из-за банального запуска контейнера от root с лишними правами.

Тут не нужно быть хакером или гуру DevSecOps. Проблема в том, что безопасность попросту игнорируют. В новом переводе от команды Spring АйО — 11 конкретных шагов, которые помогут вам избежать катастрофы: от запуска от непривилегированного пользователя до минимальных образов, сканирования уязвимостей, изоляции сетей и настройки профилей безопасности.

Если вы разворачиваете контейнеры в продакшене — это руководство должно стать вашей чеклист-основой. Ошибка может стоить очень дорого.


Позвольте рассказать о звонке, который я получил в 11 вечера во вторник. Контейнер одного клиента был скомпрометирован. Злоумышленник воспользовался уязвимостью в их Node.js-приложении, получил shell-доступ внутрь контейнера, а поскольку контейнер запускался от root и с чрезмерными привилегиями — злоумышленник получил доступ к хост-системе.

Три часа ликвидации последствий. Утечка пользовательских данных. Отчёт об инциденте. Всё это было предотвратимо.

Вот в чём дело с безопасностью Docker — в большинстве случаев она несложна. Её просто пропускают. Команда запускает контейнер, он работает, его выкатывают в прод — и безопасность превращается в «чем-нибудь займёмся потом».

А «потом» никогда не наступает — до тех пор, пока что-то не сломается.

Это руководство — всё, что я хотел бы, чтобы тот клиент сделал до того вторника ночью.
Без сложных знаний по безопасности. Только практические шаги, которые существенно снижают риски.

Зачем нужна безопасность Docker

Существует расп��остранённое заблуждение: контейнеры по умолчанию безопасны.
Они же изолированы, верно?

Ну, отчасти.

Контейнеры разделяют ядро хост-системы. Это не виртуальные машины с полной изоляцией.
Уязвимость в механизме выхода из контейнера (а такие уязвимости существуют) может дать злоумышленнику доступ к вашему хосту и ко всем другим контейнерам на нём.

Комментарий от Михаила Поливаха

Ну вот для примера - очень частый хак, который нужен разработчикам, это получение доступа к docker daemon api изнутри контейнера.

Дело в том, что условный docker CLI клиент (то есть командная утилита, с которой выработаете, типа docker pull, docker run и т.д.) общается с docker daemon-ом через unix доменные сокеты (на Linux, на windows там по-моему npipe-ы используются).

Это настолько частая проблема, что вокруг того, как безопасно получить этот доступ к docker daemon существуют целые библиотеки, относительно популярные:

https://github.com/Tecnativa/docker-socket-proxy

Так что такие истории получения изнутри контейнера доступа к хост системе они вполне реальные.

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

Цель усиления безопасности — проста: если что-то пойдёт не так, ограничить зону поражения. Сделать проникновение сложнее, а если оно произошло — свести к минимуму возможные действия злоумышленника.

Разберёмся, как этого добиться с помощью практических шагов.

Шаг 1: Перестаньте запускать контейнеры от root

Это самое значимое изменение, которое вы можете внести.

По умолчанию процессы внутри контейнеров Docker запускаются от root. Не от ограниченного пользователя, а от настоящего root с UID 0.

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

Комментарий от Михаила Поливаха

Там проблема даже не в том, что он выйдет на хост систему, а в том, что он получит root доступ на хост системе.

Есть такая фича в ядре Linux - kernel namespace remapping. Она по сути добавляет новую внутреннюю структуру маппинга между UID-ами пользователей в контейнере и на хост системе.

Эта фича она как раз нужна, чтобы в ситуации, когда  процесс внутри контейнера каким-то образом получил доступ к хост системе, чтобы он не имел UID = 0, т.е. не был рутом.

Беда только в том, что в большинстве дистров она, по-моему, по-умолчанию отключена. По ряду причин.

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

Как это исправить:
Создайте отдельного пользователя в Dockerfile и переключитесь на него:

FROM node:20-alpine

WORKDIR /app

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy files and set ownership
COPY --chown=appuser:appgroup . .

# Install dependencies as root (if needed), then switch
RUN npm ci --only=production

# Switch to non-root user
USER appuser

CMD ["node", "server.js"]

Ключевые моменты:

  • addgroup и adduser создают системную группу и пользователя

  • флаг -S создаёт системную учётную запись (без пароля и домашнего каталога)

  • флаг --chown устанавливает правильные права при копировании файлов

  • инструкция USER переключает выполнение всех последующих команд на указанного пользователя

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

Быстрая проверка:

Запустите контейнер и убедитесь:

docker run myapp whoami

Если в выводе указан root — у вас ещё есть над чем поработать.

Шаг 2: Используйте минимальные базовые образы

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

Я регулярно вижу контейнеры, основанные на полноценных образах Ubuntu или Debian.
Сотни установленных пакетов: shell-интерпретаторы, компиляторы, сетевые утилиты — всё то, что приложение не использует, но обожают злоумышленники.

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

Например, Alpine Linux:

FROM node:20-alpine

Alpine — крошечный образ, около 5 МБ в базе. Он использует musl libc вместо glibc, что иногда вызывает проблемы с совместимостью, но для большинства приложений работает отлично.

Distroless-образы:

FROM gcr.io/distroless/nodejs20-debian12

Distroless идёт ещё дальше.

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

Slim-варианты:

FROM python:3.12-slim

Если Alpine кажется слишком ограниченным, slim-варианты убирают всё лишнее,
сохраняя при этом совместимость с glibc.

Результат:

Образ на базе Ubuntu весом 1,2 ГБ?

С Alpine его можно ужать до менее 100 МБ.

Меньше пакетов → меньше уязвимостей → быстрее загрузка → меньшая поверхность атаки.

Шаг 3: Сканируйте образы на наличие уязвимостей

Ваш базовый образ содержит уязвимости.
Ваши зависимости содержат уязвимости.
Это не пессимизм — это реальность.

Вопрос в том, узнаете ли вы об этом раньше, чем злоумышленники.

Встроенное сканирование с помощью Docker Scout:

docker scout cves myapp:latest

Это покажет вам известные уязвимости в вашем образе, отсортированные по степени критичности.

Trivy (бесплатный и детальный сканер):

trivy image myapp:latest

Trivy сканирует:

  • уязвимости в пакетах ОС

  • зависимости приложения

  • ошибки конфигурации

  • секреты, случайно попавшие в образ

Что делать с результатами:

В первый раз вы, скорее всего, увидите стену из CVE.
Не паникуйте. Сосредоточьтесь на:

  • уязвимостях с критическим и высоким уровнем опасности

  • уязвимостях в пакетах, которые реально используются вашим приложением

  • проблемах, для которых уже существуют известные эксплойты

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

Автоматизируйте процесс:

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

Это уже не опция — это элементарная гигиена

# Example GitHub Actions step
- name: Scan image
  run: |
    trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

Шаг 4: Никогда не включайте секреты в образы

До сих пор встречаются захардкоженные секреты в Dockerfile:
API-ключи в ENV, пароли в build-args, приватные ключи, скопированные в образ.

Это не просто плохая практика — это медленно тикающая бомба.

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

Вот как делать не нужно:

# NEVER do this
ENV DATABASE_PASSWORD=supersecretpassword
ENV AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG

# Also bad - build args are visible in image history
ARG API_KEY
ENV API_KEY=$API_KEY

Как правильно работать с секретами:

Переменные окружения во время выполнения:

Передавайте секреты при запуске контейнера, а не при его сборке:

docker run -e DATABASE_PASSWORD="$DATABASE_PASSWORD" myapp

Ещё лучше — используйте .env-файл, который не коммитится в git:

docker run --env-file .env.production myapp

Docker secrets (для Swarm):

echo "mysecretpassword" | docker secret create db_password -

Затем в вашем compose-файле:

services:
  app:
    secrets:
      - db_password
secrets:
  db_password:
    external: true

BuildKit-маунты секретов (для секретов во время сборки):

Иногда секреты нужны на этапе сборки, например, токены для приватного npm-репозитория.
BuildKit позволяет безопасно передавать их:

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

docker build --secret id=npmrc,src=$HOME/.npmrc .

Секрет доступен только во время выполнения конкретной команды RUN и никогда не сохраняется ни в одном слое образа.

Внешние менеджеры секретов:

В продакшене используйте полноценные системы управления секретами:

  • HashiCorp Vault

  • AWS Secrets Manager

  • Azure Key Vault

  • Google Secret Manager

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

Шаг 5: Сделайте файловую систему только для чтения

Если злоумышленник попадает в контейнер, первое, что он, скорее всего, попытается сделать — записать файлы: загрузить вредоносный код, изменить файлы приложения, создать механизмы для сохранения доступа.

Файловая система только для чтения моментально блокирует такие действия.

Как включить:

docker run --read-only myapp

В Docker Compose:

services:
  app:
    image: myapp
    read_only: true

Работа с приложениями, которым нужно записывать данные:


Большинству приложений нужно куда-то записывать — временные файлы, кэш, логи.
Создайте конкретные доступные для записи директории с помощью tmpfs-маунтов:

docker run --read-only --tmpfs /tmp --tmpfs /app/cache myapp

В Compose:

services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp
      - /app/cache

Приложение может записывать в /tmp и /app/cache, но это файловые системы в памяти, и данные на них не сохраняются.

Всё остальное — надёжно заблокировано.

Почему это важно:

Даже если злоумышленник получает возможность выполнять код, он не сможет:

  • изменить код приложения

  • загрузить и закрепить вредоносное ПО

  • изменить конфигурационные файлы

  • создать новые исполняемые файлы

Он окажется заперт в рамках того, что уже находится внутри контейнера.

Шаг 6: Удалите ненужные возможности (capabilities)

Linux capabilities — это механизм, позволяющий давать процессам отдельные «root-права» без полного доступа root.

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

Например, по умолчанию контейнеры могут:

  • изменять владельца файлов (CAP_CHOWN)

  • обходить проверки прав доступа (CAP_DAC_OVERRIDE)

  • привязываться к привилегированным портам (CAP_NET_BIND_SERVICE)

Вашему Node.js API, скорее всего, ничего из этого не требуется.

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

docker run --cap-drop=ALL myapp

Если вашему приложению нужны определённые возможности — добавьте только их:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

В Compose:

services:
  app:
    image: myapp
    cap_drop:
      - ALL
    cap_add:
      - NET_BIND_SERVICE

А как насчёт --privileged?

Никогда не используйте --privileged в продакшене. Никогда.

# DON'T DO THIS
docker run --privileged myapp

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

Шаг 7: Установите лимиты на ресурсы

Контейнер без ограничений по ресурсам может потреблять всю доступную CPU и память на хосте. И это не только вопрос производительности — это вопрос безопасности.

Злоумышленник может намеренно исчерпать ресурсы (атака отказа в обслуживании, или Denial of Service). Майнер может полностью загрузить процессоры. Утечка памяти может «уронить» другие контейнеры на том же хосте.

Установите лимиты по памяти:

docker run --memory=512m --memory-swap=512m myapp

Параметр --memory-swap, равный --memory, запрещает использование swap — а это обычно именно то, что вам нужно.

В Docker Compose:

services:
  app:
    image: myapp
    deploy:
      resources:
        limits:
          memory: 512M
        reservations:
          memory: 256M
Комментарий от Евгения Сулейманова

memory: 512M - это место опасно тем, что выглядит как "мы поставили лимиты", но часто это просто декорация.
deploy.resources - это не лимиты контейнера, а директивы оркестратору (Swarm/платформе деплоя). В спецификации Compose поддержка deploy - опциональна, и если ваш рантайм ее не реализует, секция легально игнорируется.

Т.е., вы можете написать в YAML что угодно, команда будет уверена, что "память ограничили", но при docker compose up эти лимиты могут не примениться вообще - контейнер получит безлимит по памяти/CPU и сможет "уронить" хост.

Как делать так, чтобы лимиты работали:
Если вы деплоите через Swarm - используйте docker stack deploy, и тогда deploy.resources имеет смысл.

Если вы в обычном Compose (docker compose up) - либо запускайте с --compatibility (и все равно проверяйте факт применения), либо используйте механизмы, которые ваш Compose/Engine "прокидывает" в HostConfig.

Надежная проверка:

docker inspect <container> --format '{{.HostConfig.Memory}} {{.HostConfig.NanoCpus}}'

Это ограничивает контейнер до 1.5 CPU-ядер.

Почему это важно:

Если контейнер будет скомпрометирован, лимиты ресурсов ограничат масштаб ущерба.
Злоумышленник не сможет майнить биткоины, используя всю мощность сервера.
Он не сможет «уронить» другие сервисы за счёт исчерпания ресурсов.

Шаг 8: Изолируйте свои сети

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

В продакшене такое поведение слишком открытое.

Создавайте отдельные сети:

services:
  frontend:
    networks:
      - frontend-net
  
  backend:
    networks:
      - frontend-net
      - backend-net
  
  database:
    networks:
      - backend-net

networks:
  frontend-net:
  backend-net:

Теперь фронтенд может обращаться к бэкенду, бэкенд — к базе данных, но фронтенд не имеет прямого доступа к базе данных.

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

Отключайте межконтейнерное взаимодействие, если оно не нужно:

Для полной изоляции контейнеров:

docker network create --driver bridge -o com.docker.network.bridge.enable_icc=false isolated-net

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

Шаг 9: Используйте профили безопасности


Docker поддерживает фреймворки безопасности, такие как Seccomp и AppArmor, которые ограничивают, какие системные вызовы может выполнять контейнер.

Seccomp (Secure Computing Mode):

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

docker run --security-opt seccomp=custom-profile.json myapp

Для большинства приложений профиль по умолчанию подходит. Не отключайте его.

# DON'T DO THIS
docker run --security-opt seccomp=unconfined myapp

AppArmor:
Профили AppArmor ограничивают доступ к файлам, сети и возможностям (capabilities).
Docker применяет профиль по умолчанию, но вы можете создавать собственные.

docker run --security-opt apparmor=docker-custom myapp

Без новых привилегий (no new privileges): это предотвращает получение процессами дополнительных привилегий через setuid-бинарники или другие механизмы:

docker run --security-opt=no-new-privileges:true myapp

В Compose:

services:
  app:
    security_opt:
      - no-new-privileges:true

Это следует включать почти для каждого контейнера в продакшене.

Шаг 10: Включите Docker Content Trust

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

Docker Content Trust использует цифровые подписи для проверки издателя образа.

Включите его:

export DOCKER_CONTENT_TRUST=1

При включённом режиме Docker будет загружать только подписанные образы.
Если образ не подписан — загрузка завершится с ошибкой.

Для ваших собственных образов. Подписывайте их при публикации:

docker trust sign myregistry/myapp:v1.2.3

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

Шаг 11: Обновляйте всё

Устаревший Docker-демон, старые базовые образы, неактуальные зависимости приложения — всё это со временем накапливает уязвимости.

Обновляйте Docker Engine:

# Check current version
docker version

# Update (varies by installation method)
sudo apt-get update && sudo apt-get install docker-ce docker-ce-cli containerd.io

Регулярно обновляйте базовые образы:
Закрепляйте версии, но обновляйте эти закреплённые версии хотя бы раз в месяц или квартал.

Автоматизируйте обновление зависимостей:
Используйте инструменты вроде Dependabot или Renovate, чтобы автоматически создавать pull-запросы при появлении обновлений с исправлениями уязвимостей.

Полный пример Dockerfile с усиленной безопасностью

Объединяем всё вместе:

# Use specific, minimal base image
FROM node:20.11.1-alpine3.19 AS builder

WORKDIR /app

# Copy dependency files first (layer caching)
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci --only=production

# Copy application code
COPY . .

# Build if needed
RUN npm run build

# Production stage - even smaller image
FROM node:20.11.1-alpine3.19

# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init

WORKDIR /app

# Create non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Copy built application with proper ownership
COPY --from=builder --chown=appuser:appgroup /app/dist ./dist
COPY --from=builder --chown=appuser:appgroup /app/node_modules ./node_modules
COPY --from=builder --chown=appuser:appgroup /app/package.json ./

# Set environment
ENV NODE_ENV=production

# Use non-root user
USER appuser

# Expose port (documentation)
EXPOSE 3000

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"

# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/server.js"]
Running it with security options:
docker run -d \
  --name myapp \
  --read-only \
  --tmpfs /tmp \
  --cap-drop=ALL \
  --security-opt=no-new-privileges:true \
  --memory=512m \
  --cpus=1 \
  --user appuser \
  -p 3000:3000 \
  myapp:v1.2.3

Чеклист безопасности

Перед развёртыванием любого контейнера в продакшен убедитесь, что:

  • Контейнер запускается не от root-пользователя

  • Используется минимальный базовый образ (alpine, distroless, slim)

  • В Dockerfile и слоях образа нет секретов

  • Образ отсканирован на наличие уязвимостей

  • Установлены лимиты ресурсов (память, CPU)

  • Файловая система только для чтения, где это возможно

  • Удалены все capabilities (--cap-drop=ALL), добавлены только необходимые

  • Не используется --privileged

  • Включён параметр no-new-privileges

  • Настроены health checks

  • Сети изолированы надлежащим образом

  • Базовые образы и зависимости регулярно обновляются

Настоящая цена пренебрежения безопасностью

Помните того клиента, о котором я упоминал в начале?
Инцидент обошёлся им примерно в 10 000 долларов — с учётом реагирования, уведомления клиентов, юридической экспертизы и устранения последствий.

А шаги по усилению безопасности из этого руководства? Пара часов работы заранее.

Безопасность — это не паранойя, это реализм. Контейнеры взламывают. В приложениях есть уязвимости. Злоумышленники ищут лёгкие цели.

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

Каждый шаг из этого гайда — это ещё один уровень защиты. Ничего из этого не сложно. Большинство пунктов внедряются за считаные минуты.

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.