← назад к разделу

В enterprise-компаниях вместо ванильного Kubernetes часто стоит OpenShift — и разработчик, привыкший к kubectl и Ingress, попадает в мир oc, Routes и отказов «pod не может стартовать под root», причина которых снаружи не видна. Разберём, что OpenShift добавляет поверх Kubernetes и что из этого реально касается вас.

Разница видна на одном сценарии: один и тот же манифест и один и тот же образ уезжают в оба кластера.

Deployment + Service + ConfigMapKubernetesOpenShift SCC нет: UID берётся из образапроцесс идёт под UID 1000статус: Runningпишет в /app/work: owner 1000, 755UID процесса = владелец каталогастатус: Running SCC restricted-v2: root запрещёнUID подменён: 1000680000группа процесса: 0 тот же каталог: owner 1000, 755Permission deniedстатус: CrashLoopBackOff внешний доступ: Ingressвнешний доступ: Route (HAProxy) починка: chgrp 0 /app/work && chmod g=u /app/workзапись разрешена группе 0 — старт под любым UID Kubernetes переносится почти целикомломается не манифест, а образ: UID и права каталога

Манифест переезжает в 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 и веб-консоль. CLI oc — надмножество kubectl (всё то же плюс OpenShift-специфика: oc new-app, oc login). Плюс развитая графическая консоль для деплоя и диагностики.
  • Projects вместо namespace. «Проект» в OpenShift — это namespace с дополнительными политиками доступа поверх.
репозиторий исходники сервиса BuildConfig сборка образа ImageStream тег-указатель Deployment выкатка по триггеру

Путь от репозитория до работающего пода без своего 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   # то же быстро
ванильный k8s клиент HTTPS Ingress-контроллер Service под HTTP OpenShift клиент HTTPS HAProxy-роутер Service под HTTP

Путь снаружи один и тот же, меняется только объект на входе: 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 конкретной служебной записи на время; альтернатива — свой слой поверх чужого образа с правкой прав.

Что почитать дальше