Azure Container Apps с несколькими контейнерами

Azure Container Apps с несколькими контейнерами

В этой статье рассмотрим реальный процесс трансформации приложения, в котором .NET Web API и Node.js изначально работали внутри одного Docker-контейнера, а затем были разделены на два контейнера в рамках одного Azure Container App.

Рассмотрим следующие вопросы:

  • устройство проекта при использовании одного контейнера;
  • трансформацию приложения;
  • инфраструктурные изменения;
  • взаимодействие контейнеров;
  • результат рефакторинга и изменение потребляемых ресурсов.

Устройство проекта при использовании одного контейнера

Изначально на инфраструктурном уровне приложение было устроено как один Docker-контейнер, внутри которого одновременно работали два сервиса:

  • .NET Web API;
  • Node.js-приложение, отвечающее за Server-Side Rendering Angular.

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

Эта задача была успешно решена. Но после стабилизации проекта пришло время устранить возникший технический долг и разделить контейнеры по зонам ответственности.

Поскольку приложению требовались сразу два процесса — .NET и Node.js, — для их запуска использовался отдельный shell-скрипт. Именно он являлся точкой входа Docker-контейнера и запускал оба процесса.

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

Трансформация проекта

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

Всё, что относится к Frontend и Angular SSR, было перенесено в отдельный Dockerfile. Backend-контейнер, в свою очередь, остался ответственным исключительно за Web API.

В результате зоны ответственности стали гораздо понятнее:

  • Backend-контейнер — .NET Web API;
  • Frontend-контейнер — Angular SSR на Node.js.

Dockerfile для Web API теперь выглядит следующим образом:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build

WORKDIR /src

COPY serdg-api/ ./

RUN dotnet restore "SD/SD.csproj"

RUN dotnet publish "SD/SD.csproj" \
    --configuration Release \
    --output /out/api \
    --no-restore \
    /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime

WORKDIR /app

COPY --from=build /out/api ./

EXPOSE 8080

ENTRYPOINT ["dotnet", "SD.dll"]

Frontend получил собственный Dockerfile. Его основная задача — запуск Angular Server-Side Rendering:

FROM node:22-bookworm-slim AS build

WORKDIR /src/ui

COPY serdg-ui/package*.json ./

RUN npm ci --legacy-peer-deps

COPY serdg-ui/ ./

RUN npm run build
RUN npm prune --omit=dev --legacy-peer-deps

FROM node:22-bookworm-slim AS runtime

WORKDIR /app/ui

ENV NODE_ENV=production
ENV PORT=4000
ENV HOST=0.0.0.0
ENV API_BASE_URL=http://127.0.0.1:8080

COPY --from=build /src/ui/dist ./dist
COPY --from=build /src/ui/node_modules ./node_modules
COPY --from=build /src/ui/package*.json ./

EXPOSE 4000

ENTRYPOINT ["node", "dist/serdg-ui/server/server.mjs"]

После внесения изменений остаётся убедиться, что оба Docker-образа корректно собираются и успешно публикуются в Azure Container Registry для дальнейшего развёртывания.

Инфраструктурные изменения

Изначально предполагалось, что после разделения Dockerfile основная часть работы уже выполнена и приложение практически сразу заработает в новой конфигурации.

Но здесь сработало известное правило:

Не так страшны первые 80% задачи, как вторые 80%.

Если изменение Dockerfile заняло всего несколько часов, то настройка деплоя и инфраструктуры потребовала ещё примерно столько же времени.

Необходимо было решить несколько задач:

  • добавить второй контейнер в Azure Container App и настроить для каждого контейнера необходимые параметры;
  • обновить Backend и Frontend так, чтобы каждый из них отвечал исключительно за свою область;
  • изменить маршрутизацию входящего HTTP-трафика;
  • настроить взаимодействие Frontend-контейнера с Backend-контейнером.

Одной из очень удобных возможностей Azure Container Apps является поддержка нескольких контейнеров в рамках одного Container App.

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

При этом каждый контейнер обладает собственной конфигурацией:

  • Docker-образом;
  • переменными окружения;
  • настройками CPU;
  • объёмом памяти;
  • командой запуска.

В то же время жизненный цикл контейнеров связан. Они входят в одну ревизию Azure Container App, запускаются вместе и масштабируются как единое приложение.

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

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

Именно поэтому Frontend-контейнер может обращаться к API следующим образом:

ENV API_BASE_URL=http://127.0.0.1:8080

Backend при этом не требуется отдельно публиковать наружу.

Изменение ответственности Backend

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

Раньше .NET-приложение не только предоставляло API, но и участвовало в выдаче Frontend.

Например, использовался fallback:

app.MapFallbackToFile("index.html");

После перехода к отдельному Frontend-контейнеру эта логика больше не нужна.

Backend становится именно Backend: его задача — предоставление API, работа с данными, серверными ресурсами и остальной серверной логикой.

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

Frontend как единая точка входа

После разделения контейнеров все внешние HTTP-запросы сначала попадают в Node.js-приложение.

Именно Frontend-контейнер теперь является точкой входа для Azure Container Apps ingress.

Обычные пользовательские запросы обрабатываются Angular SSR. Но запросы к API и некоторым серверным ресурсам необходимо перенаправлять во второй контейнер.

Для этого потребовалась небольшая доработка server.ts.

Список маршрутов, которые должны проксироваться в Backend, выглядит следующим образом:

const proxiedRequestPaths = [
  '/api',
  '/documents',
  '/images',
  '/mnt',
];

/**
 * Proxy API-related requests to the API container.
 */
for (const requestPath of proxiedRequestPaths) {
  app.use(requestPath, proxyToApi);
}

В итоге схема обработки запросов стала выглядеть примерно так:

Internet
   │
   ▼
Azure Container Apps Ingress
   │
   ▼
Node.js / Angular SSR :4000
   │
   ├── Angular pages
   │
   └── /api, /documents, /images, /mnt
           │
           ▼
      .NET Web API :8080

Таким образом, внешний ingress требуется только для Frontend-контейнера.

Backend-контейнер доступен Frontend через внутреннее сетевое пространство Azure Container App и напрямую из интернета не публикуется.

Результат

После рефакторинга архитектура стала заметно чище.

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

Azure Container App
│
├── Frontend container
│   └── Node.js + Angular SSR :4000
│
└── Backend container
    └── .NET Web API :8080

Это позволило избавиться от вспомогательного скрипта запуска двух процессов, упростить Dockerfile и сделать архитектуру приложения более очевидной.

При этом оба контейнера по-прежнему остаются частью одного Azure Container App и тесно взаимодействуют друг с другом, что хорошо соответствует требованиям текущего проекта.

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

Метрики потребления CPU и памяти Azure Container App до и после разделения контейнеров
Изменение потребления CPU и памяти после перехода к нескольким контейнерам.

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

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