Многоэтапный Dockerfile для .NET: собираем один раз, запускаем минимум

Published: 2026-03-08

Приложение .NET, скомпилированное в контейнере с полным SDK, даёт образ весом более 800 МБ. То же приложение в многоэтапной сборке с runtime-образом занимает менее 200 МБ и не содержит компилятора, SDK или инструментов сборки в продакшне.


Паттерн

Многоэтапные сборки используют несколько инструкций FROM. Каждый этап может копировать файлы из предыдущего. В финальный образ попадает только последний этап.

dockerfile# Этап 1: восстановление зависимостей (кэшируется отдельно)
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src
COPY MyApp.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
RUN dotnet restore src/MyApp/MyApp.csproj

# Этап 2: сборка и публикация
FROM restore AS build
COPY src/ src/
RUN dotnet publish src/MyApp/MyApp.csproj \
    -c Release \
    -o /app/publish \
    --no-restore

# Этап 3: runtime-образ (финальный)
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app

RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser

COPY --from=build /app/publish .
RUN chown -R appuser:appgroup /app

USER appuser

EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "MyApp.dll"]

Зачем отделять restore от build

COPY .csproj + dotnet restore — оптимизация кэша. Docker кэширует каждый слой. Если изменился только код приложения (не .csproj), restore пропускается при пересборке.

Без этого разделения:

dockerfile# НЕПРАВИЛЬНО: каждое изменение кода инвалидирует кэш restore
FROM sdk AS build
COPY . .
RUN dotnet restore && dotnet publish

Изменения зависимостей (редкие) вызывают restore. Изменения кода (частые) затрагивают только слой сборки.


Какой базовый образ использовать

Образ Размер Применение
dotnet/sdk:8.0 ~750 МБ Только для этапа сборки
dotnet/aspnet:8.0 ~215 МБ Runtime для ASP.NET-приложений
dotnet/runtime:8.0 ~190 МБ Runtime для не-веб-приложений
dotnet/runtime-deps:8.0 ~95 МБ Self-contained trimmed

Для веб-приложений используйте aspnet (не runtime). runtime не содержит библиотек ASP.NET — приложение не запустится.


Пользователь без root

dockerfileRUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 --ingroup appgroup appuser
USER appuser

Закрепите это и на уровне Kubernetes через securityContext:

yamlspec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1001
    fsGroup: 1001
  containers:
    - securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: [ALL]

С readOnlyRootFilesystem: true приложение может писать только в явно смонтированные тома. Если нужен /tmp:

yamlvolumeMounts:
  - name: tmp
    mountPath: /tmp
volumes:
  - name: tmp
    emptyDir: {}

Решение с несколькими проектами

dockerfileFROM mcr.microsoft.com/dotnet/sdk:8.0 AS restore
WORKDIR /src
COPY MyApp.sln .
COPY src/MyApp/MyApp.csproj src/MyApp/
COPY src/MyApp.Core/MyApp.Core.csproj src/MyApp.Core/
COPY src/MyApp.Infrastructure/MyApp.Infrastructure.csproj src/MyApp.Infrastructure/
RUN dotnet restore src/MyApp/MyApp.csproj

FROM restore AS build
COPY src/ src/
RUN dotnet publish src/MyApp/MyApp.csproj -c Release -o /app/publish --no-restore

Скопируйте все .csproj до исходного кода — изменение .csproj вызывает restore, изменение кода его пропускает.


Self-contained trimmed публикация

dockerfileFROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
COPY . .
RUN dotnet publish src/MyApp/MyApp.csproj \
    -c Release \
    -r linux-x64 \
    --self-contained true \
    -p:PublishTrimmed=true \
    -o /app/publish

FROM debian:bookworm-slim AS runtime
WORKDIR /app
COPY --from=build /app/publish .
RUN adduser --system --uid 1001 appuser && chown -R appuser /app
USER appuser
ENTRYPOINT ["./MyApp"]

Размер образа — ~80 МБ, старт быстрее (нет прогрева JIT). Минус: trimming может сломать код с рефлексией (EF Core, AutoMapper). Тщательно тестируйте.


BuildKit и кэш слоёв в CI

yamlbuild:
  variables:
    DOCKER_BUILDKIT: "1"
  script:
    - |
      docker buildx build \
        --cache-from type=registry,ref=${REGISTRY}/myapp:cache \
        --cache-to type=registry,ref=${REGISTRY}/myapp:cache,mode=max \
        --platform linux/amd64 \
        --tag ${REGISTRY}/myapp:${CI_COMMIT_SHORT_SHA} \
        --push .

mode=max кэширует все промежуточные слои. На чистом раннере это сокращает время сборки .NET с 4–5 минут до менее 1 минуты при изменениях только кода.