RE:NODE

Приложения11 мин чтения

Конфигурация и секреты в ASP.NET Core: от ноутбука до сервера

Как устроены слои конфигурации ASP.NET Core: appsettings.json, окружения, переменные окружения с __, user-secrets, паттерн options и проверка при запуске.

0 прочтений

ASP.NET Core читает конфигурацию из стопки источников, и побеждает тот, что задал ключ последним: appsettings.json, затем appsettings.{Environment}.json, затем user secrets (только в Development), затем переменные окружения, затем аргументы командной строки. На вашем ноутбуке секреты берутся из user secrets. На сервере - из переменных окружения, записанных через двойное подчёркивание там, где в JSON была вложенность: ConnectionStrings__Default, Smtp__Password. Привяжите каждую секцию к типизированному классу через паттерн options, проверьте её при запуске приложения, и отсутствующий пароль превратится в понятную ошибку в первую же секунду, а не в null reference на первом запросе.

Вот и вся модель. Остальная часть статьи - детали, от которых зависит, будет ли она работать: как называются ключи, какой интерфейс перечитывает изменения, что на самом деле представляют собой user secrets, и ошибки, из-за которых продакшен-приложение работает на настройках для разработки.

Откуда берётся конфигурация#

WebApplication.CreateBuilder(args) настраивает источники по умолчанию за вас. По порядку, от низшего приоритета к высшему:

ПорядокИсточникКогда загружается
1appsettings.jsonВсегда, если есть
2appsettings.{Environment}.jsonВсегда, если есть
3User secretsОкружение - Development
4Переменные окруженияВсегда
5Аргументы командной строкиВсегда

Каждый источник вносит плоский набор ключей. Когда два источника задают один и тот же ключ, побеждает более поздний; ключи, которые задаёт только один источник, остаются нетронутыми. Именно это делает слои полезными: в appsettings.json лежит каждая настройка с безопасным значением по умолчанию, файл окружения меняет несколько из них, а переменные окружения на сервере поставляют те немногие, что секретны или специфичны для этой машины.

Это же означает, что устаревшее значение может прятаться. Переменная окружения, заданная на сервере несколько месяцев назад, переопределяет всё, что вы меняете в appsettings.Production.json сегодня, и ничто вам об этом не сообщит. Когда настройка отказывается меняться, сначала смотрите на верхние слои.

Ключи, секции и двойное подчёркивание#

Конфигурация - это дерево, развёрнутое в ключи, соединённые двоеточиями. Такой JSON:

appsettings.json
{  "ConnectionStrings": {    "Default": "Host=localhost;Database=shop;Username=shop;Password=dev"  },  "Smtp": {    "Host": "smtp.example.com",    "Port": 587,    "From": "shop@example.com"  },  "Cors": {    "Origins": [ "https://example.com", "https://www.example.com" ]  },  "Logging": {    "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }  }}

даёт ключи вроде Smtp:Host, Cors:Origins:0 и Logging:LogLevel:Default. Массивы становятся пронумерованными дочерними элементами. Ключи нечувствительны к регистру, так что smtp:host читает то же значение.

Имена переменных окружения не могут переносимо содержать двоеточие, поэтому провайдер окружения принимает вместо него двойное подчёркивание и переводит его:

env
ConnectionStrings__Default=Host=203.0.113.10;Port=5432;Database=shop;Username=shop;Password=...Smtp__Password=...Cors__Origins__0=https://example.comCors__Origins__1=https://www.example.comLogging__LogLevel__Default=Warning

На двух деталях здесь люди спотыкаются. Массив, заданный переменными окружения, заменяет элементы по индексу, а не весь массив целиком, так что если в JSON три origin, а окружение задаёт __0 и __1, третий из JSON никуда не девается. А одинарное подчёркивание - просто символ: Smtp_Password - это другой ключ, чем Smtp:Password, и всё, что читает последний, молча его игнорирует.

Прямое чтение значений подходит для единичных случаев:

csharp
var connection = builder.Configuration.GetConnectionString("Default");var smtpHost = builder.Configuration["Smtp:Host"];var port = builder.Configuration.GetValue<int>("Smtp:Port", 587);

GetConnectionString("Default") - сокращение для Configuration["ConnectionStrings:Default"]. Для всего, где настроек больше одной, используйте паттерн options, описанный ниже, а не разбрасывайте строковые ключи по коду.

Окружения и файлы для каждого окружения#

Имя окружения берётся из ASPNETCORE_ENVIRONMENT или, если она не задана, из DOTNET_ENVIRONMENT, а если не задана ни одна, по умолчанию это Production. Шаблоны задают Development в launchSettings.json, который используют dotnet run и Visual Studio, но не опубликованное приложение, - именно поэтому сервер работает как Production, без каких-либо действий с вашей стороны.

Окружение определяет три вещи:

  • какой appsettings.{Environment}.json загружается;
  • загружаются ли user secrets (по умолчанию только в Development);
  • что возвращает app.Environment.IsDevelopment(), по которому шаблоны включают страницу исключений для разработчика и выключают HSTS.

Используйте Staging для второго деплоя, который должен вести себя как продакшен, но смотреть на тестовые данные. IsStaging() существует, а appsettings.Staging.json загружается автоматически. Как запускать оба рядом, описано в статье staging и продакшен на одном аккаунте.

Что класть в какой файл: в appsettings.json - каждый ключ, который читает приложение, со значениями, которые безопасно коммитить: хосты, порты, флаги функций, таймауты. В appsettings.Development.json - локальные переопределения, например подробное логирование. appsettings.Production.json часто не нужен; если он есть, в нём несекретные значения для продакшена. Ни в одном из них нет паролей, API-ключей или строк подключения с учётными данными, потому что все они коммитятся.

User secrets при разработке#

User secrets держат учётные данные для разработки вне репозитория, сохраняя их в вашем профиле пользователя, а не в папке проекта.

bash
$ dotnet user-secrets init$ dotnet user-secrets set "ConnectionStrings:Default" "Host=localhost;Password=dev-only"$ dotnet user-secrets set "Smtp:Password" "app-password-for-testing"$ dotnet user-secrets list

init добавляет в файл проекта GUID UserSecretsId. Значения попадают в обычный JSON-файл по пути %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json в Windows или ~/.microsoft/usersecrets/<id>/secrets.json в Linux и macOS.

Важно понимать, чем это является, а чем нет. Файл не зашифрован; это обычный JSON в вашем домашнем каталоге. Его единственная задача - держать секреты вне дерева проекта, чтобы их нельзя было закоммитить случайно. Он загружается только в окружении Development, так что механизмом деплоя он не является никогда. Команда ничего через него не разделяет - каждый разработчик задаёт свои значения.

Последнее порождает самую частую ошибку первого деплоя в .NET: локально всё работает, потому что строка подключения живёт в user secrets, а на сервере этого ключа просто нет. Приложение запускается, принимает запрос и падает, потому что строка подключения равна null. Проверка при запуске, о которой ниже, превращает это в немедленную и читаемую ошибку.

Секреты на сервере#

На сервере стандартное место для секретов - переменные окружения. Они задаются вне репозитория, читаются обычной системой конфигурации без изменений в коде и переопределяют любой файл.

В RE:NODE они задаются на вкладке Startup сервера и применяются при следующем запуске контейнера - так что поменяйте значение, затем перезапустите. Тариф для приложений включает два слота баз данных, которые создаются в панели со сгенерированными хостом, пользователем и паролем; скопируйте их в ConnectionStrings__Default, а не кладите в закоммиченный файл. Общие правила и что делать в день, когда секрет всё-таки оказался в истории Git, описаны в статье переменные окружения и секреты.

Чего делать не стоит:

  • Не коммитьте продакшен-файл `appsettings.Production.json` с учётными данными, чтобы потом добавить его в `.gitignore`. Он уже в истории. Смените учётные данные.
  • Не выводите конфигурацию в лог. IConfigurationRoot.GetDebugView() - полезный метод, который печатает каждый ключ, каждое значение и провайдер, который его задал, - включая пароли. Используйте его локально; никогда не пишите его вывод в продакшен-лог.
  • Не передавайте секреты аргументами командной строки. Они видны в списках процессов, и их легко вставить в тикет поддержки.

Если вы предпочитаете читать секреты из файлов, а не из окружения - по файлу на ключ, как работают монтируемые секреты контейнеров, - пакет Microsoft.Extensions.Configuration.KeyPerFile добавляет такой источник через builder.Configuration.AddKeyPerFile("/path/to/secrets", optional: true). Файл с именем Smtp__Password в этом каталоге становится ключом Smtp:Password. У внешних хранилищ (Azure Key Vault, HashiCorp Vault, AWS Secrets Manager) тоже есть провайдеры конфигурации; они имеют смысл, когда хранилище у вас уже есть, а не как первый шаг для одного приложения.

Одна настройка через все слои

Полезно проследить одну секцию от ноутбука до сервера. Возьмём секцию Smtp из примера выше.

На вашем ноутбуке окружение - Development. Smtp:Host, Smtp:Port и Smtp:From берутся из appsettings.json. appsettings.Development.json меняет Smtp:Host на localhost, а Smtp:Port на 1025, направляя почту в локальный перехватчик. Smtp:Password берётся из user secrets. Четыре ключа, три источника, и ничего секретного в репозитории.

На сервере окружение - Production. appsettings.Development.json и user secrets не загружаются вовсе. Smtp:Host, Smtp:Port и Smtp:From снова берутся из appsettings.json, так что закоммиченные значения по умолчанию должны быть продакшен-значениями. Smtp:Password берётся из переменной окружения Smtp__Password на вкладке Startup. Если позже понадобится на один вечер направить продакшен на другой релей, Smtp__Host в окружении переопределит файл без коммита, а удаление переменной вернёт закоммиченное значение.

Привычка, которая из этого следует: коммитьте в базовый файл безопасные для продакшена значения по умолчанию, удобства для разработки кладите в файл Development, а окружение оставьте для секретов и для осознанных временных переопределений. Если вы задаёте на сервере десяток несекретных переменных, этим значениям, скорее всего, место в закоммиченном файле, где их ревьюят и версионируют вместе с остальным кодом.

Паттерн options#

Вместо чтения строк привяжите секцию к классу:

csharp
public sealed class SmtpOptions{    public const string Section = "Smtp";    [Required] public string Host { get; set; } = "";    [Range(1, 65535)] public int Port { get; set; } = 587;    [Required, EmailAddress] public string From { get; set; } = "";    [Required] public string Password { get; set; } = "";}
Program.cs
builder.Services.AddOptions<SmtpOptions>()    .BindConfiguration(SmtpOptions.Section)    .ValidateDataAnnotations()    .ValidateOnStart();

Затем внедряйте его там, где он нужен. Интерфейсов три, и разница между ними в том, когда читаются значения:

ИнтерфейсВремя жизниВидит изменения после запуска
IOptions<T>SingletonНет - читается один раз, при первом использовании
IOptionsSnapshot<T>ScopedДа - пересчитывается на каждый запрос
IOptionsMonitor<T>SingletonДа - CurrentValue, плюс OnChange

По умолчанию используйте IOptions<T>. Он самый дешёвый и самый предсказуемый: что приложение прочитало при запуске, то и использует до перезапуска. IOptionsSnapshot<T> нельзя внедрить в singleton, потому что он scoped; такая ошибка даёт ошибку внедрения зависимостей при запуске. IOptionsMonitor<T> - для singleton-сервисов, которым действительно нужно реагировать на изменения, например фоновому сервису, чей интервал опроса вы хотите подстраивать на ходу.

Binder задаёт публичные свойства с сеттерами, сопоставляя имена без учёта регистра. Свойство без соответствующего ключа молча сохраняет значение по умолчанию. Вот почему важна проверка: без неё опечатка в имени ключа даёт объект, полный значений по умолчанию, и никакой ошибки.

Проверка при запуске#

ValidateDataAnnotations() проверяет атрибуты класса. ValidateOnStart() заставляет проверку выполниться при запуске хоста, а не в первый раз, когда кто-то запросит options. Вместе они превращают отсутствующий секрет вот в это, в первую же секунду лога:

code
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:DataAnnotation validation failed for 'SmtpOptions' members: 'Password' with the error:'The Password field is required.'.

Именно такое поведение вам нужно на сервере. Процесс, который отказывается запускаться и называет в одной фразе отсутствующий ключ, чинится за две минуты. Процесс, который запускается, принимает трафик и падает на первом письме, - это инцидент.

Для правил, которые не выразить атрибутами, добавьте делегат:

csharp
builder.Services.AddOptions<SmtpOptions>()    .BindConfiguration(SmtpOptions.Section)    .Validate(o => o.Port != 25 || o.Host.EndsWith(".internal"),        "Port 25 is only allowed for the internal relay.")    .ValidateOnStart();

Data annotations проверяют свойства верхнего уровня класса options; вложенные объекты рекурсивно не проверяются, если не проверять их явно. Если в ваших options есть вложенные секции, дайте каждой собственный класс options и собственную проверку.

Процесс, завершившийся при запуске, большинство платформ перезапустит, причём многократно. В RE:NODE три неожиданных перезапуска за час выводят предупреждение на странице сервера и автоматически открывают тикет, так что ошибка конфигурации не крутится в цикле незамеченной. Читайте первые строки лога, а не последние.

Перезагрузка без повторного деплоя#

JSON-источники регистрируются с reloadOnChange: true, так что правка appsettings.json на диске сервера обновляет конфигурацию в работающем приложении. Заметит ли это ваш код, зависит от интерфейса: IOptionsMonitor<T> и IOptionsSnapshot<T> видят изменение, IOptions<T> - нет, а значения, скопированные в поля при запуске, разумеется, тоже нет.

Логирование - единственное место, где это действительно полезно. Уровни логирования читаются через монитор, так что изменение Logging:LogLevel:Default в файле на диске повышает или понижает подробность без перезапуска - удобно, когда гоняетесь за проблемой в продакшене. Всё остальное считайте неизменным на время жизни процесса, меняйте через окружение и перезапускайте. Переменные окружения читаются один раз при запуске процесса и никогда не перезагружаются. Файлы, отредактированные на сервере, к тому же перезаписываются следующим деплоем, если они отслеживаются в репозитории, - хорошая причина вообще не править отслеживаемые файлы руками на сервере. Подробнее о выборе уровней - в статье логи, которые стоит хранить.

Частые ошибки#

Настройка не меняется. Её задаёт более высокий слой. Сначала проверьте переменные окружения, затем аргументы командной строки.

Локально работает, а на сервере null. Значение живёт в user secrets, которые загружаются только в Development. Задайте его переменной окружения на сервере.

Сервер работает с настройками для разработки. ASPNETCORE_ENVIRONMENT=Development перекочевала в окружение сервера из какого-то туториала. Уберите её; значение по умолчанию - Production.

Переменная окружения игнорируется. Одно подчёркивание вместо двух, опечатка в имени секции, или приложение не перезапустили после изменения.

В options одни значения по умолчанию. Имя секции, переданное в BindConfiguration, не совпадает с JSON, или у свойств нет сеттеров. Проверка поймала бы это.

«Cannot consume scoped service IOptionsSnapshot from singleton». В singleton-сервисах используйте IOptionsMonitor<T>.

Число или логическое значение неверно. Любое значение конфигурации - строка, пока его не привязали. "Port": "587" и "Port": 587 привязываются одинаково, но логическое значение, записанное как yes, или порт, записанный как 587/tcp, не проходят преобразование - и ошибка называет ключ, так что прочитайте её.

Секрет всё равно оказался в логе. Что-то записало в лог весь объект options, или сообщение исключения содержало строку подключения. Переопределите ToString() у классов options, хранящих секреты, и проверьте, что ваша библиотека логирования делает с объектами, переданными как структурированные свойства.

FAQ#

Безопасно ли хранить секреты в переменных окружения?

Для большинства приложений достаточно безопасно и гораздо безопаснее закоммиченных файлов. Любой, кто может прочитать окружение процесса или вкладку Startup в панели, может их прочитать, так что ограничьте круг тех, у кого есть такой доступ, - разделение прав в панели описано в статье субпользователи и минимальные привилегии.

Нужно ли коммитить appsettings.Production.json?

Да, если в нём только несекретные значения. Если ему нужен секрет, этому секрету место в переменной окружения, а в файле его не должно быть вовсе.

Нужно ли задавать ASPNETCORE_ENVIRONMENT на сервере?

Нет. Значение по умолчанию - Production, и оно правильное. Задавайте переменную только для намеренно другого окружения, например Staging.

Как задать строку подключения с точкой с запятой в переменной окружения?

Задайте как есть; точки с запятой - часть значения, и на вкладке Startup их не нужно экранировать. Кавычки понадобились бы только в оболочке. Рабочая строка подключения для каждого провайдера показана в статье .NET с PostgreSQL, MySQL или SQL Server.

Можно ли менять конфигурацию без перезапуска приложения?

Для значений, читаемых через IOptionsMonitor<T>, и для уровней логирования правка JSON-файла на диске работает. Переменные окружения всегда требуют перезапуска. Ради предсказуемости приложения лучше перезапускать.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000