«Контейнер скомпрометирован». С этих слов начался трёхчасовой ночной кошмар с утечкой данных и полным отчётом об инциденте. Всё из-за банального запуска контейнера от 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 и всего, что с ним связано.
