Содержание
Сборка .NET-образа в GitHub Actions без кэша занимает 5-7 минут. С правильно настроенным BuildKit-кэшем — 1-1.5. Разница — экономия часов в неделю на CI и более быстрый feedback на PR.
Почему обычный Docker cache не работает в Actions
Каждый job на GitHub Actions запускается на чистой VM — слои образов из предыдущей сборки не сохраняются между ранами. docker build работает «как будто впервые» — скачивает базовый образ, восстанавливает все NuGet-пакеты, пересобирает с нуля.
Стандартный actions/cache спасёт ~/.nuget/packages, но сборку внутри Docker не ускорит — кэш живёт на хосте, а в контейнере его нет.
Решение: BuildKit умеет экспортировать слои образа во внешнее хранилище и подтягивать их перед сборкой. С версии 0.10+ есть бэкенд gha — кэш живёт в нативном GitHub Actions cache (10 GB, бесплатно).
Полный workflow для .NET
name: Build & Push
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
Ключевые строки — cache-from: type=gha и cache-to: type=gha,mode=max. Первый раз кэш пустой, сборка идёт полностью. Все последующие — BuildKit сравнивает слои с кэшем и переиспользует неизменённые.
mode=max экспортирует все промежуточные слои, не только финальные — для multi-stage критично.
Dockerfile, который реально кэшируется
Главное правило — слои с редко меняющимися данными должны быть до слоёв с часто меняющимися (подробно о слоях и кэшировании — в статье про Dockerfile). У большинства .NET-проектов так:
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# 1. Копируем только .csproj — слой инвалидируется только при
# изменении зависимостей (а не на каждый правленный .cs)
COPY *.sln .
COPY src/Api/Api.csproj src/Api/
COPY src/Domain/Domain.csproj src/Domain/
# 2. Restore — самый долгий шаг (~1 мин). Кэшируется до изменения .csproj
RUN dotnet restore --no-cache
# 3. Копируем остальной код — этот слой инвалидируется на каждом коммите,
# но restore выше уже закэширован
COPY . .
# 4. Build + publish
RUN dotnet publish src/Api/Api.csproj \
-c Release \
-o /app/publish \
--no-restore
FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app/publish .
USER 1000
ENTRYPOINT ["dotnet", "Api.dll"]
Если ты копируешь COPY . . в начале — каждое изменение .cs файла инвалидирует слой restore. Все NuGet-пакеты будут скачиваться заново. На .NET-проекте это +2-3 минуты на каждый билд.
Реальные цифры
Проект — Api на .NET 9, ~30 NuGet-пакетов, ~150 .cs файлов:
| Билд | Время | Что кэшировано |
|---|---|---|
| Первый (cache miss) | 5:42 | Ничего |
| Изменение README | 0:48 | Всё (cache hit на финальный слой) |
Изменение .cs файла |
1:24 | Restore + base image |
| Добавление NuGet-пакета | 3:12 | Только base image |
| Обновление .NET SDK base image | 5:30 | Почти ничего |
Среднее по реальным PR — ~1:30. Это в 4 раза быстрее некэшированной сборки.
Подводные камни
10 GB лимит и eviction
GitHub Actions cache имеет жёсткий лимит 10 GB на репозиторий. Если превысил — старые ключи вытесняются по LRU. Multi-stage с тяжёлыми слоями (~1-2 GB) может «забивать» кэш. Чисти ручками через gh cache delete или ожидай естественной очистки.
mode=max vs mode=min
min— кэширует только финальные слои финального этапа multi-stage. Маленький, но если меняешь Dockerfile — почти всё пересобирается.max— кэширует все промежуточные слои. Больше места, но восстановление с любой точки.
Для .NET — всегда max. Для микро-Dockerfile из 3 строк — min ок.
Cache miss из-за .dockerignore
Если .dockerignore некорректный, в context попадают файлы, которые меняются часто (например, bin/, obj/). COPY . . инвалидируется на каждом билде. Минимальный .dockerignore:
bin/
obj/
.git/
.github/
.vs/
*.user
**/node_modules/
Сделай docker build --progress=plain . локально и проверь, какие файлы реально попадают в context.
Если меняешь base image FROM (например, обновил .NET SDK с 9.0.0 до 9.0.1) — кэш ровно ноль. Это нормально. Не пытайся «зафиксировать» через digest без причины — пропустишь security-патчи.
Параллельные джобы и race condition
Если два PR одновременно пушат в кэш одного и того же ключа — последний выигрывает. Не критично, но иногда вылезает в виде «у меня закэшировалось, у коллеги нет». Для shared-кэша используй scope:
cache-from: type=gha,scope=${{ github.ref_name }}
cache-to: type=gha,mode=max,scope=${{ github.ref_name }}
Каждая ветка получает свой кэш.
Альтернативы — registry cache
Если 10 GB GHA cache мало, кэш можно сложить в OCI-registry (тот же GHCR):
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
Плюсы — нет лимита размера, доступен из других репо. Минусы — медленнее (push/pull через сеть), занимает место в registry.
Итого
- BuildKit cache
type=ghaускоряет .NET-билды в 3-4 раза, бесплатно. - Структура Dockerfile решает:
.csprojсначала →restore→ код →build. mode=maxдля multi-stage — обязательно.- Минимальный
.dockerignore— без негоCOPY . .инвалидируется на пустом месте. - 10 GB лимит — учитывай при больших проектах, scope по веткам помогает.
- Изменил base image — кэш сброшен, это норма.
Обсуждение
Пока нет комментариев. Будьте первым.