Решение: Couldn't find a valid ICU package installed on the system в контейнере .NET
В базовом образе нет ICU. Установите icu-libs и icu-data-full, перейдите на вариант образа -extra или включите InvariantGlobalization=true и примите порядковое поведение строк.
Базовый образ вашего контейнера не содержит ICU, и .NET отказывается запускаться без него. Выберите один из двух ответов. Если приложение форматирует даты, сравнивает строки лингвистически или обращается к любой культуре, кроме инвариантной, установите ICU: RUN apk add --no-cache icu-libs icu-data-full в Alpine либо перейдите на вариант образа -extra, где ICU уже есть. Если приложению действительно никогда не нужны данные культур, укажите <InvariantGlobalization>true</InvariantGlobalization> в файле проекта и оставьте маленький образ. Не полагайтесь на одну лишь переменную окружения: это самый слабый из трёх переключателей.
Process terminated. Couldn't find a valid ICU package installed on the system.
Please install libicu (or icu-libs) using your package manager and try again.
Alternatively you can set the configuration flag System.Globalization.Invariant
to true if you want to run with no globalization support. Please see
https://aka.ms/dotnet-missing-libicu for more information.
Всё, что описано ниже, проверено на .NET 10 (10.0, выпуск 2025-11-11) и предварительных версиях .NET 11. Механизм не менялся с .NET 5, поэтому те же решения без изменений подходят для образов net8.0 и net9.0. Меняются только имена пакетов и теги образов.
Почему среда выполнения убивает процесс, а не деградирует
Стек глобализации .NET в Unix представляет собой тонкую прослойку над ICU (International Components for Unicode). Данные культур, лингвистическое сравнение строк, правила регистра за пределами ASCII, форматирование календарей, обработка IDN: всё это приходит из libicuuc и libicui18n, которые не входят в состав .NET. Это нативная зависимость, которую должен предоставить базовый образ.
При запуске статический конструктор GlobalizationMode проходит по фиксированному списку решений:
- Включён ли режим инвариантной глобализации? Если да, ICU полностью пропускается и используются встроенные инвариантные данные.
- Настроен ли ICU, локальный для приложения? Если да, загружаются
libicuuc.so.<version>иlibicui18n.so.<version>из каталога приложения. - Задана ли
DOTNET_ICU_VERSION_OVERRIDE? Если да, делается попытка загрузить именно эту версию. - Иначе загружается самая старшая версия ICU, установленная в системе.
Если шаг 4 ничего не находит, среда выполнения вызывает Environment.FailFast. Именно эта деталь сбивает людей с толку: это не исключение. Никакой try/catch вас не спасёт, никакой обработчик AppDomain.UnhandledException, никакого аккуратного отката в инвариантный режим. Процесс завершается ещё до того, как Main реально начнёт работу, что в Linux проявляется как SIGABRT и код выхода контейнера 134. Это сделано намеренно: молчаливый откат к порядковому сравнению строк изменил бы сортировку, регистр и разбор дат так, что вместо громкой ошибки вы получили бы неверные данные.
Чаще всего с этим сталкиваются как раз те образы, которые вы выбрали именно за компактность. Alpine, Azure Linux distroless и Ubuntu chiseled не содержат ICU и tzdata, и документация по контейнерам .NET прямо говорит, что эти образы работают только с приложениями, настроенными на режим инвариантной глобализации. Полные образы Debian и Ubuntu уже содержат ICU, поэтому приложение работало на вашей машине и в образе sdk и умерло в момент попадания в стадию runtime.
Минимальное воспроизведение
Две стадии, обычная сборка через SDK, Alpine в качестве среды выполнения. Достаточно такого Dockerfile:
# .NET 10. Fails at startup with the ICU error.
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Само приложение не обязано делать ничего экзотического. Сбой происходит во время инициализации среды выполнения, до запуска вашего кода, поэтому падает даже такое:
// .NET 10, C# 14. Never reaches the WriteLine.
Console.WriteLine("hello");
Это стоит усвоить, потому что первый инстинкт таков: искать вызов CultureInfo, который всё сломал. Его нет. Инициализация глобализации выполняется заранее.
Решение 1: установить ICU в образ
Это правильное решение для большинства приложений и именно то, что описано в примерах контейнеров .NET. В Alpine:
# .NET 10 on Alpine 3.22. Adds ICU and disables invariant mode.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine
RUN apk add --no-cache icu-libs icu-data-full
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false \
LC_ALL=en_US.UTF-8 \
LANG=en_US.UTF-8
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApi.dll"]
icu-data-full не является опциональным балластом. Начиная с Alpine 3.16 пакет данных ICU разделили, и icu-libs сам по себе содержит только локаль en, что порождает куда более запутанный сбой, чем исходный: среда выполнения стартует нормально, а затем все культуры, кроме английской, молча форматируются как английская. Тесты, проверяющие форматы дат fr-FR, начинают падать вообще без сообщения об ошибке. Ставьте оба пакета.
Строка DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false важна только в том случае, если что-то выше по цепочке установило значение true, а так поступают несколько базовых образов и шаблонов CI. Задать её явно ничего не стоит, зато убирает целый класс ошибок с унаследованным окружением.
Эквивалент для образов на базе Debian или Ubuntu, который понадобится только для собранного вами образа runtime-deps:
# .NET 10 on Ubuntu 24.04 (noble).
RUN apt-get update \
&& apt-get install -y --no-install-recommends libicu74 tzdata \
&& rm -rf /var/lib/apt/lists/*
Закрепите имя пакета libicu за тем, которое реально есть в вашем выпуске дистрибутива (libicu74 в Ubuntu 24.04, libicu72 в Debian bookworm). Если следить за этим не хочется, apt-get install -y libicu-dev транзитивно подтянет нужную библиотеку ценой более крупного слоя.
Решение 2: перейти на вариант образа -extra
Microsoft публикует оптимизированные по размеру образы в трёх вариантах, и суффикс -extra означает ровно “маленький образ плюс ICU, tzdata и libstdc++”. Если вы на chiseled или Azure Linux, это одна строка вместо установки пакетов:
# .NET 10, Ubuntu chiseled with globalization support.
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled-extra
WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "MyApi.dll"]
Есть асимметрия доступности, о которой стоит знать заранее. Для Ubuntu chiseled и Azure Linux -extra существует в репозиториях runtime-deps, runtime и aspnet. Для Alpine -extra публикуется только в runtime-deps, а значит, использовать его можно лишь с самодостаточной (self-contained) публикацией или Native AOT. Приложению на Alpine, зависящему от фреймворка, придётся ставить пакеты вручную, как в решении 1.
Если вы собираете образы встроенными средствами SDK, а не Dockerfile, выбирайте вариант через ContainerFamily, а не через строку FROM:
<!-- .NET 10 SDK. Applies to dotnet publish /t:PublishContainer. -->
<PropertyGroup>
<ContainerFamily>noble-chiseled-extra</ContainerFamily>
</PropertyGroup>
Это встраивается в тот же процесс, который описан в статье про публикацию приложения .NET как образа контейнера с PublishContainer, и оставляет выбор базового образа в файле проекта, где живёт остальная конфигурация публикации.
Решение 3: осознанно включить инвариантную глобализацию
Если приложение действительно не зависит от культуры (классический случай: внутренний API, обменивающийся метками времени ISO-8601 и числами в инвариантном формате), то инвариантный режим является не костылём, а правильной конфигурацией. Он полностью снимает зависимость и даёт меньший образ и более быстрый старт.
<!-- .NET 10, C# 14. -->
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
Задавайте это в файле проекта, а не в Dockerfile. Согласно проектному документу среды выполнения о режиме инвариантной глобализации, значения из файла проекта и runtimeconfig.json имеют приоритет над DOTNET_SYSTEM_GLOBALIZATION_INVARIANT, поэтому свойство MSBuild всегда выигрывает, а переменная окружения молча проигрывает. Файл проекта к тому же путешествует вместе с приложением: никто не сможет переложить ваш контейнер в другой оркестратор, забыть блок переменных окружения и воскресить сбой.
Понимайте, на что вы соглашаетесь. В инвариантном режиме:
ToUpperиToLowerпреобразуют только диапазон ASCII. Турецкие правила для I с точкой и без точки исчезают.String.Compare,IndexOfиLastIndexOfвыполняют порядковое сравнение независимо от переданныхCompareOptionsилиStringComparison. Лингвистическая сортировка молча превращается в побайтовую.String.Normalizeвозвращает строку без изменений.- Отображаемые имена часовых поясов в Linux откатываются к стандартному имени вместо локализованного имени из ICU.
TimeZoneInfo.TryConvertIanaIdToWindowsIdи обратный метод завершаются неудачей, поскольку опираются на ICU.- Перечисление культур возвращает ровно одну культуру, а все LCID схлопываются в
0x1000.
На практике сильнее всего бьёт по рукам создание культур. Начиная с .NET 6 в инвариантном режиме PredefinedCulturesOnly по умолчанию равно true, поэтому new CultureInfo("fr-FR") выбрасывает:
System.Globalization.CultureNotFoundException: Only the invariant culture is supported
in globalization-invariant mode.
Если создание должно проходить успешно (middleware локализации запросов, разбирающая Accept-Language, делает это даже тогда, когда результат вам не нужен), правило можно ослабить:
<!-- .NET 10. Cultures can be created, but all behave as invariant. -->
<PropertyGroup>
<InvariantGlobalization>true</InvariantGlobalization>
<PredefinedCulturesOnly>false</PredefinedCulturesOnly>
</PropertyGroup>
Исключение прекратится. Культурно-зависимое поведение при этом не вернётся: каждая созданная культура ведёт себя ровно как инвариантная. 1234.56m.ToString("C", new CultureInfo("de-DE")) по-прежнему вернёт инвариантную денежную форму с обобщённым знаком валюты, а не сумму в евро в немецком формате. Если считать эту пару настроек “решением” для по-настоящему локализованного приложения, вы гарантированно выпустите приложение, вывод которого неверен везде, кроме en-US.
Решение 4: везти ICU с собой (app-local ICU)
Нишевый, но законный вариант: зафиксировать конкретную версию ICU и поставлять её вместе с приложением, чтобы поведение было побайтово одинаковым на любом хосте. Смена версии ICU меняет данные CLDR, а данные CLDR меняют порядок сортировки и форматирование, так что приложение с эталонными тестами по форматированному выводу можно дестабилизировать обновлением базового образа, о котором оно не просило.
<!-- .NET 10. Ships ICU 72.1 with the app instead of using the system copy. -->
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Globalization.AppLocalIcu" Value="72.1" />
<PackageReference Include="Microsoft.ICU.ICU4C.Runtime" Version="72.1.0.3" />
</ItemGroup>
С включённым переключателем .NET загружает libicuuc.so.72.1 и libicui18n.so.72.1 из нативных путей поиска приложения и никогда не смотрит на системную копию. Соответствующая переменная окружения называется DOTNET_SYSTEM_GLOBALIZATION_APPLOCALICU, а формат значения имеет вид <version> или <suffix>:<version>, где суффикс соответствует пользовательской сборке ICU. Если библиотек нет, вы получите другой, более конкретный сбой: Failed to load app-local ICU: <library name>. Согласуйте версию в PackageReference со значением переключателя, иначе увидите именно это.
Ловушки, ведущие к неправильному решению
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=false в Dockerfile ничего не дал. Проверьте файл проекта. Если <InvariantGlobalization>true</InvariantGlobalization> задано там или в runtimeconfig.json, приоритет за ним, а ваша переменная окружения бесполезна. Пройдитесь grep по всему решению, включая Directory.Build.props, где часто живёт благонамеренная оптимизация размера.
Failed to load system ICU: libicuuc.so.<n> вместо приведённого выше сообщения. Это другая ветка. Она означает, что ICU нашёлся при переборе версий, но конкретный soname не удалось загрузить, обычно из-за неполной установки или несовпадения архитектур (слой amd64 под эмуляцией arm64). Проверьте командой ldconfig -p | grep icu внутри контейнера.
Ошибка появляется только в публикациях Native AOT или с обрезкой. Тогда дело, скорее всего, вовсе не в образе. PublishAot и PublishTrimmed взаимодействуют с переключателями возможностей, и InvariantGlobalization входит в число тех, что часто включают ради размера в шаблонах AOT. Тот же класс проблем “SDK переключил флаг за вашей спиной” разобран в статье о том, почему отключается сериализация на основе рефлексии, и в более широком разборе trim-safe кода.
Даты форматируются правильно, а часовые пояса не разрешаются. ICU и tzdata поставляются разными пакетами. TimeZoneInfo.FindSystemTimeZoneById читает /usr/share/zoneinfo, который оптимизированные по размеру образы тоже опускают. Установите tzdata рядом с icu-libs либо используйте вариант -extra, включающий оба.
Работает всё, кроме культурно-зависимых тестов. Вы установили icu-libs без icu-data-full в Alpine. Присутствуют только данные en.
Образ SDK работает, а образ runtime нет. Так и должно быть. Образы sdk по умолчанию основаны на Debian и несут ICU; зависимость нужна вашей финальной стадии aspnet или runtime. Диагностируйте внутри реального слоя выполнения, а не слоя сборки.
Чтобы подтвердить, в каком режиме вы оказались, не гадая:
// .NET 10, C# 14. Prints 1 in invariant mode, several hundred with ICU loaded.
using System.Globalization;
Console.WriteLine(CultureInfo.GetCultures(CultureTypes.AllCultures).Length);
Console.WriteLine(AppContext.TryGetSwitch("System.Globalization.Invariant", out bool inv) && inv);
Связанные материалы
- Как опубликовать приложение .NET 11 как образ контейнера с помощью dotnet publish /t:PublishContainer
- Что такое Native AOT и чего он вам стоит?
- Fix: PlatformNotSupportedException: Operation is not supported on this platform в Native AOT
- Что такое trim-safe код и как его писать?
- Как сократить время холодного старта AWS Lambda на .NET 11
Источники
- Режим инвариантной глобализации .NET, про список изменений поведения и приоритет настроек - dotnet/runtime
GlobalizationMode.Unix.cs, про порядок загрузки иFailFastпри отсутствии ICU - dotnet/runtime- Параметры конфигурации глобализации - MS Learn
- Глобализация .NET и ICU, про app-local ICU и последовательность поиска в Linux - MS Learn
- Включение глобализации в образах контейнеров .NET - dotnet/dotnet-docker
- Варианты образов .NET, про то, какие репозитории публикуют
-extra- dotnet/dotnet-docker - Образы контейнеров .NET - MS Learn
- Установка .NET в Alpine, про список зависимостей, включая
icu-data-full- MS Learn - Alpine 3.16 icu-libs теперь содержит только en - dotnet/dotnet-docker
- Создание культур и сопоставление регистра в режиме инвариантной глобализации - MS Learn
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.