
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 и тесно взаимодействуют друг с другом, что хорошо соответствует требованиям текущего проекта.
Кроме архитектурных изменений, рефакторинг дал и вполне практический эффект: заметно снизилось потребление памяти и процессорного времени. Изменение метрик хорошо видно на скриншоте ниже.
Но, пожалуй, не менее важный результат заключается в том, что архитектура теперь гораздо лучше соответствует реальным зонам ответственности компонентов.
И, конечно, остаётся отдельный бонус любого успешно завершённого рефакторинга: моральное удовлетворение от того, что технического долга стало немного меньше.