.NET 11 RC 1 делает образы контейнеров из dotnet publish воспроизводимыми
SDK .NET 11 RC 1 учитывает SOURCE_DATE_EPOCH при публикации образов контейнеров, убирает идентификатор процесса из tar-заголовков слоёв и пропускает загрузку blob-объектов, если манифест уже есть в реестре. Один и тот же коммит на входе, один и тот же digest на выходе.
Опубликуйте один и тот же коммит дважды через dotnet publish /t:PublishContainer на .NET 10, и вы получите два разных digest образа. Код идентичен, но SDK записывал текущее время в каждую запись слоя и в конфигурацию образа. Кроме того, он записывал идентификатор процесса в tar каждого слоя. Реестр не может такое дедуплицировать, а GitOps-контроллер, следящий за тегом, видит новый релиз. .NET 11 RC 1, вышедший 2026-09-08, исправляет обе проблемы во встроенных в SDK инструментах для контейнеров (dotnet/sdk#55836).
Четыре места, где часы просачивались в digest
Исходный PR перечисляет их:
- Каждый
PaxTarEntryв слое по умолчанию получал время изменения изDateTime.UtcNow, которое считывалось отдельно для каждого файла. - Конфигурация образа считывала
DateTime.UtcNowдляcreatedи ещё раз для сгенерированной записи истории. - Метки
org.opencontainers.image.createdиorg.opencontainers.artifact.createdбрались изUtcNowв файле targets. TarWriterназывает каждый расширенный заголовок pax./PaxHeaders.<process id>/., поэтому pid попадал в каждый слой.
Одного pid хватало, чтобы изменить digest слоя при побайтно идентичном содержимом. Различались только 13 байт слоя, и все они были вызваны этим именем заголовка.
Включение через SOURCE_DATE_EPOCH
RC 1 следует соглашению reproducible-builds. Свойство MSBuild SOURCE_DATE_EPOCH или одноимённая переменная окружения, которую MSBuild подхватывает автоматически, разбирается один раз в единую метку времени. Затем это значение попадает в каждую tar-запись, в поле created конфигурации, в запись истории и в обе метки OCI:
dotnet publish -c Release -r linux-x64 /t:PublishContainer \
-p:ContainerRegistry=registry.example.com \
-p:SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct)"
Если использовать метку времени коммита, digest меняется только тогда, когда меняется коммит. Я проверил это на SDK RC 1 (11.0.100-rc.1.26425.128), трижды опубликовав консольное приложение в tar-архив:
pub() { dotnet publish -c Release -r linux-x64 /t:PublishContainer \
-p:ContainerArchiveOutputPath=./$1.tar.gz "${@:2}"; }
pub a -p:SOURCE_DATE_EPOCH=1757635200 # config dc51dd30..., app layer 47af118e...
pub b -p:SOURCE_DATE_EPOCH=1757635200 # config dc51dd30..., app layer 47af118e...
pub c # config 5f24aace..., app layer 2f4f68c1...
Запуски a и b побайтно идентичны, а поле created конфигурации содержит 2025-09-12T00:00:00.0000000Z. Запуск c по-прежнему получает системное время, так что воспроизводимость включается явно. Если значение некорректно, отрицательно или выходит за допустимый диапазон, SDK откатывается к текущему времени. Сборка при этом не падает.
Два побочных эффекта, о которых стоит знать до обновления. Запись слоёв теперь сортирует элементы по пути в контейнере, потому что порядок перечисления каталогов зависит от файловой системы. Имя заголовка pax всегда равно константе ./PaxHeaders/.. Оба изменения действуют при каждой публикации, даже без SOURCE_DATE_EPOCH, поэтому ваши digest один раз сдвинутся при переходе на RC 1.
Пропуск загрузок, которые уже есть в реестре
Сопутствующее изменение (dotnet/sdk#55838) опирается на это. Перед отправкой SDK выполняет запрос HEAD для вычисленного digest манифеста. Если манифест уже есть в реестре, SDK пропускает загрузку слоёв и конфигурации, всё равно применяет все запрошенные теги и записывает в журнал Manifest '...' already exists in repository '...'. Повторный запуск CI-задания или второй тег на неизменённом коммите сводятся к нескольким вызовам для метаданных.
В targets RC 1 это включено по умолчанию: ContainerPushNoCache по умолчанию равно false. Если реестр неверно сообщает о наличии манифеста, отключите проверку:
dotnet publish /t:PublishContainer -p:ContainerPushNoCache=true
Образ всё равно собирается локально, так как сначала нужно вычислить digest, поэтому экономится передача данных, а не время сборки. Кроме того, выигрыш есть только при стабильном digest, и именно поэтому оба PR вошли вместе.
Ещё одно изменение RC 1, которое убирает давний обходной путь, описано в статье сигналы и код завершения в Process. Полный список изменений SDK есть в примечаниях к выпуску SDK RC 1.
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.