Жизненный цикл программного обеспечения
Язык и алгоритмы составляют лишь половину дела. Вторая половина начинается там, где программа перестаёт быть личным черновиком, написанным за один вечер. Ею пользуются коллеги, на полученных ею результатах строятся статьи, а поддерживать её приходится годами, отвечая на письма людей, никогда не видевших автора.
Жизненный цикл программного обеспечения (ПО) охватывает всё происходящее с программой, от разговора, в котором задача только формулируется, до дня, когда её снимают с поддержки. Каждая стадия закрывает вопрос, иначе возникающий позже и по другой цене: чего от программы требуют, как она устроена, работает ли она и кто устраняет её сбои в нерабочее время.
Слайды к главе. Материал главы изложен также в лекции «Жизненный цикл ПО» (анализ и проектирование, разработка, тестирование, релиз, CI/CD, сопровождение) с демонстрациями в терминале; слайды лекции доступны на сайте книги и в PDF.
Основные стадии жизненного цикла
Жизненный цикл ПО принято делить на стадии; в настоящей главе их семь:
- Анализ. Выясняется, что именно требуется построить.
- Проектирование. Определяется, как это будет устроено.
- Разработка. Пишется код.
- Тестирование. Проверяется, что получилось именно задуманное.
- Релиз. Продукт доводится до пользователя.
- Сопровождение. Работающая система наблюдается и поддерживается.
- Завершение жизненного цикла. Продукт снимается с поддержки.
Стадии повторяются циклами. В каскадной модели они проходят один раз строго по порядку, в итеративных моделях цикл короткий и повторяется много раз: каждая итерация заканчивается работающей версией, а требования уточняются по опыту её использования. Современная разработка почти всегда итеративна. Пропущенная стадия не исчезает: её вопрос возникает позже и обходится дороже, и ошибку в требованиях, найденную при тестировании, приходится исправлять во всех слоях, построенных поверх неё. У научного кода цикл короче, а роль заказчика, аналитика и приёмщика играет сам разработчик.
Анализ и планирование
На этапе анализа собираются требования к будущему продукту и сводятся в документ — спецификацию требований к программному обеспечению (Software Requirements Specification, SRS).
Спецификация требований
Структуру спецификации задавал стандарт IEEE 830-1998. В 2011 году его заменил стандарт ISO/IEC/IEEE 29148 (действующая редакция — 2018 года), однако основное содержание документа сохранилось. Спецификация состоит из трёх частей:
- Введение: назначение документа и продукта, его границы, термины и ссылки.
- Общее описание: место продукта среди других систем, основные функции, пользователи, окружение, ограничения и допущения.
- Конкретные требования: внешние интерфейсы и форматы данных, функции, производительность, проектные ограничения и атрибуты качества — надёжность, точность, сопровождаемость.
Сроки и бюджет в спецификацию не включают: это требования к проекту, а не к продукту, и они фиксируются в плане работ. В отечественной практике документ с требованиями называют техническим заданием. Его содержание для программ устанавливает ГОСТ 19.201-78, для автоматизированных систем — ГОСТ 34.602-2020; техническое задание на систему, кроме требований к ней, определяет состав и этапы работ, порядок контроля и приёмки.
Ключевые задачи этапа анализа
Кроме сбора требований, этап включает постановку целей, планирование работ, распределение ресурсов и согласование сроков и бюджета. В научной группе все эти задачи умещаются в один разговор с руководителем и полстраницы заметок: что вычисляется, из каких данных, с какой точностью, в каком виде и к какому сроку нужен результат. Первый этап итогового проекта курса представляет собой именно такой документ на одну-две страницы.
Этим этапом пренебрегают из ощущения, что «и так всё понятно». Цена ошибки здесь самая высокая из всех стадий: неверно понятое требование обнаруживается на защите, когда переделывать некогда, и увлекает за собой все написанные поверх него страницы.
Проектирование
Из требований выводится устройство системы: деление на части (модули, сервисы, хранилища) и интерфейсы между ними — форматы данных, программные интерфейсы, протоколы. Решения этого этапа переживают весь написанный впоследствии код. Переписать отдельную функцию дёшево; изменить деление на части в собранной системе почти невозможно.
Первый вопрос проектирования — нужен ли код вообще. Каждая строка кода требует сопровождения, поэтому задача, решаемая настройкой существующего инструмента, готовой библиотекой или ручной операцией, решена лучше всего.
Design-review
Прежде чем писать код, замысел, оформленный на паре страниц, показывают коллегам. Design-review отвечает на два вопроса: стоит ли вообще это делать и хорошо ли спроектирована система. На разборе обсуждаются:
- что планируется сделать;
- мотивация, то есть цели, заказчики и пользователи;
- сравнение с уже существующими аналогами;
- план работ (roadmap).
Самым полезным пунктом в науке является третий: инструмент, уже написанный другой группой, обнаруживается нередко, и полчаса поиска могут сэкономить месяцы работы. Разбор окупается и после запуска. Система, построенная без него, при первом отказе может оказаться без графиков, оповещений и журналов в привычном для коллег месте, и поиск причины займёт больше времени, чем само исправление.
Security-review
Второй разбор, посвящённый безопасности, необходим всегда, но особенно там, где ошибка обходится дорого: когда поднимается новый сервис, выставленный в сеть; когда система распоряжается деньгами; когда через неё проходят закрытые или персональные данные; когда пишется собственная аутентификация или собственное разграничение прав.
Проверка паролей, написанная самостоятельно, остаётся типичной уязвимостью лабораторных сервисов. Сделанная за один вечер, она затем годами пропускает внутрь кого угодно, поэтому проверку доступа поручают готовым средствам, например SSH и единому входу организации. Разбор не заканчивается запуском: сервис со временем начинает обрабатывать новые данные, и хранилище, одобренное для открытых данных, оказывается открытым и для закрытых.
MVP (Minimum Viable Product)
Минимально жизнеспособный продукт — самая маленькая версия, которой уже можно пользоваться и которая удовлетворяет первых потребителей. Смысл заключается в том, чтобы как можно раньше получить отзыв и выяснить, тот ли продукт создаётся, вместо того чтобы полгода разрабатывать полную версию по непроверенным требованиям. По отзывам пользователей уточняются требования следующей версии, и цикл повторяется.
Для лабораторного инструмента MVP обычно выглядит так: одна команда, один формат входных файлов, никаких настроек. Она вычисляет задуманное, и её можно передать коллеге. Форматы, настройки и отчёты добавляются, когда выяснится, что они действительно нужны.
Разработка
Интерес здесь представляет не сам код, а то, как несколько человек работают над одним проектом, не затирая сделанное друг другом.
Системы контроля версий
Система контроля версий (VCS) хранит историю изменений с указанием того, кто, что и когда правил, и позволяет вернуться к любому прошлому состоянию. Решать эту задачу начали в 1960-х программой IEBUPDTE, затем появились SCCS в 1970-х, RCS и CVS в 1980-х, Subversion и Git в 2000-х.
Поколения различаются тем, где хранится история. Централизованные системы (CVS, Subversion) хранят её на сервере, и без связи с ним нельзя ни просмотреть историю, ни сделать коммит. Распределённые системы (Git, Mercurial) дают каждому участнику полную копию репозитория со всей историей, поэтому работать можно без сети, а синхронизироваться впоследствии.
Стандартом де-факто является Git, написанный Линусом Торвальдсом в 2005 году для разработки ядра Linux, когда команда лишилась бесплатного доступа к использовавшейся до того проприетарной системе BitKeeper. Главной его особенностью является дешёвое ветвление. Ветка — указатель на коммит, поэтому её создание ничего не копирует, и ветвиться и сливать можно постоянно. Команда git switch -c feature создаёт ветку и переключается на неё (прежняя форма — git checkout -b feature), а git merge feature сливает её изменения в текущую ветку. Если обе ветки изменили одни и те же строки, возникает конфликт слияния: Git помечает спорные строки, а решение принимает человек. Устройству Git и повседневным командам посвящена отдельная глава.
Вокруг ветвления сложились схемы работы:
- Git Flow держит две постоянные ветки, main и develop, и заводит на время ветки функций, релизов и срочных исправлений, которые удаляются после слияния. Схема подходит для продуктов с редкими версионированными релизами.
- GitHub Flow проще: основная ветка всегда находится в рабочем состоянии, каждая задача делается в короткой ветке и вливается через pull request после ревью.
- GitLab Flow дополняет GitHub Flow ветками окружений или долгоживущими ветками релизов, через которые изменения проходят по стендам.
- Магистральная разработка (trunk-based development) сокращает жизнь веток до дня и опирается на непрерывную интеграцию и feature-флаги.
Выбор определяется тем, как часто выходят релизы и сколько версий одновременно поддерживается. Научной группе почти всегда достаточно самой простой схемы, GitHub Flow.
Ревью кода
Pull request (или merge request) — предложение влить в общую ветку сделанные разработчиком изменения. Он решает три задачи: делает правку видимой для всей команды, предоставляет место для обсуждения до того, как код попал в основную ветку, и служит точкой, к которой привязываются автоматические проверки.
Ревью, занимающее полчаса, полезно даже в группе из двух человек. Свежий взгляд обнаруживает и ошибки, и места, кажущиеся очевидными автору, но не читателю. Ревью распространяет знание о проекте между участниками, и написанное перестаёт быть понятным одному человеку. Лучше всего ревью работает на небольших изменениях: pull request на пятьдесят строк читают внимательно, а на две тысячи — лишь просматривают. Ревью не заменяет тесты и линтер, а дополняет их: машина проверяет форму, человек — смысл, а в научном коде ещё и физику — единицы измерения, знаки, предельные случаи.
Споры о форме снимают единые требования к коду: стиль определяется соглашением, а не вкусом автора, и проверяют его линтер и форматтер. Хук pre-commit в каталоге .git/hooks запускает проверки перед каждым коммитом и не даёт зафиксировать код, который их не проходит. Хук обходится ключом --no-verify и не копируется при клонировании, поэтому проверки на сервере он не заменяет; общий для всех участников набор хуков описывают файлом .pre-commit-config.yaml фреймворка pre-commit.
Монорепозиторий
Монорепозиторий хранит код нескольких проектов вместе, под общей системой сборки и тестирования. Это упрощает управление зависимостями (все компоненты всегда согласованной версии), облегчает работу через границы команд и позволяет вносить сквозные изменения одним коммитом, а не пятью, разложенными по репозиториям. Платой являются разросшийся репозиторий и более сложные инструменты сборки, проверяющие только затронутые части. Монорепозитории используют крупные компании, в том числе Яндекс и Google; большинство открытых проектов живёт в отдельных репозиториях.
В монорепозитории удобно хранить и копии сторонних зависимостей: сборка не зависит от того, что удалят или подменят в чужом репозитории. В 2016 году автор пакета left-pad из одиннадцати строк удалил его из npm, и на несколько часов перестали собираться тысячи проектов; в 2024 году в библиотеку xz один из сопровождающих внёс бэкдор, попадавший в сборку через архивы релизов. Копия защищает только при закреплённых версиях и проверке каждого обновления.
Лаборатории разумно держать в одном репозитории всё, что выпускается и версионируется вместе, и разносить части, развивающиеся независимо.
Тестирование
Проверка вручную после каждой правки быстро становится невыполнимой. Автоматические тесты позволяют один раз описать требуемое поведение и в дальнейшем проверять его одной командой.
Проверки удобно выстроить пирамидой по цене и частоте запуска. Классическая пирамида тестов (М. Кон, 2009) состоит из модульных, интеграционных и сквозных тестов через интерфейс пользователя; в основание часто кладут статический анализ, не запускающий программу. Внизу проверки дешёвые и быстрые: их много, и они запускаются на каждое изменение. Вверху — дорогие и медленные: их мало, и они запускаются перед релизом. Нагрузочные тесты стоят особняком: они проверяют не правильность, а производительность.
Статический анализ
Самая дешёвая проверка не запускает программу. Анализатор читает исходный код и ищет в нём ошибки, отклонения от стиля и уязвимости: неиспользуемые переменные и импорты, опечатки в именах, сравнение с None через ==, голые except; по аннотациям типов mypy находит передачу значения не того типа. По сути это автоматизированная часть ревью, но не его замена. В отчёты о качестве попадают и метрики: цикломатическая сложность и доля дублированных строк. Туда же попадает покрытие тестами, однако получают его иначе, выполняя тесты под coverage.py (coverage run -m pytest, а с плагином pytest-cov — pytest --cov): узнать, какие строки выполнялись, не выполнив их, нельзя.
Анализатор, встроенный в редактор и в CI, в дальнейшем работает самостоятельно. Набор его правил задают в pyproject.toml явно: правила по умолчанию меняются от версии к версии, и в ruff 0.16 из них исключены, например, проверки сравнения с None и импорта со звёздочкой.
«Самое важное, что я сделал как программист за последние годы, — начал агрессивно применять статический анализ кода» — Джон Кармак, 2011
Модульные тесты
Модульный тест (unit test) проверяет одну функцию или класс, отделённые от всего остального. Их пишут много, они выполняются за секунды и запускаются на каждое изменение, поэтому ошибка, внесённая правкой, обнаруживается сразу.
Для научного кода это самая ценная разновидность тестов. Формулу проверяют на случаях с известным ответом, в предельных случаях и на инвариантах. Рассмотрим модуль physics.py с релятивистскими формулами:
"""Релятивистские формулы для пучка."""
import math
C = 299_792_458.0 # скорость света, м/с
def gamma(v):
"""Лоренц-фактор частицы со скоростью v, м/с."""
if not 0 <= v < C:
raise ValueError("ожидается 0 <= v < c")
return 1 / math.sqrt(1 - (v / C) ** 2)
def kinetic_energy(m, v):
"""Кинетическая энергия, Дж: масса m в кг, скорость v в м/с."""
return (gamma(v) - 1) * m * C**2
Тесты к нему лежат в файле test_physics.py:
import pytest
from physics import C, gamma, kinetic_energy
@pytest.mark.parametrize(
"beta, expected", [(0.0, 1.0), (0.6, 1.25), (0.8, 5 / 3)]
)
def test_gamma_known_values(beta, expected):
assert gamma(beta * C) == pytest.approx(expected)
def test_gamma_rejects_light_speed():
with pytest.raises(ValueError):
gamma(C)
def test_kinetic_energy_classical_limit():
m, v = 1.0, 1e3 # скорость много меньше c
assert kinetic_energy(m, v) == pytest.approx(m * v**2 / 2, rel=1e-6)
Декоратор parametrize прогоняет один тест на нескольких наборах данных, pytest.raises проверяет, что функция отвергает недопустимый вход, а pytest.approx сравнивает вещественные числа с допуском. Подробнее pytest рассмотрен в главе «От скрипта к приложению».
Тест предельного случая не проходит: при скорости 1000 м/с функция возвращает 500 006,9 Дж вместо 500 000. Ошибка не в физике, а в арифметике. Величина \(\gamma - 1 \approx 5{,}6 \cdot 10^{-12}\) получается вычитанием близких чисел, и из шестнадцати значащих цифр двойной точности в ней остаётся около пяти. Математически равная запись \((\gamma - 1) mc^2 = mv^2 \gamma^2 / (\gamma + 1)\) близкие числа не вычитает:
def kinetic_energy(m, v):
"""Кинетическая энергия, Дж: масса m в кг, скорость v в м/с."""
return m * v**2 * gamma(v) ** 2 / (gamma(v) + 1)
С ней проходят все пять тестов. Тест предельного случая обнаружил ошибку, которую не видно на релятивистских скоростях; проверенная однажды, формула остаётся проверенной и после того, как через полгода её начнут оптимизировать.
Разработка через тестирование (TDD)
В примере выше тесты написаны к готовому коду. Разработка через тестирование (test-driven development, TDD), описанная К. Беком (2002), меняет порядок работы. Сначала пишется тест, фиксирующий требование, и он не проходит, поскольку кода ещё нет; затем пишется простейший код, с которым тест проходит; затем при проходящих тестах код приводится в порядок (рефакторинг), в частности из него убирается дублирование, допущенное ради быстрого прохождения теста (принцип DRY рассмотрен в главе «Требования к коду»). Цикл «красный — зелёный — рефакторинг», названный по цвету отчёта о тестах, повторяется для каждого требования. Тест, написанный до кода, проверяет требование, а не реализацию, тогда как тест, составленный по готовому коду, нередко принимает за норму то, что код делает, вместе с его ошибками.
Рассмотрим функцию, проверяющую адреса приборов в файле конфигурации. Требование сформулируем так: адрес IPv4 записывается четырьмя десятичными числами от 0 до 255 через точку, без ведущих нулей (адресация рассмотрена в главе «Компьютерные сети»). Тесты составлены по пунктам требования и лежат в файле test_ipv4.py:
import pytest
from ipv4 import is_ipv4
VALID = ("8.8.8.8", "192.0.2.10", "255.255.255.255")
INVALID = ("", "a.b.c.d", "256.1.1.1", "8.8.8.8.8")
# три числа, ведущий нуль, шестнадцатеричная запись, одно число
STRICT = ("8.8.8", "010.0.0.1", "0x08080808", "134744072")
@pytest.mark.parametrize("text", VALID)
def test_valid(text):
assert is_ipv4(text)
@pytest.mark.parametrize("text", INVALID + STRICT)
def test_invalid(text):
assert not is_ipv4(text)
Первая реализация в модуле ipv4.py поручает разбор адреса стандартной функции socket.inet_aton:
import socket
def is_ipv4(text):
"""Проверяет, является ли строка text адресом IPv4."""
try:
socket.inet_aton(text)
except OSError:
return False
return True
python3 -m pytest -q --tb=no
.......FFFF [100%]
=========================== short test summary info ============================
FAILED test_ipv4.py::test_invalid[8.8.8] - AssertionError: assert not True
FAILED test_ipv4.py::test_invalid[010.0.0.1] - AssertionError: assert not True
FAILED test_ipv4.py::test_invalid[0x08080808] - AssertionError: assert not True
FAILED test_ipv4.py::test_invalid[134744072] - AssertionError: assert not True
4 failed, 7 passed in 0.01s
Ключи -q и --tb=no сокращают отчёт до строки хода выполнения, перечня непрошедших тестов и итога. Очевидные случаи реализация проходит, строгие — нет. Функция inet_aton разбирает адрес по правилам, унаследованным от BSD Unix: в записи из трёх чисел последнее занимает два младших байта, и 8.8.8 означает 8.8.0.8; число с ведущим нулём читается как восьмеричное, и 010.0.0.1 превращается в 8.0.0.1; строки 0x08080808 и 134744072 представляют собой адрес 8.8.8.8, записанный целиком одним числом, шестнадцатеричным и десятичным. Поэтому проверка пропустила бы и опечатку 192.0.2 вместо 192.0.2.10: inet_aton читает её как адрес 192.0.0.2. Документация Python к тому же оговаривает, что набор допустимых строк зависит от реализации библиотеки C. Разбор, соответствующий требованию, выполняет класс ipaddress.IPv4Address; ведущие нули он отвергает начиная с Python 3.9.5:
import ipaddress
def is_ipv4(text):
"""Проверяет, является ли строка text адресом IPv4."""
try:
ipaddress.IPv4Address(text)
except ipaddress.AddressValueError:
return False
return True
С новой реализацией та же команда показывает, что проходят все одиннадцать тестов:
........... [100%]
11 passed in 0.00s
Тест, написанный по требованию, обнаружил ошибку при первом запуске, а тест, составленный по результатам готовой функции, скорее всего закрепил бы 8.8.8 как допустимый адрес. Считать ли такую запись адресом — вопрос спецификации, а не тестирования: inet_aton её принимает, а сформулированное выше требование отвергает. Таким образом, тест лишь фиксирует выбранную спецификацию, и выбирают её до написания тестов.
Разработка через тестирование не даёт выигрыша, пока требование неизвестно. В исследовательском скрипте, будь то первый взгляд на новые данные или пробная подгонка, код меняется быстрее, чем к нему успевают написать тесты, и тест сохранил бы случайное промежуточное состояние. Тесты пишут, когда результат исследования становится требованием: формулой с известными предельными случаями, форматом файла, функцией, которую вызывают повторно.
Интеграционные и функциональные тесты
Модуль может работать правильно сам по себе и отказывать в связке с другими. Интеграционные тесты проверяют взаимодействие компонентов между собой и с внешними системами: базой данных, файловым хранилищем, чужим API за сетью. Они медленнее модульных и требуют поднятого окружения, поэтому их пишут меньше. Типичный пример: модуль чтения возвращает энергию в килоэлектронвольтах, а модуль подгонки ожидает мегаэлектронвольты. Модульные тесты каждого из них проходят, а ошибку в тысячу раз обнаруживает только интеграционный тест, проверяющий модули вместе.
Функциональные тесты проверяют, что программа делает записанное в требованиях, с точки зрения работающего с ней человека: через пользовательский или программный интерфейс, вручную или автоматически (для веб-интерфейсов — средствами вроде Selenium). У лабораторного инструмента сквозной функциональный тест обычно один. Он берёт снятый с установки эталонный файл, выполняет всю обработку от начала до конца и сравнивает итоговые числа с заведомо правильными.
Нагрузочное тестирование
Нагрузочные тесты проверяют, как система ведёт себя под поданной снаружи нагрузкой: измеряют производительность, проверяют поведение на пиках и восстановление после них и служат основой планирования ресурсов. Профили различаются по характеру нагрузки и по времени испытания; в таблице приведены названия, принятые в документации инструментов, например k6.
| Профиль | Что проверяет |
|---|---|
| Load testing | выдерживает ли система ожидаемую штатную нагрузку |
| Stress testing | что происходит за пределами расчётной нагрузки |
| Spike testing | переживает ли система резкий всплеск и как восстанавливается после него |
| Soak (endurance) testing | нет ли утечек памяти и деградации при штатной нагрузке в течение часов и дней |
| Breakpoint testing | где предел системы при постепенном росте нагрузки |
Общим словом performance testing называют все испытания производительности. Перед замером подают небольшой прогревающий поток запросов (warm-up), наполняющий кеши, иначе первые минуты измерений искажены. Это актуально и для физика: программа, быстро считающая на прогретых кешах, на первом запуске ведёт себя иначе. Для нагрузки используют JMeter, Siege, Яндекс.Танк, на Python — locust. Нагрузку подают на отдельное окружение, а не на рабочую систему, и обращения к внешним сервисам заменяют заглушками: иначе тест нарушит работу коллег или будет воспринят чужим сервисом как атака.
Релиз
Главной заботой при выкатке является возможность быстро откатиться в случае поломки.
Стратегии развёртывания
Существуют стратегии выкатки, каждая из которых по-своему страхует от неудачного релиза:
- Blue-green. Поддерживаются две одинаковые среды. Новая версия выкатывается на неактивную, проверяется, и трафик переключается одним действием. Откат — переключение обратно. Плата — удвоенные ресурсы, а схема общей базы данных должна подходить обеим версиям.
- Canary. Новая версия сначала показывается небольшой доле пользователей. Если число ошибок и потребление ресурсов не выросли, доля увеличивается. Проблема затрагивает немногих, а откат быстрый, но выкатка медленнее.
- Rolling update. Экземпляры приложения заменяются постепенно, по одному или группами, и сервис всё это время продолжает отвечать. Сколько экземпляров может быть недоступно одновременно и сколько добавляется сверх нормы, задают параметры выкатки (в Kubernetes —
maxUnavailableиmaxSurge). - A/B-тестирование. Контролируемый эксперимент: пользователи случайным образом делятся на группы, каждая получает свою версию, и решение принимается по заранее выбранной метрике с проверкой статистической значимости. От канареечной выкатки его отличает цель — выбор лучшего варианта, а не безопасность выпуска.
Крупные сервисы сочетают канареечную выкатку, постепенную замену, feature-флаги и A/B-эксперименты.
Контейнеры
Выпуск удобно оформлять образом контейнера (см. главу «Docker»). Образ содержит приложение вместе со средой выполнения — интерпретатором, библиотеками, системными файлами — и после сборки не меняется. Тег образа служит версией: выкатка — запуск контейнера из нового тега, откат — запуск из предыдущего, и старый образ хранится готовым к повторному запуску. Тег при этом остаётся изменяемой ссылкой: его можно перенести на другой образ, а базовый python:3.12-slim обновляется с каждым выпуском 3.12.x. Неизменяемой ссылкой служит дайджест (python:3.12-slim@sha256:…). Образ работает на любой машине с той же архитектурой процессора; для разных машин собирают многоплатформенный образ (docker buildx build --platform linux/amd64,linux/arm64).
Feature-флаги
Feature-флаг — переключатель, включающий или выключающий функциональность без изменения кода и без новой выкатки. Недописанную функцию можно держать в основной ветке выключенной, включить её для десяти пользователей, а при проблемах выключить за секунду вместо отката всего релиза. Флаг служит и аварийным выключателем: при сбое им отключают функцию, создающую лишнюю нагрузку. Флаги копятся, поэтому флаг, включённый для всех пользователей, удаляют вместе со старой ветвью кода, а навсегда выключенный — вместе с неиспользуемой новой.
Версии и теги
Релиз получает номер версии, по которому его можно найти, установить и сослаться на него. Семантическое версионирование (SemVer) записывает версию тремя числами MAJOR.MINOR.PATCH: старшее увеличивается при несовместимом изменении интерфейса, среднее — при новых возможностях с сохранением совместимости, младшее — при исправлении ошибок. Версия фиксируется в Git аннотированным тегом: git tag -a v1.5.0 отмечает коммит, из которого собран релиз, а git describe показывает, сколько коммитов прошло с последнего тега. Список изменений между версиями ведут в файле CHANGELOG.
В статье указывают версию или хеш коммита кода, которым получен результат. Архив релиза с DOI, например через интеграцию GitHub с Zenodo, позволяет сослаться на код и воспроизвести расчёт спустя годы.
Момент выпуска
Мораторий на выкатки (change freeze) — период с запрещёнными выкатками: перед большой конференцией, во время набора данных на установке, в новогодние праздники, когда некому устранять неполадки. От него отличается feature freeze — этап цикла выпуска, после которого в версию принимают только исправления ошибок: в CPython он наступает с первой бета-версией. Релизные окна — заранее оговорённые промежутки, в которые выкатки разрешены, чтобы в этот момент на месте были люди, способные разобраться с последствиями.
CI/CD
Перечисленные проверки выполняет конвейер CI/CD. Без него разработчики занимаются сборкой и ручным тестированием вместо разработки, под давлением сроков тестирование пропускают, а при ручной выкатке легко ошибиться. За тремя похожими аббревиатурами скрываются три степени автоматизации.
Непрерывная интеграция (CI) — минимальный уровень. Код хранится в системе контроля версий, каждый push запускает сборку, модульные тесты и статический анализ, изменения проходят ревью. Поломка обнаруживается через минуты после появления, а не через недели.
Непрерывная поставка (CD, Continuous Delivery) добавляет хранение собранных дистрибутивов в централизованном хранилище и автоматическое развёртывание на тестовые среды. Система в любой момент готова к выкатке и ожидает решения человека.
Непрерывное развёртывание (CD, Continuous Deployment): прошедшие все проверки изменения попадают в продакшен без участия человека. Это требует уверенности в тестах.
Научной группе обычно достаточно первого уровня — конвейера, запускающего на каждый push тесты и линтер; он требует одного файла в репозитории. Конвейеры исполняют GitHub Actions, GitLab CI, Jenkins, TeamCity.
Устройство реального конвейера
Ниже в обновлённом виде приведён конвейер научной библиотеки REDPIC, кода для расчёта динамики пучка, которому отведена отдельная глава; в репозитории библиотеки он записан на более старых версиях действий. Конвейер написан для GitHub Actions и состоит из трёх файлов в каталоге .github/workflows.
Первый файл, check.yml, проверяет качество кода. Он запускается на push в основную ветку, затрагивающий файлы Python, и на каждый pull request в неё. Работа выполняется на чистой виртуальной машине и начинается с подготовки: получить исходники и установить нужную версию Python.
name: Check
on:
push:
branches: [master]
paths: ["**.py"]
pull_request:
branches: [master]
jobs:
check:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
- name: Checkout sources
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
Блок strategy.matrix задаёт матричную сборку. Один и тот же набор шагов выполняется на нескольких версиях Python, и если библиотека перестала работать на 3.12, но работает на 3.10, это выясняется сразу. Для библиотеки, передаваемой незнакомым людям, такая проверка обязательна: разработчик не управляет тем, какой Python установлен у пользователя.
Анализаторов два, поскольку обнаруживают они разное. flake8 следит за стилем и очевидными ошибками, pylint анализирует глубже и сообщает о сомнительных конструкциях; в новых проектах первый обычно заменяют на ruff. Шаги продолжают список steps той же работы:
- name: Install deps
run: |
python -m pip install --upgrade flake8 pylint
python -m pip install -r requirements.txt
- name: Run flake8
run: python -m flake8 redpic tests setup.py
- name: Run pylint
run: python -m pylint redpic tests setup.py
Проверяется не только код библиотеки, но и tests с setup.py. Тесты также являются кодом, и приходят в негодность они не реже основного.
Второй файл, test.yml, устроен так же, но вместо анализаторов устанавливает pytest и выполняет тесты:
- name: Run functional test
run: python -m pytest tests/functional/src
Любой шаг, завершившийся с ненулевым кодом, окрашивает проверку pull request в красный цвет. Запретить слияние с красной проверкой позволяет правило защиты основной ветки, в котором проверка отмечена обязательной.
Третий файл, release.yml, выпускает релиз. Он запускается только на поставленный автором тег версии. Первая работа собирает пакет, отправляет его в PyPI, где он становится доступен по pip install redpic, и прикладывает архив исходников к релизу на GitHub:
name: Release
on:
push:
tags: ["*.*.*"]
jobs:
library:
runs-on: ubuntu-latest
permissions:
contents: write # создание релиза на GitHub
id-token: write # публикация в PyPI без токена
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Build distributions
run: |
python -m pip install --upgrade build twine
python -m build
python -m twine check --strict dist/*
- name: Publish distribution to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
- name: Upload Release Asset (sdist) to GitHub
uses: softprops/action-gh-release@v3
with:
files: dist/*.tar.gz
Блок permissions перечисляет права, которые получает работа; без него токен работы в новых репозиториях может только читать. Право id-token: write нужно для доверенной публикации (Trusted Publishing): PyPI принимает пакет от конвейера, заранее зарегистрированного на сайте, и токен PyPI в репозитории не хранится.
Ещё две работы того же файла собирают руководство и документацию. PDF-версия руководства прикладывается к релизу, а документация, собранная Sphinx из исходников, публикуется на GitHub Pages:
manual:
needs: library
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- name: Compile LaTeX document
uses: xu-cheng/latex-action@v4
with:
root_file: main.tex
working_directory: manual/
- name: Upload Release Asset (pdf) to GitHub
uses: softprops/action-gh-release@v3
with:
files: manual/main.pdf
docs:
needs: library
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- name: Build docs
run: |
python -m pip install -r docs/requirements.txt
python -m sphinx docs docs/_build/html
- name: Push documentation to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs/_build/html
Пароли и токены в конвейере никогда не записываются открытым текстом: они помещаются в хранилище секретов репозитория и подставляются по имени, например ${{ secrets.PYPI_API_TOKEN }}, если пакет публикуется по токену. Исключение составляет secrets.GITHUB_TOKEN: этот токен GitHub выдаёт каждой работе сам, с правами из блока permissions. Секрет, закоммиченный в репозиторий, считается скомпрометированным навсегда: история git хранит всё, и удаление следующим коммитом ничего не исправляет.
Каждое чужое действие — код, выполняемый в конвейере разработчика с доступом к его секретам. Неизменяемой ссылкой на действие служит только полный хеш коммита: тег, как и ветку, может перенести на другой коммит любой, получивший доступ к репозиторию действия (так в марте 2025 года были подменены теги действия tj-actions/changed-files). Мажорные теги вида @v7 переносятся на каждый новый выпуск по замыслу; в примере для краткости указаны именно они, а в рабочем конвейере действия сторонних авторов надёжнее закреплять полным хешем коммита с комментарием о версии, обновлять который помогает Dependabot. Запись вида @master означает «взять из чужой ветки то, что там находится сегодня». @release/v1 в примере — также ветка, однако PyPA поддерживает её под стабильные выпуски и сама рекомендует такую запись.
Одного тега в git достаточно, чтобы появилась новая версия библиотеки, устанавливаемая через pip, с собранной документацией и PDF-руководством, приложенным к релизу. Ни одного действия вручную, а следовательно, ни одной возможности пропустить шаг.
Это главный аргумент в пользу CI/CD в науке. Релиз, собираемый вручную, рано или поздно окажется собран не из того коммита, без одного теста или с забытым файлом, и обнаружится это через полгода, когда результат уже опубликован.
Задание. Выступить в роли рецензента: «Ревью чужого кода».
Сопровождение
Работающую систему необходимо наблюдать: собирать метрики и логи, следить за ними и вовремя замечать сигналы о неполадках. Метрики показывают задержки, долю ошибок и нагрузку, логи позволяют восстановить, что происходило перед сбоем, а трассировки — проследить запрос через несколько сервисов. Вместе их называют наблюдаемостью (observability); подробно она рассмотрена в главе «Наблюдаемость», а практика эксплуатации — в книге Google «Site Reliability Engineering» [23].
В зрелых командах вокруг этого выстраивается набор практик: назначенные дежурства с порядком реагирования на инциденты; разбор каждого сбоя без поиска виноватых, с поиском причин и мер, исключающих повторение; feature-флаги для быстрого выключения проблемной функции; регулярные учения на случай отказов. Chaos engineering — намеренное внесение отказов в работающую систему ради проверки того, что она их переживает: выяснять, что план не работает, лучше в контролируемых условиях, чем во время аварии.
Для лабораторного сервиса минимум следующий: программа ведёт журнал, перезапускается после сбоя, а об отказе кто-то узнаёт раньше, чем это обнаружат пользователи. Перезапуск обеспечивает, например, политика контейнера docker run --restart on-failure. Причину отказа он не устраняет: о перезапуске должен узнать человек, а причину разбирают по журналу.
Завершение жизненного цикла
Рано или поздно программу перестают поддерживать: её заменяет новая система, исчезает задача или некому её сопровождать. Об этом объявляют заранее, называя сроки, дают пользователям время и путь перехода на замену, а данные выгружают в формате, переживающем сам сервис. Инфраструктуру отключают, не оставляя забытых серверов и открытых портов.
В науке этот этап игнорируется чаще, чем где бы то ни было. Код к статье, заброшенный сразу после публикации, встречается повсеместно, а от него зависит, сможет ли кто-то воспроизвести результат через пять лет. Минимум, который необходимо оставлять после себя, — работающий репозиторий с README, зафиксированными версиями зависимостей и архивом релиза.
Резюме
Таким образом, семь стадий сохраняются, даже если код пишется в одиночку: кто-то должен понять, что нужно построить; продумать устройство; написать; проверить; довести до пользователей; сопровождать; а когда придёт время, закрыть, оставив после себя работающий репозиторий.
Разница между научной группой и большой компанией заключается в масштабе церемоний. Заказчиком, аналитиком, тестировщиком и дежурным оказывается один и тот же человек. Стадии пропускают обычно по незнанию. Ценой являются потерянные данные, невоспроизводимые результаты и код, в котором через год не разберётся даже автор.