В enterprise-компаниях вместо ванильного Kubernetes часто стоит OpenShift — и разработчик, привыкший к kubectl и Ingress, попадает в мир oc, Routes и отказов «pod не может стартовать под root», причина которых снаружи не видна. Разберём, что OpenShift добавляет поверх Kubernetes и что из этого реально касается вас.
Разница видна на одном сценарии: один и тот же манифест и один и тот же образ уезжают в оба кластера.
Манифест переезжает в OpenShift без правок, а образ — нет: SCC подменяет UID на случайный, и каталог, созданный под UID 1000, становится недоступен на запись. Лечится группой 0, а не возвратом к root.
Что это
Образ, который месяцами работал в обычном Kubernetes, в OpenShift падает на старте с Permission denied. Причина не в образе, а в том, что OpenShift добавляет поверх Kubernetes.
OpenShift — дистрибутив Kubernetes от Red Hat. Внутри — тот же Kubernetes (те же поды, деплойменты, сервисы, тот же API), но собранный в готовый продукт с поддержкой, интегрированной сборкой, безопасностью «из коробки» и веб-консолью. Аналогия: Kubernetes — ядро, OpenShift — дистрибутив вокруг него, как Ubuntu вокруг Linux.
Поэтому знание Kubernetes переносится напрямую: манифесты Deployment, Service, ConfigMap работают как есть. Отличия — в надстройках.
Что добавляет поверх Kubernetes
- Routes вместо Ingress. Внешний доступ в OpenShift исторически делается через объект
Route(со встроенным HAProxy-роутером), а не черезIngress. В OpenShift 4 объектIngressтоже принимается — кластер просто создаёт из негоRouteи дальше работает с ним; то есть ваши ванильные манифесты не отвалятся. Но в существующих проектах вы почти всегда встретите именноRoute. - Встроенный реестр и сборка (S2I). OpenShift умеет собирать образ из исходников через Source-to-Image: даёшь репозиторий — получаешь образ в его внутреннем реестре, без ручного
Dockerfile. За это отвечают объектыBuildConfigиImageStream. - Security Context Constraints (SCC). Главный источник сюрпризов. Политика по умолчанию называется
restricted-v2: она запрещает контейнерам запускаться от root и подменяет UID процесса. Образ, который на обычном Kubernetes работал под root — или под фиксированным UID изUSER, — здесь падает: процесс стартует под чужим UID и не может писать в собственные каталоги. Лечится это не возвратом к root, а правами: не хардкодить UID, отдать нужные каталоги группе0. - DeploymentConfig. Старший родственник
Deployment, придуманный в OpenShift до того, какDeploymentпоявился в самом Kubernetes. В новых проектах его не заводят (в свежих версиях он объявлен устаревшим), но в живом проекте на OpenShift встретить его шансы высокие. Ведёт себя он иначе: выкаткой управляет отдельный под-развёртыватель, есть триггеры — например, пересобралсяImageStream, иDeploymentConfigсам начал выкатку, — а команды другие:oc rollout latest dc/order-serviceвместо привычного пути через смену образа вDeployment. Если видите в проектеkind: DeploymentConfig, не ищитеkubectl rollout— работайте черезoc. ocи веб-консоль. CLIoc— надмножествоkubectl(всё то же плюс OpenShift-специфика:oc new-app,oc login). Плюс развитая графическая консоль для деплоя и диагностики.- Projects вместо namespace. «Проект» в OpenShift — это namespace с дополнительными политиками доступа поверх.
Путь от репозитория до работающего пода без своего Dockerfile: BuildConfig собирает образ, ImageStream держит тег, а сдвиг тега сам запускает выкатку Deployment.
Как починить образ и манифест: код
Всё описанное выше сводится к нескольким строкам в Dockerfile и в манифесте. Вот они целиком.
Dockerfile под произвольный UID. Смысл в том, что процесс придёт с неизвестным номером пользователя, но с группой 0: значит, каталоги, куда он пишет, должны принадлежать этой группе и быть доступны ей на запись.
FROM registry.access.redhat.com/ubi9/openjdk-21-runtime
WORKDIR /app
COPY build/libs/app.jar app.jar
# каталоги, куда приложение пишет: группа 0 и права группы как у владельца
RUN mkdir -p /app/work /app/tmp && \
chgrp -R 0 /app && \
chmod -R g=u /app
# фиксированный USER не нужен и мешает: OpenShift всё равно подменит UID
USER 1001
ENTRYPOINT ["java", "-Djava.io.tmpdir=/app/tmp", "-jar", "app.jar"]
Три детали. chmod g=u означает «дать группе те же права, что у владельца» — это и есть стандартный приём для произвольного UID. Базовый образ Red Hat (UBI) взят не для красоты: в нём заранее заведены записи пользователей, и System.getProperty("user.name") не спотыкается. USER 1001 оставляют как указание «этот образ не для root» — платформа всё равно назначит свой номер, но на обычном Kubernetes образ тоже не будет работать от суперпользователя.
Манифест, который проходит политику по умолчанию. Явно описанный контекст безопасности не обязателен, но делает манифест переносимым: он одинаково пройдёт и политику OpenShift, и строгие политики обычного кластера.
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: app
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /app/tmp
volumes:
- name: tmp
emptyDir: {}
Обратите внимание, чего здесь нет: поля runAsUser с конкретным номером. Его и не должно быть — номер назначит платформа, а жёстко заданный UID политика restricted-v2 отвергнет.
Route на сервис. Простейший вариант делается одной командой, но в репозитории держат объект:
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: orders
spec:
host: orders.apps.cluster.example.com
to:
kind: Service
name: orders
port:
targetPort: 8080
tls:
termination: edge
insecureEdgeTerminationPolicy: Redirect
oc expose service/orders --hostname=orders.apps.cluster.example.com # то же быстро
Путь снаружи один и тот же, меняется только объект на входе: TLS заканчивается на роутере, до пода идёт обычный HTTP, а манифест Ingress в OpenShift всё равно превращается в Route.
Три вида TLS у Route
Поле termination — первый практический вопрос при выпуске сервиса наружу, и вариантов три.
edge — шифрование заканчивается на роутере, до пода идёт обычный HTTP. Сертификат хранится у платформы (обычно общий на домен кластера), приложение про TLS ничего не знает. Это выбор по умолчанию для большинства сервисов. insecureEdgeTerminationPolicy: Redirect добавляет перевод с HTTP на HTTPS.
passthrough — роутер не расшифровывает ничего, поток идёт до пода как есть. Нужен, когда сертификат обязан принадлежать приложению (взаимная проверка сертификатов, особые требования регулятора) или когда протокол не HTTP. Плата: роутер не видит путей и заголовков, поэтому маршрутизация только по имени хоста, и никаких заголовков вроде X-Forwarded-For приложение не получит.
reencrypt — роутер расшифровывает поток снаружи и заново шифрует внутрь, к поду. Берут, когда шифрование внутри кластера обязательно, но маршрутизация по пути и заголовки нужны. Требует, чтобы приложение слушало HTTPS, а роутеру был известен сертификат, которым оно подписано.
Практическая подсказка: начинают с edge; reencrypt появляется, когда шифрование между всеми звеньями требует регулятор; passthrough — когда сертификат по-настоящему ваш.
Что это значит для разработчика
Если вы деплоите Spring Boot-сервис в OpenShift:
- Образ должен быть rootless. Готовьте контейнер так, чтобы он работал под произвольным непривилегированным UID (не пишите в
/, кладите файлы в каталоги с групповой записью:chgrp 0иchmod g=u— процесс идёт с GID 0 и пишет под любым UID). Это же — хорошая практика и для обычного Kubernetes. - «Случайный» UID на самом деле не случайный. Диапазон выдаётся на проект — он записан в аннотации namespace вида
openshift.io/sa.scc.uid-range, и первый номер из диапазона достаётся вашим подам. Практический вывод: в одном проекте UID стабилен от запуска к запуску, а в соседнем проекте у того же образа он будет другой. Поэтому «у нас на стенде работало, а в другом namespace упало» — нормальная для OpenShift история, а не мистика. - UID, которого нет в
/etc/passwd. Вот это ловит уже конкретно Java-сервисы. Процесс идёт под номером, для которого в образе нет записи пользователя, и всё, что пытается узнать «а кто я такой», спотыкается:System.getProperty("user.name")отдаёт что-то вроде?, домашний каталог не определяется, часть утилит и библиотек (включая некоторые клиенты баз и SSH) падает с ошибкой поиска пользователя. Лечится двумя способами: взять базовый образ Red Hat (UBI) — там записи заведены заранее, — или подложитьnss_wrapper, который подсовывает процессу нужную строку на лету. - Внешний доступ — через Route. Вместо
IngressсоздаётеRouteна вашService(или пользуетесьoc expose). - Пробы, ресурсы, конфигурация — как в Kubernetes.
readinessProbe,livenessProbe, лимиты CPU/памяти,ConfigMap/Secret— всё то же самое; здесь ничего переучивать не нужно. - Порт ниже 1024 не откроется. Процесс под непривилегированным номером не имеет права слушать привилегированные порты, а выдать ему такое право политика по умолчанию не даст. Приложение слушает 8080 (или любой выше 1024), а снаружи 443 обеспечивает роутер — это обычная схема, но манифест, принесённый из среды, где приложение слушало 80, здесь упадёт.
hostPath,privilegedи доступ к сети узла запрещены. Всё, что выходит за границу пода, политика отвергает: монтирование каталогов узла, привилегированный режим,hostNetwork,hostPID. Для отладочных инструментов и агентов, принесённых из обычного кластера, это самая частая причина отказа на этапе создания пода (forbidden: violates PodSecurityили отказ SCC в событиях).
Команды oc, которые действительно нужны
oc — надмножество kubectl, поэтому всё привычное работает, а специфику стоит знать поимённо.
oc login https://api.cluster.example.com:6443 # вход, дальше контекст как в kubectl
oc project payments # переключить проект (namespace)
oc get route,dc,is # объекты, которых нет в обычном кластере
oc describe scc restricted-v2 # какая политика действует и что она разрешает
oc get pod my-pod -o jsonpath='{.metadata.annotations.openshift\.io/scc}' # какая SCC применилась к поду
oc rollout latest dc/orders # выкатка для DeploymentConfig
oc rollout status deployment/orders # а для Deployment всё как в kubectl
oc logs -f dc/orders # логи по объекту, а не по поду
oc rsh deployment/orders # зайти внутрь (аналог kubectl exec -it)
oc debug deployment/orders --as-root # отладочная копия пода, если позволяют права
oc adm policy who-can create routes # кто вообще может это делать
Две из них стоит запомнить отдельно. oc get pod -o jsonpath='{...openshift.io/scc}' отвечает на вопрос «по какой политике меня запустили» — первое, что выясняют при отказе. А oc debug поднимает копию пода с изменёнными параметрами: это штатный способ разобраться, почему приложение не стартует, когда внутрь обычного пода зайти нечем.
ImageStream: зачем он разработчику
ImageStream — объект, который хранит ссылки на образы и их теги внутри кластера, отдельно от самого реестра. Пользы от него ровно одна, и она практическая: на изменение тега в нём можно подписаться.
oc import-image orders:1.4.2 --from=ghcr.io/acme/orders:1.4.2 --confirm
oc tag orders:1.4.2 orders:prod # передвинуть «указатель» prod на новую версию
Дальше объект развёртывания с триггером на этот тег выкатывается сам, как только указатель передвинулся:
triggers:
- type: ImageChange
imageChangeParams:
automatic: true
containerNames: ["app"]
from:
kind: ImageStreamTag
name: orders:prod
То есть выкат превращается в одну команду oc tag, а не в правку манифеста. Это удобно и это же ловушка: развёртывание теперь зависит от состояния объекта в кластере, а не только от того, что лежит в репозитории, — и подход с репозиторием как источником правды с таким триггером плохо сочетается. В Deployment (а не DeploymentConfig) то же делается аннотацией image.openshift.io/triggers.
Если образ действительно требует фиксированного UID
Бывает, что переделать образ нельзя: чужой продукт, унаследованный образ, требование поставщика. Тогда дорога одна — попросить платформенную команду выдать вашей служебной записи более свободную политику.
# это делает администратор кластера, не разработчик
oc adm policy add-scc-to-user anyuid -z orders-sa -n payments
Политика anyuid разрешает запуск под тем UID, который задан в образе, включая root; nonroot-v2 — под фиксированным непривилегированным номером. Просить стоит вторую: она закрывает большинство случаев и не открывает лишнего.
Как выглядит такой разговор с платформенной командой, чтобы он закончился успехом: назвать образ и причину (почему UID нельзя сделать произвольным), назвать нужную политику, попросить привязать её к конкретной служебной записи в конкретном проекте, а не к группе, и договориться о сроке — обычно до переделки образа. Отказ тоже возможен, и тогда остаётся оборачивание: свой слой поверх чужого образа, который правит права на каталоги (chgrp 0, chmod g=u) — это решает большинство случаев без всяких политик.
Практический вывод: учите Kubernetes — он переносится в OpenShift почти целиком. OpenShift-специфику (Routes, SCC, S2I) добираете точечно под конкретный кластер.
Коротко
- OpenShift — дистрибутив Kubernetes от Red Hat: то же ядро, надстройки сверху и коммерческая поддержка.
- Внешний доступ — через
Route(встроенный роутер), а не толькоIngress. У Route три вида TLS:edge(шифрование до роутера, выбор по умолчанию),passthrough(до пода, маршрутизация только по имени хоста),reencrypt(расшифровал и зашифровал внутрь). - Умеет собирать образ из исходников (S2I,
BuildConfig,ImageStream) и хранить его во встроенном реестре. Полезные команды:oc get pod -o jsonpath=...openshift.io/scc(по какой политике запустили),oc debug,oc rsh,oc rollout latest dc/...;oc tagв ImageStream запускает выкатку по триггеру. - Security Context Constraints (по умолчанию
restricted-v2) запрещают root и подменяют UID — образ должен быть rootless, это частая причина падений при переезде. Диапазон UID выдаётся на проект, а самого пользователя нет в/etc/passwd. - В живых проектах встречается
DeploymentConfig— предокDeploymentсо своими триггерами и своей выкаткой черезoc. oc— надмножествоkubectl; манифесты Kubernetes работают как есть, переучиваться не нужно.- Починка образа — это
chgrp 0иchmod g=uна каталоги записи, база UBI ради записей в/etc/passwdи отсутствиеrunAsUserс конкретным номером в манифесте. - Политика по умолчанию запрещает и порты ниже 1024, и
hostPath,privileged,hostNetwork— манифесты и отладочные агенты из обычного кластера падают на создании пода. - Если UID в образе не изменить, платформенная команда выдаёт
nonroot-v2илиanyuidконкретной служебной записи на время; альтернатива — свой слой поверх чужого образа с правкой прав.
Что почитать дальше
- Kubernetes: основы — поды, деплойменты и сервисы, на которых стоит OpenShift.
- Деплой в Kubernetes — манифесты, Helm и rolling update.
- Spring Boot в Kubernetes — пробы и конфигурация сервиса в кластере.