Что мы разбираем и кому это сэкономит время
Массив в JSON записывается единственным способом: квадратные скобки и запятые между элементами. В YAML для того же списка есть блочная запись через дефисы и потоковая в квадратных скобках. Вложенность строится по одной логике: элемент массива может быть объектом, а внутри объекта снова лежит массив.
Дальше - рабочие примеры вложенных списков объектов для обоих форматов, минимальная JSON Schema для массива сервисов, таблица ошибок парсинга с симптомом и способом починки, команды для pre-commit и GitHub Actions, правила версионирования схемы. Всё можно копировать в свой репозиторий.
Материал для DevOps-инженеров, сисадминов и разработчиков, которые держат конфигурацию как код: манифесты Kubernetes, плейбуки Ansible, docker-compose.yml, конфиги CI. Если вы уже писали такие файлы и ловили mapping values are not allowed in this context посреди релиза, здесь найдёте причину и способ закрыть весь класс таких ошибок схемой и проверкой в пайплайне.
Если массивы лежат не в конфиге, а в колонке базы, критерии выбора другие: у нас есть отдельный разбор четырех способов хранения массивов в MySQL с индексацией и миграциями - хранение массивов в MySQL: JSON, сериализация и нормализация.
Массивы скаляров и вложенные списки объектов: примеры для JSON и YAML
Порядок элементов массива сохраняется всегда, порядок ключей внутри объекта - нет. Парсер вправе вернуть ключи в любом порядке, поэтому логику приложения нельзя завязывать на последовательность полей маппинга. Массивы для этого и нужны: там порядок значим и гарантирован.
Массив скаляров: порты, теги, хосты
Простейший случай: список чисел или строк без вложенности. JSON:
{
"ports": [80, 443, 8080],
"tags": ["web", "edge"],
"upstream_hosts": ["10.0.0.11", "10.0.0.12"]
}
Тот же конфиг в YAML, блочная запись:
ports: - 80 - 443 - 8080 tags: - web - edge upstream_hosts: - 10.0.0.11 - 10.0.0.12
Потоковая запись короче и удобна для двух-трех значений: ports: [80, 443, 8080]. В манифестах Kubernetes и плейбуках Ansible чаще берут блочную: диффы в Git читаются построчно, а добавление элемента не переписывает всю строку.
Ключевая ловушка на этом уровне - кавычки. В YAML значения 80 и "80" различаются по типу: число и строка. Схема с type: integer отклонит второй вариант, а Nginx или Docker Compose могут молча привести строку к числу и создать расхождение между конфигом и результатом.
Живой пример роли простого флага в плоском конфиге: в плагине CyberLevels бета-функция включается одной строкой в config.yml, а не через вложенную секцию из трех уровней - описание плагина CyberLevels. Чем меньше уровней вложенности до булева значения, тем меньше шансов ошибиться в отступах.
Список объектов: сервисы, пользователи, маршруты
Повторяющиеся сущности с несколькими полями описывают массивом объектов. JSON:
{
"services": [
{"name": "api", "image": "registry.local/api:1.4.2", "ports": [8080], "env": ["LOG_LEVEL=info"]},
{"name": "web", "image": "registry.local/web:1.4.2", "ports": [80, 443], "env": ["TZ=UTC"]}
]
}
YAML, тот же смысл:
services:
- name: api
image: registry.local/api:1.4.2
ports:
- 8080
env:
- LOG_LEVEL=info
- name: web
image: registry.local/web:1.4.2
ports:
- 80
- 443
env:
- TZ=UTC
Правило отступов простое: дефис первого элемента стоит на одном уровне с ключом services, дефис второго элемента - ровно под первым дефисом, а поля элемента сдвинуты на два пробела от дефиса и выровнены между собой. Сдвиг хотя бы одной строки на один пробел меняет смысл документа или роняет парсер.
Список пользователей с ролями строится так же, только вместо image и ports идут name, role и ssh_keys. Список маршрутов Nginx удобно держать массивом объектов с полями path, upstream и weight: при добавлении маршрута достаточно дописать один элемент.
Для больших списков (больше сотни элементов) один файл становится неудобным: диффы разрастаются, ревью теряет смысл. Выносите такие списки в отдельный файл и подключайте его: в Ansible через include_vars, в Docker Compose через несколько флагов -f, в Kubernetes через kustomize. Якоря YAML с ключом слияния << тоже сокращают повторы, но раскрытие якорей зависит от инструмента. Перед деплоем проверьте, что ваш рендерер их поддерживает, и не полагайтесь на якоря в том, что читает сторонний API.
Вложенные массивы внутри объектов: когда это оправдано
Вложенность на два уровня нормальна: сервис с массивом портов и массивом переменных окружения. Схема для такого объекта остается читаемой, а код приложения обходит два цикла.
Массив массивов без семантики - другое дело. Сравните два варианта:
[ ["api", 8080], ["web", 80] ]
[
{"name": "api", "port": 8080},
{"name": "web", "port": 80}
]
В первом случае смысл позиций знает только тот, кто писал конфиг: где имя, где порт, что делать при добавлении третьего поля. Во втором поля названы, и массив объектов проверяется обычной схемой: type: array, items с required: [name, port].
Отдельная история - разнородные массивы, где первый элемент число, второй строка, третий объект. В JSON Schema такие позиции описывает prefixItems в драфте 2020-12 или массив схем в ключе items в draft-07. Схема получается длинной и хрупкой, поэтому разнородные наборы лучше сразу переводить в объект с именованными полями.
Ориентир по глубине: три уровня вложенности - рабочая граница. Если до нужного значения приходится считать четыре отступа и больше, выносите секцию в отдельный файл или в именованные поля. Это же правило упрощает версионирование: правки в плоской структуре затрагивают одну строку, а не переписывают дерево.
Валидация структуры через JSON Schema: массив объектов под контролем
JSON Schema описывает структуру данных, а не синтаксис файла. Один и тот же набор правил проверяет JSON-документ и YAML после разбора в объектную модель. Комментарии, отступы и способ записи массива схему не интересуют, ей важны типы, обязательность полей и допустимые значения.
Минимальная схема для массива объектов
{
"$id": "schemas/services-v1.json",
"type": "object",
"required": ["schemaVersion", "services"],
"additionalProperties": false,
"properties": {
"schemaVersion": {"type": "integer", "const": 1},
"services": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["name", "image"],
"additionalProperties": false,
"properties": {
"name": {"type": "string", "pattern": "^[a-z][a-z0-9-]{1,30}$"},
"image": {"type": "string", "minLength": 1},
"ports": {"type": "array", "items": {"type": "integer", "minimum": 1, "maximum": 65535}},
"env": {"type": "array", "items": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]*="}}
}
}
}
}
}
Разбираем по строкам. type: object на верхнем уровне говорит, что конфиг - маппинг, а не список. required перечисляет ключи, без которых документ невалиден. additionalProperties: false ловит опечатки в ключах: лишнее поле prot вместо ports не пройдет молча. minItems: 1 запрещает пустой массив сервисов, а items описывает схему каждого элемента.
Драфт зафиксируйте один раз: поле $schema принимает идентификатор 2020-12 для новых проектов или draft-07, если ваш валидатор новее не поддерживает. Смешивать драфты в одном проекте не стоит: ключи вроде prefixItems и items в старой и новой редакциях работают по-разному.
Конфиг, который проходит проверку:
{
"schemaVersion": 1,
"services": [
{"name": "api", "image": "registry.local/api:1.4.2", "ports": [8080]}
]
}
Три варианта, которые схема отклонит, с реальными сообщениями:
- {"name": "api", "image": "...", "prot": [8080]} - must NOT have additional properties, в параметрах ошибки будет additionalProperty: prot;
- элемент без ключа name - must have required property 'name', путь указывает на конкретный индекс: /services/0;
- "ports": ["8080"] - must be integer, строка не проходит проверку типа.
Одно ограничение схемы стоит держать в голове: uniqueItems: true сравнивает элементы целиком. Два сервиса с одинаковым именем, но разными образами схема пропустит. Уникальность по ключу name проверяется либо в коде приложения, либо через отдельный тест, который собирает имена и ищет дубликаты.
Проверка конфига в командной строке и CI
Быстрая проверка синтаксиса и структуры:
jq . config.json ajv validate -s schemas/services-v1.json -d config.json --spec=draft2020 check-jsonschema --schemafile schemas/services-v1.json config/services.yaml yamllint -c .yamllint.yml . kubeconform -strict -summary manifests/
Инструменты дополняют друг друга: jq и yamllint проверяют синтаксис и форматирование, ajv и check-jsonschema - структуру по схеме, kubeconform - соответствие манифестов схемам Kubernetes с флагом -strict, который запрещает неизвестные поля.
Шаг пайплайна, который блокирует мерж при невалидном конфиге:
name: config-validate
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx install check-jsonschema yamllint
- run: yamllint -c .yamllint.yml .
- run: check-jsonschema --schemafile schemas/services-v1.json config/services.yaml
Мерж блокирует не сам шаг, а защита ветки: включите обязательную проверку в настройках репозитория, иначе красный статус останется советом. Тот же набор команд вешается на pre-commit через локальный hook с language: python и entry, ссылающимся на установленный check-jsonschema, чтобы ошибка ловилась до пуша, а не на ревью.
Валидация YAML: конвертация и подводные камни
YAML проверяется схемой не напрямую: файл сначала разбирается в объектную модель, и дальше к ней применяется тот же валидатор, что и к JSON. Значит, результат проверки зависит от парсера. PyYAML, go-yaml и js-yaml по-разному трактуют булевы значения, восьмеричные числа и даты, а от этого меняются типы, которые увидит схема.
Практический вывод: фиксируйте парсер и его версию в проекте (lock-файл, версия образа в CI), не полагайтесь на системный python или node. Дополнительно прогоняйте YAML через yamllint с включенным правилом duplicate-keys: часть парсеров молча оставляет последнее значение дубликата и не сообщает об ошибке.
Синтаксис манифестов с отступами, многострочными строками и проверкой в CI разобран отдельно в руководстве по синтаксису YAML для манифестов Kubernetes: там же готовые шаблоны Deployment и связка yamllint с kubeconform.
Типичные ошибки при парсинге вложенных структур
Таблица для быстрой диагностики: симптом из лога, причина, починка.
| Симптом | Причина | Починка |
|---|---|---|
| Unexpected token } in JSON at position 128 | Лишняя запятая перед закрывающей скобкой | Удалить запятую после последнего элемента, проверить через jq |
| Unexpected token ' в JSON | Одинарные кавычки или комментарий | Двойные кавычки, комментарии вынести в отдельный файл документации |
| Unexpected character при чтении файла | BOM в начале файла | Сохранить как UTF-8 без BOM |
| found character '\t' that cannot start any token | Таб в отступах YAML | Только пробелы, правило indentation в yamllint |
| mapping values are not allowed in this context | Двоеточие в незакавыченном значении | Взять значение в кавычки: "web:8080" |
| bad indentation of a mapping entry | Дефис массива сдвинут относительно ключа | Выровнять все дефисы одного списка по одной колонке |
| Часть конфига пропала без ошибки | Дубликат ключа, парсер взял последнее значение | Включить duplicate-keys в yamllint |
Ошибки JSON: запятые, кавычки, BOM
Сообщения о позиции в файле врут ровно на один токен: парсер доходит до конца и только тогда понимает, что структура не закрыта. Поэтому лишнюю запятую ищите не в указанной колонке, а на несколько знаков раньше.
Комментарии в JSON не поддерживаются. Варианты JSON5 и JSONC существуют, но в конфигах продакшена они редко оправданы: инструменты вроде jq, ajv и клиентов API на них спотыкаются. Если нужны пояснения, держите их рядом в README или в поле description внутри конфига.
Самый частый однострочник для проверки синтаксиса - jq . config.json. Он выведет нормализованный документ или укажет строку и колонку с ошибкой. Пустой вывод при ненулевом exit code означает, что файл не разобрался.
Ошибки YAML: табы, отступы, дубликаты ключей
Табы в YAML запрещены в любом месте, где ожидается отступ, включая отступ для многострочных строк. Правило indentation в yamllint ловит это до коммита. Второй источник проблем - отступ внутри массива: все дефисы одного списка обязаны стоять в одной колонке, а поля элемента - выравниваться относительно дефиса.
Дубликат ключа опаснее синтаксической ошибки, потому что не всегда виден. Поведение зависит от парсера: PyYAML оставляет последнее значение, js-yaml в строгом режиме может выдать ошибку. Результат одинаково плохой - часть конфига исчезает без предупреждения, а сервис стартует с неполными настройками.
Многострочные блоки с символами | и > ломаются о тот же отступ. Литеральный блок сохраняет переводы строк, folded склеивает их в одну строку с пробелами, и сдвиг отступа на один пробел меняет содержимое конфига целиком, а не отдельного поля.
null, пустой массив и отсутствующий ключ: три разных состояния
Три записи выглядят похоже, но означают разное:
{"services": []}
{"services": null}
{}
Пустой массив говорит "сервисов нет, это осознанный выбор". Null значит "значение не задано". Отсутствующий ключ - "поле не упомянуто вообще". В JSON Schema это три разные проверки: required: [services] отклонит третий вариант, type: array отклонит null, а minItems: 1 отклонит пустой массив.
Рекомендация: в схеме явно решите, допустим ли null (для этого тип пишется как type: [array, null]), и не полагайтесь на значения по умолчанию в парсере. Дефолт, подставленный библиотекой, невидим в конфиге и всплывает только на проде.
YAML-массивы в структурах приложения: где теряются типы
YAML - формат с богатой системой типов, и парсер приводит незакавыченные значения к типам автоматически. Именно здесь строка становится числом, а код страны превращается в boolean.
yes/no/on/off и норвежская проблема
В YAML 1.1 слова yes, no, on, off, y, n распознаются как булевы значения. Классический пример: country: NO разбирается как country: false, и массив кодов стран превращается в набор true и false. В YAML 1.2 булевыми остались только true и false.
Починка одна: кавычки. Запись country: "NO" гарантирует строку в любой версии спецификации. Режим страны в CI, список регионов, флаг окружения - всё, что похоже на короткое слово из двух-трех букв, берите в кавычки.
Поведение парсеров различается: PyYAML исторически следует YAML 1.1, js-yaml начиная с четвертой версии использует более строгую схему и не считает yes/no булевыми. Проверьте свой стек на тестовом конфиге, а не на предположении.
Числа, версии и даты: как не потерять строку
Пример с версией: version: 1.10 разбирается как число с плавающей точкой и превращается в 1.1. Если приложение сравнивает версии как строки, сравнение молча идет не туда. Решение - version: "1.10".
Восьмеричные числа в YAML 1.1 начинаются с нуля: mode: 0755 может прийти в приложение как 493. Для прав доступа это критично: файл получит неожиданный режим. Записывайте восьмеричные значения строкой или в явном виде 0o755, если парсер его поддерживает.
Даты тоже приводятся к объектам: date: 2026-01-01 в PyYAML становится объектом date, и обычная JSON-сериализация такого значения падает. Кавычки решают вопрос: "2026-01-01" остается строкой.
Как зафиксировать типы в схеме и в коде
Четыре действия закрывают тему типов системно:
- Зафиксировать парсер и его версию в lock-файле и в образе CI: разбор одного конфига двумя версиями библиотеки дает разные результаты.
- Указать type для каждого поля схемы. Поле без типа почти не защищает.
- Для критичных значений добавить pattern или enum: версия по шаблону ^[0-9]+\.[0-9]+\.[0-9]+$, список допустимых ролей, диапазон портов.
- Добавить в CI тест, который парсит конфиг и проверяет типы значений после разбора, а не до него.
Схема для поля версии сработает как предохранитель: при type: string незакавыченное значение 1.10 придет числом 1.1 и получит ошибку must be string. Ошибка появится на шаге валидации, а не в рантайме приложения.
Версионирование схемы конфигурации: как обновлять без боли
Схема и конфиг живут вместе: конфиг без схемы проверяется глазами, схема без номера версии не дает понять, какой файл с ней совместим. Номер версии связывает эти сущности и делает миграцию предсказуемой.
Где хранить $id и schemaVersion
Три рабочих варианта:
- Отдельный файл схемы на версию: schemas/services-v1.json, schemas/services-v2.json. Плюс - обе схемы лежат рядом, старые конфиги проверяются без правок. Минус - больше файлов.
- Один файл с версией в $id: идентификатор отличает редакции, а поле schemaVersion в конфиге подсказывает, какую редакцию применять.
- Поле schemaVersion в самом конфиге: обязательный ключ с ограничением const на конкретную версию.
Мы рекомендуем комбинацию: schemaVersion первым ключом в конфиге плюс отдельный файл схемы на каждую версию. Скрипт в CI читает номер версии, выбирает соответствующий файл и запускает проверку. Конфиг становится самодостаточным: по одному файлу видно, каким правилам он подчиняется.
{
"schemaVersion": 2,
"services": [
{"name": "api", "image": "registry.local/api:1.5.0", "scale": 3}
]
}
Правила обратной совместимости и deprecated-поля
Правила совместимости укладываются в три строки:
- Добавили необязательное поле - это минорное изменение, старые конфиги валидны.
- Добавили поле в required - это ломающее изменение, все существующие конфиги перестают проходить проверку.
- Удалили, переименовали поле или сменили тип - мажорная версия с миграцией.
Устаревшие поля не удаляйте сразу. Помечайте их в схеме через deprecated: true и описание вида "используйте scale", оставляя поле валидным в течение одного мажорного цикла. Пример: поле replicas заменяется на scale, обе формы проходят валидацию, но код читает только новое поле, а в конфиг попадает предупреждение.
Миграции конфигов: скрипт вместо ручной правки
Алгоритм миграции повторяется от проекта к проекту:
- Снять бэкап рабочего конфига и положить рядом с результатом миграции.
- Прочитать schemaVersion и определить цепочку преобразований (v1 в v2, v2 в v3).
- Выполнить dry-run: записать результат во временный файл, не перезаписывая исходный.
- Проверить результат новой схемой, а исходный файл - старой: так видно, что миграция не сломала то, что работало.
- Показать дифф и задеплоить за флагом, оставив возможность отката.
Для JSON и YAML преобразования удобно делать через jq и yq: выражение вида .services[] |= (.scale = .replicas | del(.replicas)) переносит значение и удаляет старое поле. Прогоняйте такой скрипт на копии прод-конфига: статистика по реальным файлам вскрывает случаи, которых нет в тестовых примерах.
Автоматическая подстройка конфигов при обновлении версии сервиса существует: в описании CyberLevels указано, что конфигурации адаптируются при каждом обновлении и переделывать файлы не требуется - описание плагина CyberLevels. Такой подход снимает ручную работу, но переносит риск на инструмент: тесты на миграцию и валидация старого конфига после обновления остаются обязательными.
JSON или YAML для вашего конфига: таблица выбора
| Критерий | JSON | YAML |
|---|---|---|
| Комментарии | Нет в стандарте | Да |
| Многострочные строки | Только через экранирование \n | Блоки | и >, folded |
| Машинная генерация | Проще: сериализатор в любой библиотеке | Сложнее: чувствительность к кавычкам и типам |
| Человекочитаемость массива объектов | Скобки и запятые на каждом элементе | Дефисы и отступы, меньше шума |
| Строгость типов | Строгая: строка всегда строка | Слабая: yes/no, 1.10 и даты приводятся к типам |
| Валидация структурой | JSON Schema напрямую | JSON Schema после разбора в объект |
| Якоря и повторное использование | Нет | Есть, поддержка зависит от инструмента |
Правило выбора простое. Машинная генерация, обмен данными между сервисами, ответы API, файлы, которые пишет и читает один и тот же инструмент, - JSON. Ручные конфиги с комментариями и повторяющимися блоками, манифесты Kubernetes, плейбуки Ansible, конфиги CI - YAML.
Пример машинного JSON рядом: при установке function kit конфигурационная информация записывается в firebase.json, то есть файл формирует инструмент, а не человек - практики миграции Firebase Extensions. Дополнительную причину держать схему конфига версионируемой дает расписание отключений у внешних сервисов: в той же документации указано, что сервис Extensions будет отключен 31 марта 2027 года, а ключевые функции управления перестанут работать после этой даты. Внешние сроки диктуют график миграции ваших конфигов.
Если массив должен жить в базе данных, а не в файле, критерии другие: сравнивать нужно размер на диске, скорость кодирования и риски десериализации - это разобрано в материале про сравнение JSON, BLOB и бинарных форматов хранения массивов. А для случаев, когда массив уходит на диск или в бинарный обмен, полезен разбор сериализации и персистентности массивов на диске с порядком байтов и выравниванием.
Чек-лист перед коммитом конфига
- Синтаксис проверен: jq . config.json для JSON, yamllint -c .yamllint.yml . для YAML.
- Структура проверена схемой: check-jsonschema --schemafile schemas/services-v1.json config/services.yaml.
- Секреты не в репозитории: токены и пароли приходят из окружения или хранилища секретов, .env добавлен в .gitignore.
- Права на файлы конфигов ограничены (0640), в веб-сервере выключен autoindex, каталоги с конфигами не отдаются наружу.
- Ключ schemaVersion указан в каждом конфиге и совпадает с версией файла схемы.
- Миграция схемы протестирована на копии прод-конфига, результат проверен новой схемой.
- Валидация в CI запускается на pull request и блокирует мерж при ненулевом exit code.
- Для манифестов Kubernetes добавлен kubeconform -strict -summary manifests/.
Конфиг - это код. У него есть схема, версия, тесты и проверка в пайплайне. Начните с одного файла: опишите схему для самого болезненного конфига, повесьте проверку на pull request, а остальные подтянутся по мере правок.