YAML: формат конфигов, в котором есть подводные камни
YAML — формат, который вы встретите в 100% DevOps-задач: docker-compose, Kubernetes manifests, GitHub Actions, ansible playbooks. Для DE это означает, что любая работа с инфраструктурой ETL — это YAML. И за иллюзией «человекочитаемого формата» прячется куча сюрпризов: значимые отступы, валидный синтаксис, который значит не то, что кажется (country: NO парсится как false), и опасный yaml.load, который позволяет атакующему выполнить произвольный Python-код.
В этом уроке разберём YAML 1.2, anchors/aliases для DRY, разницу safe_load vs load, типичные подводные камни и сравнение с JSON.
YAML 1.2: что это
YAML расшифровывается как «YAML Ain’t Markup Language» (рекурсивный акроним). Текущая версия — 1.2 (2009), хотя многие парсеры де-факто поддерживают 1.1 features. PyYAML по умолчанию работает в режиме 1.1, что приводит к нескольким сюрпризам (см. ниже).
Ключевые свойства:
- Человекочитаемость — без скобок и кавычек.
- Значимые отступы (как в Python) — структура задаётся indent’ом.
- Поддержка mapping, sequence, scalars — три базовых структуры.
- Anchors и aliases — ссылки внутри документа, позволяют не повторять блоки.
- Tags — явное указание типа значения.
- Multi-document — один файл может содержать несколько YAML-документов через
---.
Пример типичного YAML:
version: "3.8"
services:
web:
image: nginx:1.27
ports:
- "80:80"
environment:
- DEBUG=true
- API_URL=https://api.example.com
db:
image: postgres:16
volumes:
- db_data:/var/lib/postgresql/data
volumes:
db_data:
То же самое в JSON:
{
"version": "3.8",
"services": {
"web": {
"image": "nginx:1.27",
"ports": ["80:80"],
"environment": ["DEBUG=true", "API_URL=https://api.example.com"]
},
"db": {
"image": "postgres:16",
"volumes": ["db_data:/var/lib/postgresql/data"]
}
},
"volumes": {"db_data": null}
}
YAML более компактен и без лишних кавычек/скобок. Это не просто косметика — для конфигов с множеством полей разница большая.
Структуры: mappings, sequences, scalars
YAML поддерживает два стиля для каждого типа:
# Block style
person:
name: Alice
hobbies:
- reading
- hiking
# Flow style (как JSON)
person: {name: Alice, hobbies: [reading, hiking]}
# Можно смешивать
person:
name: Alice
hobbies: [reading, hiking] # inline list
Block style — основной для конфигов. Flow style — для inline-данных.
Скаляры в YAML 1.1 (PyYAML default) автоматически парсятся:
42-> integer.3.14-> float.true,True,yes,Yes,on,On-> boolean true.false,False,no,No,off,Off-> boolean false.null,Null,~или просто пусто -> null.
Это источник знаменитой проблемы Norway:
countries:
- NO # Norway? нет -- это БУЛЕВ false!
- SE # Sweden -- string
- JP # Japan -- string
В YAML 1.1 NO — это альтернативная запись для false. После парсинга countries[0] становится False, не 'NO'. В YAML 1.2 этого нет (только true/false/null), но PyYAML по умолчанию работает как 1.1.
Решение — явные кавычки или явный tag:
countries:
- "NO" # явно строка
- !!str NO # явно strict string через tag
В YAML значимые отступы (как в Python) — но в отличие от Python, нельзя смешивать tabs и spaces. Tabs запрещены вообще для отступов (но допустимы внутри scalar-значений). Если редактор автоматически вставляет tab — YAML-парсер падает с непонятной ошибкой. Всегда configure редактор на spaces для YAML файлов.
Anchors и aliases: DRY в YAML
Если один и тот же блок повторяется в нескольких местах, можно объявить anchor с & и сослаться на него через alias *.
# Anchor -- определение
defaults: &defaults
timeout: 30
retries: 3
log_level: info
# Alias -- ссылка
production:
<<: *defaults # merge keys: вставить все ключи из defaults
host: prod.example.com
staging:
<<: *defaults
host: staging.example.com
log_level: debug # override
После парсинга:
{
'defaults': {'timeout': 30, 'retries': 3, 'log_level': 'info'},
'production': {'timeout': 30, 'retries': 3, 'log_level': 'info', 'host': 'prod.example.com'},
'staging': {'timeout': 30, 'retries': 3, 'log_level': 'debug', 'host': 'staging.example.com'}
}
Merge keys — это extension YAML, работает в большинстве парсеров (PyYAML — да, Kubernetes parser — частично, GitHub Actions — нет). Если нужна максимальная переносимость, лучше копипастить.
Anchor можно использовать и для скаляров:
default_timeout: &timeout 30
services:
api:
timeout: *timeout
worker:
timeout: *timeout
Anchor referenceable только внутри одного документа. Между документами в multi-document файле — нет.
Tags: явное указание типа
Иногда автоматический type inference ошибается, или вы хотите конкретный тип. Tags — это явное указание.
# Числа как строки
phone: !!str 1234567890 # string '1234567890', не int 1234567890
# Бинарные данные
icon: !!binary |
R0lGODlhDAAMAIQAAP//9/X
17unp5WZmZgAAAOfn515eXv
# Множества (set, не list)
tags: !!set
? python
? rust
? sql
# Pyaml-специфичный тег для timestamp
created: !!timestamp 2026-05-15T12:30:00
# Кастомные теги для приложения
deploy:
- !aws/ec2
instance_type: t3.medium
- !aws/s3
bucket: my-bucket
Стандартные tags YAML 1.2: !!str, !!int, !!float, !!bool, !!null, !!seq, !!map, !!set, !!binary, !!timestamp. Кастомные начинаются с одного !.
Кастомные tags — это то, чем приложения расширяют YAML. Например, !Ref в AWS CloudFormation, !Vault в ansible. Парсер должен знать, как их обрабатывать (через custom Loader).
yaml.load vs yaml.safe_load: критичная разница
PyYAML предоставляет несколько функций для загрузки:
import yaml
# ОПАСНО -- позволяет construct произвольных Python объектов
data = yaml.load(yaml_text, Loader=yaml.Loader)
# ПРАВИЛЬНО -- только базовые типы
data = yaml.safe_load(yaml_text)
# Также правильно
data = yaml.load(yaml_text, Loader=yaml.SafeLoader)
yaml.load(..., Loader=yaml.Loader) поддерживает специальные tags типа !!python/object, которые могут конструировать произвольные Python объекты. Это включает !!python/object/apply:os.system — что позволяет выполнить произвольный код.
# Атакующий присылает такой YAML
some_field: !!python/object/apply:os.system ['rm -rf /']
Если ваш код делает yaml.load(user_input, Loader=yaml.Loader), он выполнит os.system('rm -rf /'). Это RCE (Remote Code Execution).
import yaml
# Это упадёт с ConstructorError на safe_load -- безопасно
malicious = "x: !!python/object/apply:os.system ['echo PWNED']"
try:
yaml.safe_load(malicious)
except yaml.constructor.ConstructorError as e:
print(f"Blocked: {e}")
# could not determine a constructor for the tag '!!python/object/apply:os.system'
# Но если использовать yaml.load -- выполнится
yaml.load(malicious, Loader=yaml.Loader)
# Выведет: PWNED
ВСЕГДА используйте yaml.safe_load (или yaml.load с SafeLoader). yaml.load без явного Loader в новых версиях PyYAML deprecation warning, но в legacy коде встречается. Это самая частая security-уязвимость в Python-приложениях, работающих с YAML.
Если вам нужно работать с custom tags для своего приложения — extends SafeLoader, добавляя только нужные конструкторы. Не используйте полный Loader.
YAML в DevOps: примеры из жизни
Imperative vs Declarative: два паттерна работы с API YAML и TOML: форматы для конфиговПример Kubernetes Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
labels:
app: api
spec:
replicas: 3
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: myorg/api:v2.5.0
ports:
- containerPort: 8080
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-creds
key: url
resources:
requests:
cpu: "100m"
memory: "256Mi"
limits:
cpu: "500m"
memory: "512Mi"
---
apiVersion: v1
kind: Service
metadata:
name: api-service
spec:
selector:
app: api
ports:
- port: 80
targetPort: 8080
Multi-document через ---. Парсинг:
import yaml
with open("deployment.yaml") as f:
docs = list(yaml.safe_load_all(f)) # iterator по документам
for doc in docs:
print(doc['kind'], doc['metadata']['name'])
# Deployment api-server
# Service api-service
YAML vs JSON: когда что выбрать
Правило: JSON для машин, YAML для людей. API -> JSON. Конфиги -> YAML. Большие данные -> ни то, ни другое (Parquet/Avro).
Подводные камни
Проблема Norway. NO, OFF, YES парсятся как booleans. Решение: использовать YAML 1.2 strict parser (ruamel.yaml с typ='safe', pure=True) или явно квотить.
Версии в строках. version: 3.8 парсится как float 3.8. Если нужна именно строка '3.8' (например, для compose-version) — version: "3.8".
Числа с leading zero. code: 0123 в YAML 1.1 — это OCTAL number (= 83 в decimal). YAML 1.2 не имеет octal-literal такого вида. PyYAML обработает как octal. Решение: квотить.
Tabs. Внутри scalar — OK. В indent — запрещено. Редакторы часто автоинсёртят tab. Один tab — и парсер падает.
Глубокая вложенность. Большие YAML-файлы (Helm values на 1000+ строк) становятся нечитаемыми. Лучше разбивать на несколько файлов и собирать через include/import (Helm partials, ansible imports).
Дубликаты ключей в map. Поведение не определено по spec, разные парсеры — разное поведение. PyYAML safely предупреждает, но всё равно использует последнее. В kubectl apply дубликат может вызвать silent override, что приведёт к surprise.
Попробуй сам
import yaml
# 1. Разные стили
yaml_text = """
# Block style
person:
name: Alice
age: 30
hobbies:
- reading
- hiking
# Flow style (compact)
location: {city: Moscow, country: RU}
"""
print(yaml.safe_load(yaml_text))
# 2. Anchors и aliases
yaml_text = """
defaults: &defaults
retries: 3
timeout: 30
prod:
<<: *defaults
host: prod.com
staging:
<<: *defaults
host: staging.com
retries: 5
"""
print(yaml.safe_load(yaml_text))
# 3. Norway problem
yaml_text = """
countries:
- US
- NO
- SE
on_button:
- on
- off
"""
data = yaml.safe_load(yaml_text)
print(data)
# {'countries': ['US', False, 'SE'], 'on_button': [True, False]}
# NO стало False! on/off стали True/False!
# Решение: кавычки
yaml_text = '''
countries:
- "US"
- "NO"
- "SE"
on_button:
- "on"
- "off"
'''
print(yaml.safe_load(yaml_text))
# 4. Безопасность: pop test
malicious = "x: !!python/object/apply:os.system ['echo HACKED']"
try:
yaml.safe_load(malicious)
except yaml.constructor.ConstructorError as e:
print(f"Blocked safely: {type(e).__name__}")
# С yaml.load -- НЕ запускайте на проде!
# yaml.load(malicious, Loader=yaml.Loader) # выполнит os.system
# 5. Multi-document
yaml_text = """
---
kind: Deployment
name: api
---
kind: Service
name: api-svc
"""
for doc in yaml.safe_load_all(yaml_text):
print(doc)