Программист-защитник сильнее энтропии
© Dragon Ball. Goku.
Программист-защитник в любой момент и в любом месте кода ожидает появления потенциальных проблем и пишет код таким образом, чтобы заранее от них защититься. А если от проблемы нельзя защититься, то хотя бы сделать так, чтобы её последствия и влияние на пользователей были минимальными.
Вспоминается эффект FlashForward из голливудских блокбастеров, когда главный герой видит грядущую катастрофу и остаётся предельно спокойным, потому что заранее знает, что она произойдёт, и имеет от неё защиту. Идея защитного программирования в том, чтобы защититься от проблем, которые сложно или вовсе невозможно предвидеть. Программист-защитник ожидает появления ошибок в любом месте системы и в любой момент времени, чтобы предотвратить их до того, как они нанесут ущерб. При этом цель не в том, чтобы создать систему, которая никогда не падает, это всё равно невозможно. Цель в том, чтобы создать систему, которая падает изящно в случае любой непредвиденной проблемы.
Давайте разберёмся подробнее, что входит в понятие «падать изящно».
- Падать быстро. В случае непредвиденной ошибки все операции должны завершаться сразу же, особенно если последующие вычисления тяжёлые или могут привести к порче данных.
- Падать аккуратно. Если возникла ошибка, программа должна освободить все ресурсы, снять локи, удалить временные и наполовину записанные файлы, закрыть соединения. Дождаться завершения критических операций, прерывание которых может привести к непредсказуемым результатам. Либо безопасным способом аварийно завершить эти операции.
- Падать явно и красиво. Если что-то сломалось, сообщение об ошибке должно быть простым, лаконичным и содержать важные детали из того контекста системы, где возникла ошибка. Это поможет команде, которая отвечает за систему, максимально быстро разобраться в проблеме и исправить её.
Но у вас может появиться вопрос.
Зачем тратить время на проблемы, которые могут возникнуть в будущем? Сейчас же их нет, код работает просто идеально. К тому же проблемы могут и вовсе никогда не произойти. Ведь профессионалы не занимаются инженерией ради инженерии (YAGNI — You aren’t gonna need it)!
Главное — прагматизм
Эндрю Хант в книге «Программист-прагматик» даёт следующее определение защитному программированию — »прагматическая паранойя».
Защищайте свой код от:
- собственных ошибок;
- ошибок других людей;
- ошибок и сбоев в других системах, с которыми интегрирована ваша;
- ошибок железа, сред и платформ, на которых работает ваше приложение.
Давайте обсудим несколько тактических и стратегических приёмов защитного программирования, следование которым позволит создать надёжную и предсказуемую систему, устойчивую к произвольным сбоям.
Некоторые советы могут показаться «капитанскими», но на практике многие разработчики не следуют даже им. А ведь если придерживаться простых практик и подходов, это значительно повысит стабильность вашей системы.
Никому не доверяйте
Данные пользователей по умолчанию ненадёжны. Пользователи часто неверно понимают то, что нам (как разработчикам системы) кажется очевидным. Ожидайте на входе некорректные данные и всегда проверяйте их.
Также проверяйте объём входных данных. Может быть такое, что пользователь отправляет их слишком много. При этом, с точки зрения бизнес-логики, это корректный сценарий. Но он может привести к слишком долгой их обработке. Что с этим можно сделать? Например, запустить её асинхронно, в случае если объём входных данных превышает определённый порог и специфика бизнеса позволяет обработать данные в фоновом режиме.
Настройки программ (например, конфигурационные файлы) также подвержены появлению в них некорректных данных. Часто настройки программ хранятся в JSON, YAML, XML, INI и других форматах. Поскольку всё это текстовые файлы, стоит ожидать, что рано или поздно кто-то что-то в них поменяет, и ваша программа станет работать некорректно. Это может быть как конечный пользователь, так и кто-то из вашей команды.
Базы данных, файлы, централизованные хранилища конфигов, реестр — ко всем этим местам может быть доступ у других людей, и рано или поздно они что-то там поменяют (Murphy’s law).
Мусор на входе → мусор на выходе
Входные данные, которые прошли валидацию и начинают обрабатываться, должны быть чистыми, если вы хотите, чтобы ваш код делал именно то, что вы от него ожидаете.
Однако, хороший тон — делать дополнительные проверки корректности данных, в том числе когда они уже начали обрабатываться. В критических местах (биллинг, авторизация, персональные и конфиденциальные данные и т.д.) это практически обязательное требование. Это необходимо, чтобы в случае появления багов в коде или проблем с валидатором входных данных остановить поток исполнения как можно быстрее. Сложно сделать качественную валидацию с проверкой всех возможных сценариев ошибок, поэтому можно использовать более простые способы валидации того, что программа всё ещё исполняется корректно — assertions и exceptions.
Здоровая параноидальность — это характерная черта всех профессиональных разработчиков. Но очень важно искать оптимальный баланс и понимать, когда решение уже достаточно хорошее.
Разделяйте конфиги по окружениям
Частая причина появления проблем — недостаточное разделение конфигов по окружениям или вовсе отсутствие такого разделения.
Это может привести к множеству проблем, например:
- тестовое окружение начинает читать и/или писать данные из продакшена, баз данных, очередей и других ресурсов;
- тестовое окружение использует внешние интеграции и сервисы с проадкшен-аккаунтом;
- смешение статистики, метрик, ошибок из разных окружений;
- нарушение безопасности (разработчики, тестировщики и другие члены команды получают доступ к продакшен-ресурсам);
- сложноисследуемые баги на продакшене (например, часть сообщений в очереди теряется из-за того, что её начинает читать тестовое окружение).
Это лишь примеры, полный список проблем, к которым может привести недостаточно ответственное разделение конфигов, практически бесконечен и зависит от специфики проекта.
Ответственное разделение конфигурационных данных по окружениям позволяет значительно уменьшить вероятность сразу целого класса проблем, связанных с:
- безопасностью;
- надёжностью;
- поддержкой и разворачиванием (DevOps-инженеры скажут вам спасибо).
Помимо этого, хорошей практикой является хранение секретных данных (ключей, токенов, паролей) в отдельном месте, специально предназначенном для хранения и обработки секретов. Такие системы надёжно шифруют данные, имеют гибкие средства для управления правами доступа, а также позволяют быстро сменить ключи, если они были скомпрометированы. При этом не потребуется вносить изменения в код и вновь разворачивать приложение. Это особенно важно для систем, которые работают с финансовыми транзакциями, конфиденциальными или персональными данными.
Помните о каскадном эффекте
Распространённая причина падения больших и сложных систем — каскадный эффект. Происходит поломка или деградация функциональности одной из частей системы, и одна за другой начинают отказывать другие подсистемы, связанные с ней. Каскадно, пока вся система не станет полностью недоступна.
Несколько защитных трюков:
- используйте прогрессивные (экспоненциальные) тайм-ауты с элементом рандома;
- устанавливайте разумные значения connection timeout и socket timeout;
- предусмотрите заранее fallback на случай отказа отдельных сервисов. Лучше временно деградировать часть функциональности, отключить сервисы совсем, но не рисковать поломкой всей системы. Но предусмотрите, чтобы в этом случае пользователь увидел понятное и не пугающее сообщение, а команда поддержки и разработки максимально быстро узнала о возникшей проблеме.
Быстро сообщайте себе о проблемах
Все системы сбоят. В них иногда происходит нечто странное, что создатели ожидают «раз в 10 лет». Интеграции и внешние API периодически становятся недоступны или отвечают некорректно. Сделать fallback для всех таких случаев зачастую сложно, долго или просто невозможно. Предусмотрите заранее такую ситуацию и сообщайте о ней как можно быстрее. Запись в лог с уровнем ERROR или в систему мониторинга — как само собой разумеющееся. Добавить дополнительную проверку в healthcheck — ещё лучше. Отправить из кода сообщение в Slack, Telegram, PagerDuty или другой сервис, который мгновенно оповестит вашу команду о проблеме, — идеально.
Но важно чётко понимать, когда есть смысл отправлять сообщения напрямую. Только в случае, если возникшая ошибка, подозрительная или нетипичная ситуация связана с бизнес-процессами и важно, чтобы конкретный человек или группа людей в команде как можно быстрее получили нотификацию и могли отреагировать.
Все остальные технические проблемы и отклонения должны обрабатываться стандартными средствами — monitoring, alerting, logging.
Кешируйте часто используемые и/или недавние данные
У программ и людей есть одна схожая черта — они склонны переиспользовать те данные, которые часто используются или недавно встречались. В высоконагруженных системах всегда следует об этом помнить и кешировать данные в самых горячих местах системы.
Стратегия кеширования сильно зависит от специфики проекта и данных. Если данные мутабельные, появляется необходимость инвалидации кешей. Поэтому заранее обдумайте, как вы будете делать это. А также подумайте о том, какие риски могут быть в случае появления в кеше устаревших данных, выхода кеша из строя и т.д.
Заменяйте дорогие операции на дешёвые
Работа со строками — одна из самых частых операций в любой программе. И если делать это не оптимально, это может быть дорогая операция. В разных языках программирования специфика работы со строками может различаться, но нужно всегда помнить о ней.
В крупных приложениях с большой кодовой базой часто встречается код, написанный много лет назад, который работает без ошибок, но не оптимален с точки зрения производительности. Зачастую банальное изменение структуры данных с массива/списка на хеш-таблицу даёт серьёзный буст (пусть лишь в локальном месте кода).
Иногда можно улучшить производительность, переписав алгоритм на использование побитовых операций. Но даже в тех редких случаях, когда это оправдано, код получается весьма сложным. Поэтому при принятии решения учитывайте читабельность кода и то, что его нужно будет поддерживать. То же самое касается и других хитрых оптимизаций: почти всегда такой код становится трудным для чтения и очень сложным для поддержки. Если вы всё же решились на хитрые оптимизации, не забывайте писать комментарии с описанием того, что вы хотите, чтобы этот код делал, и почему он написан именно так.
При этом к оптимизации стоит относиться со здоровым прагматизмом:
- если она займёт у вас, как у разработчика, несколько секунд или минут — есть смысл сделать её сразу;
- если больше, то разумно делать её сразу же только тогда, когда вы на 100% уверены в её необходимости. Во всех остальных случаях есть смысл отложить её, написать в коде TODO, собрать больше информации, посоветоваться с коллегами и т.д.
Преждевременная оптимизация — корень всех зол (Дональд Кнут)
Перепишите на языке более низкого уровня
Это крайняя мера. Языки низкого уровня почти всегда быстрее по сравнению с языками более высокого уровня. Но у этого решения есть цена — разрабатывать такую программу дольше и сложнее. Иногда, переписав критические части системы на языке низкого уровня, можно добиться серьёзного увеличения производительности. Но есть и побочные эффекты — обычно такие решения теряют в кросс-платформенности и их поддержка стоит дороже. Поэтому принимайте решение взвешено.
Один в поле не воин
В заключение хочется отметить ещё одну важную вещь, возможно, самую главную. Меры, которые мы рассмотрели в предыдущих пунктах, будут работать только в том случае, если все участники команды придерживаются их и у каждого есть понимание, кто за что отвечает и что нужно делать в случае критической ситуации. Важно после исправления проблемы провести встречу (Post Mortem) со всеми заинтересованными людьми и выяснить, почему эта проблема возникла и что можно сделать, чтобы этой же проблемы не было в будущем. Во многих случаях требуются как технические изменения, так и изменения в процессах. С каждым новым Post Mortem«ом ваша система будет становится надёжнее, команда опытнее и сплочённее, а энтропии во вселенной чуть меньше;)
В статье частично использованы материалы из Why Defensive Programming is the Best Way for Robust Coding (Ravi Shankar Rajan).