Упаковка и доставка кода
Заставить код работать так, как задумано, – трудно; заставить тот же самый код работать на чужой машине зачастую ещё труднее.
Доставить код (ship code) – значит взять написанный вами код и превратить его в пригодную к использованию форму, которую кто-то другой сможет запустить, не воспроизводя в точности настройку вашего компьютера. Доставка кода принимает самые разные формы и зависит от выбора языка программирования, системных библиотек и операционной системы, а также от множества других факторов. Она зависит и от того, что именно вы создаёте: у программной библиотеки, инструмента командной строки и веб-сервиса разные требования и разные шаги развёртывания. Тем не менее у всех этих сценариев есть общая схема: нам нужно определить, что именно мы поставляем – так называемый артефакт (artifact) – и что он предполагает о среде, в которой будет работать.
В этой лекции мы рассмотрим:
- Зависимости и окружения
- Артефакты и упаковка
- Релизы и версионирование
- Воспроизводимость
- Виртуальные машины и контейнеры
- Конфигурация
- Сервисы и оркестрация
- Публикация
Объяснять эти понятия мы будем на примерах из экосистемы Python, поскольку конкретные примеры помогают разобраться. В экосистемах других языков программирования инструменты другие, но сами понятия по большей части те же.
Зависимости и окружения
В современной разработке ПО слои абстракции встречаются повсюду. Программы естественным образом перекладывают часть логики на другие библиотеки или сервисы. Однако это создаёт отношение зависимости (dependency) между вашей программой и библиотеками, которые нужны ей для работы. Например, в Python, чтобы получить содержимое веб-сайта, мы обычно пишем:
import requests
response = requests.get("https://missing.csail.mit.edu")
Но библиотека requests не входит в поставку среды выполнения Python, поэтому, если попытаться запустить этот код без установленной requests, Python выдаст ошибку:
$ python fetch.py
Traceback (most recent call last):
File "fetch.py", line 1, in <module>
import requests
ModuleNotFoundError: No module named 'requests'
Чтобы эта библиотека стала доступна, нужно сначала выполнить pip install requests и установить её.
pip – это инструмент командной строки для установки пакетов, который предоставляет сам язык программирования Python.
Выполнение pip install requests приводит к следующей последовательности действий:
- Найти requests в индексе пакетов Python (Python Package Index, PyPI)
- Найти подходящий артефакт для платформы, на которой мы работаем
- Разрешить зависимости – библиотека
requestsсама зависит от других пакетов, поэтому установщик должен найти совместимые версии всех транзитивных зависимостей и установить их заранее - Скачать артефакты, затем распаковать их и скопировать файлы в нужные места нашей файловой системы
$ pip install requests
Collecting requests
Downloading requests-2.32.3-py3-none-any.whl (64 kB)
Collecting charset-normalizer<4,>=2
Downloading charset_normalizer-3.4.0-cp311-cp311-manylinux_x86_64.whl (142 kB)
Collecting idna<4,>=2.5
Downloading idna-3.10-py3-none-any.whl (70 kB)
Collecting urllib3<3,>=1.21.1
Downloading urllib3-2.2.3-py3-none-any.whl (126 kB)
Collecting certifi>=2017.4.17
Downloading certifi-2024.8.30-py3-none-any.whl (167 kB)
Installing collected packages: urllib3, idna, charset-normalizer, certifi, requests
Successfully installed certifi-2024.8.30 charset-normalizer-3.4.0 idna-3.10 requests-2.32.3 urllib3-2.2.3
Здесь видно, что у requests есть собственные зависимости, например certifi или charset-normalizer, и что их нужно установить до того, как можно будет установить сам requests.
После установки среда выполнения Python сможет найти эту библиотеку при её импорте.
$ python -c 'import requests; print(requests.__path__)'
['/usr/local/lib/python3.11/dist-packages/requests']
$ pip list | grep requests
requests 2.32.3
В разных языках программирования свои инструменты, соглашения и практики установки и публикации библиотек.
В некоторых языках, например в Rust, набор инструментов унифицирован – cargo отвечает и за сборку, и за тестирование, и за управление зависимостями, и за публикацию.
В других, например в Python, унификация происходит на уровне спецификаций – вместо единого инструмента существуют стандартизированные спецификации, описывающие, как устроена упаковка (packaging), – поэтому для каждой задачи может существовать несколько конкурирующих инструментов (pip против uv, setuptools против hatch против poetry).
А в некоторых экосистемах, например в LaTeX, дистрибутивы вроде TeX Live или MacTeX поставляются с тысячами предустановленных пакетов.
Вместе с зависимостями появляются и конфликты зависимостей.
Конфликты возникают, когда программам требуются несовместимые версии одной и той же зависимости.
Например, если tensorflow==2.3.0 требует numpy>=1.16.0,<1.19.0, а pandas==1.2.0 требует numpy>=1.16.5, то подойдёт любая версия, удовлетворяющая numpy>=1.16.5,<1.19.0.
Но если ещё какому-то пакету в вашем проекте нужен numpy>=1.19, возникает конфликт: нет ни одной версии, удовлетворяющей всем ограничениям сразу.
Такую ситуацию – когда нескольким пакетам требуются взаимно несовместимые версии общих зависимостей – принято называть адом зависимостей (dependency hell). Один из способов справиться с конфликтами – изолировать зависимости каждой программы в её собственном окружении (environment). В Python виртуальное окружение создаётся так:
$ which python
/usr/bin/python
$ pwd
/home/missingsemester
$ python -m venv venv
$ source venv/bin/activate
$ which python
/home/missingsemester/venv/bin/python
$ which pip
/home/missingsemester/venv/bin/pip
$ python -c 'import requests; print(requests.__path__)'
['/home/missingsemester/venv/lib/python3.11/site-packages/requests']
$ pip list
Package Version
------- -------
pip 24.0
Окружение можно представлять себе как отдельную, полностью самостоятельную копию среды выполнения языка со своим набором установленных пакетов. Такое виртуальное окружение, или venv, изолирует установленные зависимости от глобальной установки Python. Хорошая практика – заводить отдельное виртуальное окружение для каждого проекта, содержащее нужные ему зависимости.
Хотя многие современные операционные системы поставляются с уже установленными средами выполнения языков программирования вроде Python, изменять эти установки неразумно: ОС может полагаться на них для собственной работы. Вместо этого лучше использовать отдельные окружения.
В некоторых языках протокол установки определяется не инструментом, а спецификацией.
В Python PEP 517 описывает интерфейс системы сборки, а PEP 621 задаёт, как метаданные проекта хранятся в pyproject.toml.
Это позволило разработчикам превзойти pip и создать более эффективные инструменты вроде uv. Чтобы установить uv, достаточно выполнить pip install uv.
У uv тот же интерфейс, что и у pip, но работает он значительно быстрее:
$ uv pip install requests
Resolved 5 packages in 12ms
Prepared 5 packages in 0.45ms
Installed 5 packages in 8ms
+ certifi==2024.8.30
+ charset-normalizer==3.4.0
+ idna==3.10
+ requests==2.32.3
+ urllib3==2.2.3
Мы настоятельно рекомендуем при любой возможности использовать
uv pipвместоpip: это радикально сокращает время установки.
Помимо изоляции зависимостей, окружения позволяют держать разные версии среды выполнения самого языка программирования.
$ uv venv --python 3.12 venv312
Using CPython 3.12.7
Creating virtual environment at: venv312
$ source venv312/bin/activate && python --version
Python 3.12.7
$ uv venv --python 3.11 venv311
Using CPython 3.11.10
Creating virtual environment at: venv311
$ source venv311/bin/activate && python --version
Python 3.11.10
Это помогает, когда нужно протестировать код на нескольких версиях Python или когда проекту требуется какая-то конкретная версия.
В некоторых языках программирования каждый проект автоматически получает собственное окружение для своих зависимостей, и создавать его вручную не нужно, но принцип тот же. Кроме того, в большинстве современных языков есть механизм, позволяющий держать на одной системе несколько версий языка и указывать, какую из них использовать для отдельных проектов.
Артефакты и упаковка
В разработке ПО различают исходный код и артефакты (artifacts). Разработчики пишут и читают исходный код, а артефакты – это упакованные, готовые к распространению результаты, полученные из этого исходного кода, – их можно устанавливать или разворачивать.
Артефакт может быть чем-то совсем простым, вроде файла с кодом, который мы запускаем, или чем-то сложным, вроде целой виртуальной машины, содержащей всё хозяйство, нужное приложению для работы.
Рассмотрим пример: в текущем каталоге у нас есть Python-файл greet.py:
$ cat greet.py
def greet(name):
return f"Hello, {name}!"
$ python -c "from greet import greet; print(greet('World'))"
Hello, World!
$ cd /tmp
$ python -c "from greet import greet; print(greet('World'))"
ModuleNotFoundError: No module named 'greet'
Стоит перейти в другой каталог – и импорт перестаёт работать, потому что Python ищет модули только в определённых местах (текущий каталог, установленные пакеты и пути из PYTHONPATH). Упаковка (packaging) решает эту проблему: код устанавливается в известное место.
В Python упаковать библиотеку – значит собрать артефакт, с помощью которого установщики пакетов вроде pip или uv смогут установить нужные файлы.
Артефакты в Python называются wheels («колёсами») и содержат всё необходимое для установки пакета: файлы с кодом, метаданные пакета (имя, версию, зависимости) и указания, куда именно в окружении класть файлы.
Чтобы собрать артефакт, нужно написать файл проекта (его часто называют манифестом), в котором описаны особенности проекта, требуемые зависимости, версия пакета и прочая информация. В Python для этого используется pyproject.toml.
pyproject.toml– современный и рекомендуемый способ. Более ранние подходы к упаковке, такие какrequirements.txtилиsetup.py, всё ещё поддерживаются, но по возможности предпочитайтеpyproject.toml.
Вот минимальный pyproject.toml для библиотеки, которая заодно предоставляет инструмент командной строки:
[project]
name = "greeting"
version = "0.1.0"
description = "A simple greeting library"
dependencies = ["typer>=0.9"]
[project.scripts]
greet = "greeting:cli"
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
Библиотека typer – популярный Python-пакет для создания интерфейсов командной строки с минимумом шаблонного кода.
А вот соответствующий greeting.py:
import typer
def greet(name: str) -> str:
return f"Hello, {name}!"
def cli():
typer.run(greet)
if __name__ == "__main__":
cli()
С этим файлом мы можем собрать wheel:
$ uv build
Building source distribution...
Building wheel from source distribution...
Successfully built dist/greeting-0.1.0.tar.gz
Successfully built dist/greeting-0.1.0-py3-none-any.whl
$ ls dist/
greeting-0.1.0-py3-none-any.whl
greeting-0.1.0.tar.gz
Файл .whl – это и есть wheel (zip-архив определённой структуры), а .tar.gz – дистрибутив исходного кода (source distribution) для систем, которым нужно собирать пакет из исходников.
Можно заглянуть внутрь wheel и посмотреть, что именно попадает в пакет:
$ unzip -l dist/greeting-0.1.0-py3-none-any.whl
Archive: dist/greeting-0.1.0-py3-none-any.whl
Length Date Time Name
--------- ---------- ----- ----
150 2024-01-15 10:30 greeting.py
312 2024-01-15 10:30 greeting-0.1.0.dist-info/METADATA
92 2024-01-15 10:30 greeting-0.1.0.dist-info/WHEEL
9 2024-01-15 10:30 greeting-0.1.0.dist-info/top_level.txt
435 2024-01-15 10:30 greeting-0.1.0.dist-info/RECORD
--------- -------
998 5 files
Теперь, если передать этот wheel кому-то другому, он сможет установить его так:
$ uv pip install ./greeting-0.1.0-py3-none-any.whl
$ greet Alice
Hello, Alice!
Это установит собранную нами ранее библиотеку в его окружение, включая инструмент командной строки greet.
У этого подхода есть ограничения. В частности, если наша библиотека зависит от платформенно-специфичных библиотек, например CUDA для ускорения на GPU, то наш артефакт будет работать только на системах, где эти библиотеки установлены, и нам, возможно, придётся собирать отдельные wheel для разных платформ (Linux, macOS, Windows) и архитектур (x86, ARM).
При установке программ важно различать установку из исходников и установку готового бинарного файла. Установка из исходников означает, что вы скачиваете оригинальный код и компилируете его на своей машине – для этого нужны установленные компилятор и инструменты сборки, а для больших проектов это может занять немало времени.
Установка готового бинарника означает, что вы скачиваете артефакт, который уже скомпилировал кто-то другой, – быстрее и проще, но бинарник должен подходить под вашу платформу и архитектуру. Например, на странице релизов ripgrep выложены готовые бинарники для Linux (x86_64, ARM), macOS (Intel, Apple Silicon) и Windows.
Релизы и версионирование
Код пишется непрерывно, но выпускается дискретно. В разработке ПО есть чёткое разделение между средой разработки и продакшеном. Прежде чем код будет доставлен в продакшен, он должен доказать свою работоспособность в среде разработки. Процесс выпуска релиза включает множество шагов, в том числе тестирование, управление зависимостями, версионирование, конфигурацию, развёртывание и публикацию.
Программные библиотеки не статичны: со временем они развиваются, получают исправления и новые возможности. Мы отслеживаем эту эволюцию с помощью дискретных идентификаторов версий, каждый из которых соответствует состоянию библиотеки в определённый момент времени. Изменения в поведении библиотеки бывают самыми разными: от патчей, исправляющих некритичную функциональность, и новых возможностей, расширяющих её, до изменений, ломающих обратную совместимость. Список изменений (changelog) документирует, что именно привносит новая версия, – с помощью таких документов разработчики сообщают об изменениях, связанных с новым релизом.
Однако следить за текущими изменениями в каждой отдельной зависимости непрактично, тем более если учесть транзитивные зависимости – то есть зависимости наших зависимостей.
Всё дерево зависимостей проекта можно посмотреть командой
uv tree, которая показывает все пакеты и их транзитивные зависимости в виде дерева.
Чтобы упростить эту задачу, существуют соглашения о том, как версионировать ПО, и одно из самых распространённых – семантическое версионирование, или SemVer. При семантическом версионировании версия имеет идентификатор вида MAJOR.MINOR.PATCH, где каждое из значений – целое число. Если коротко, то повышение:
- PATCH (например, 1.2.3 → 1.2.4) должно содержать только исправления ошибок и быть полностью обратно совместимым
- MINOR (например, 1.2.3 → 1.3.0) добавляет новую функциональность с сохранением обратной совместимости
- MAJOR (например, 1.2.3 → 2.0.0) означает ломающие изменения, которые могут потребовать правок в коде
Это упрощение, и мы советуем прочитать полную спецификацию SemVer, чтобы понять, например, почему переход с 0.1.3 на 0.2.0 может привести к ломающим изменениям или что означает 1.0.0-rc.1. Упаковка в Python поддерживает семантическое версионирование «из коробки», поэтому, указывая версии зависимостей, мы можем использовать разные спецификаторы:
В pyproject.toml есть несколько способов ограничить диапазон совместимых версий наших зависимостей:
[project]
dependencies = [
"requests==2.32.3", # Exact version - only this specific version
"click>=8.0", # Minimum version - 8.0 or newer
"numpy>=1.24,<2.0", # Range - at least 1.24 but less than 2.0
"pandas~=2.1.0", # Compatible release - >=2.1.0 and <2.2.0
]
Спецификаторы версий есть во многих менеджерах пакетов (npm, cargo и т. д.), хотя точная семантика у них различается. Оператор ~= – это питоновский оператор «совместимого релиза»: ~=2.1.0 означает «любая версия, совместимая с 2.1.0», то есть >=2.1.0 и <2.2.0. Это примерно соответствует оператору «крышка» (^) в npm и cargo, который следует понятию совместимости из SemVer.
Не всё ПО использует семантическое версионирование. Распространённая альтернатива – календарное версионирование (Calendar Versioning, CalVer), где версии основаны на датах выпуска, а не на семантическом смысле. Например, Ubuntu использует версии вида 24.04 (апрель 2024) и 24.10 (октябрь 2024). CalVer позволяет легко понять, насколько стар релиз, но ничего не сообщает о совместимости. Наконец, семантическое версионирование не безупречно: иногда мейнтейнеры непреднамеренно вносят ломающие изменения в minor- или patch-релизах.
Воспроизводимость
В современной разработке ПО код, который вы пишете, стоит поверх немалого числа слоёв абстракции. Это и среда выполнения вашего языка программирования, и сторонние библиотеки, и операционная система, и даже само железо. Любое различие в любом из этих слоёв может изменить поведение вашего кода или вовсе помешать ему работать так, как задумано. Более того, даже различия в нижележащем оборудовании влияют на вашу возможность доставлять ПО пользователям.
Закрепление (pinning) библиотеки – это указание точной версии вместо диапазона, например requests==2.32.3 вместо requests>=2.0.
Часть работы менеджера пакетов – учесть все ограничения, заданные зависимостями (и транзитивными зависимостями), и выдать корректный список версий, который удовлетворяет всем ограничениям. Этот конкретный список версий затем можно сохранить в файл ради воспроизводимости; такие файлы называют lock-файлами.
$ uv lock
Resolved 12 packages in 45ms
$ cat uv.lock | head -20
version = 1
requires-python = ">=3.11"
[[package]]
name = "certifi"
version = "2024.8.30"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/...", hash = "sha256:..." }
wheels = [
{ url = "https://files.pythonhosted.org/...", hash = "sha256:..." },
]
...
Одно принципиальное различие, о котором нужно помнить, говоря о версионировании зависимостей и воспроизводимости, – это разница между библиотеками и приложениями/сервисами. Библиотека предназначена для того, чтобы её импортировал и использовал другой код, у которого могут быть свои зависимости, поэтому слишком жёсткие ограничения на версии могут вызвать конфликты с другими зависимостями пользователя. Приложения же и сервисы – конечные потребители ПО, и свою функциональность они обычно предоставляют через пользовательский интерфейс или API, а не через программный интерфейс. Для библиотек хорошая практика – указывать диапазоны версий, чтобы максимизировать совместимость с широкой экосистемой пакетов. Для приложений закрепление точных версий обеспечивает воспроизводимость – все, кто запускает приложение, используют в точности одни и те же зависимости.
Для проектов, которым нужна максимальная воспроизводимость, инструменты вроде Nix и Bazel предоставляют герметичные (hermetic) сборки – в них каждый входной компонент, включая компиляторы, системные библиотеки и даже само окружение сборки, закреплён и адресуется по содержимому. Это гарантирует побитово идентичный результат независимо от того, когда и где выполняется сборка.
С помощью NixOS можно даже управлять всей установкой своего компьютера: тогда вы сможете без труда разворачивать новые копии своей настроенной системы и управлять всей их конфигурацией через файлы конфигурации под контролем версий.
Вечное противоречие в разработке ПО: новые версии программ вносят поломки – намеренно или нет, – а с другой стороны, в старых версиях со временем накапливаются уязвимости безопасности. Справиться с этим можно, используя конвейеры непрерывной интеграции (подробнее – в лекции Качество кода и CI), которые тестируют наше приложение с новыми версиями ПО, и настроив автоматику, которая замечает выход новых версий наших зависимостей, например Dependabot.
Даже при наличии CI-тестов при обновлении версий ПО всё равно случаются проблемы – часто из-за неизбежного расхождения между окружениями разработки и продакшена. В таких обстоятельствах лучше всего иметь план отката (rollback): обновление версии отменяется, и вместо него заново разворачивается заведомо рабочая версия.
Виртуальные машины и контейнеры
По мере того как вы начинаете полагаться на всё более сложные зависимости, вполне вероятно, что зависимости вашего кода выйдут за границы того, с чем способен справиться менеджер пакетов. Одна из частых причин – необходимость взаимодействовать с конкретными системными библиотеками или драйверами оборудования. Например, в научных вычислениях и ИИ программам часто нужны специализированные библиотеки и драйверы, чтобы задействовать GPU. Многие зависимости системного уровня (драйверы GPU, конкретные версии компиляторов, разделяемые библиотеки вроде OpenSSL) по-прежнему требуют установки на уровне всей системы.
Традиционно эта более широкая проблема зависимостей решалась с помощью виртуальных машин (Virtual Machines, VM). Виртуальные машины абстрагируют компьютер целиком и предоставляют полностью изолированное окружение с собственной выделенной операционной системой. Более современный подход – контейнеры: они упаковывают приложение вместе с его зависимостями, библиотеками и файловой системой, но используют ядро операционной системы хоста, а не виртуализируют весь компьютер. Контейнеры легче виртуальных машин, потому что делят с хостом ядро, – за счёт этого они быстрее запускаются и эффективнее работают.
Самая популярная контейнерная платформа – Docker. Docker ввёл стандартизированный способ собирать, распространять и запускать контейнеры. Под капотом Docker использует containerd в качестве среды выполнения контейнеров – это отраслевой стандарт, который применяют и другие инструменты, например Kubernetes.
Запустить контейнер несложно. Например, чтобы запустить интерпретатор Python внутри контейнера, мы используем docker run (флаги -it делают контейнер интерактивным и подключают к нему терминал; когда вы выходите, контейнер останавливается).
$ docker run -it python:3.12 python
Python 3.12.7 (main, Nov 5 2024, 02:53:25) [GCC 12.2.0] on linux
>>> print("Hello from inside a container!")
Hello from inside a container!
На практике ваша программа может зависеть от всей файловой системы целиком. Чтобы справиться с этим, можно использовать образы контейнеров, которые поставляют в качестве артефакта всю файловую систему приложения. Образы контейнеров создаются программно. В Docker мы в точности описываем зависимости, системные библиотеки и конфигурацию образа с помощью синтаксиса Dockerfile:
FROM python:3.12
RUN apt-get update
RUN apt-get install -y gcc
RUN apt-get install -y libpq-dev
RUN pip install numpy
RUN pip install pandas
COPY . /app
WORKDIR /app
RUN pip install .
Важное различие: образ (image) Docker – это упакованный артефакт (что-то вроде шаблона), а контейнер – это запущенный экземпляр этого образа. Из одного образа можно запустить несколько контейнеров. Образы собираются послойно: каждая инструкция (FROM, RUN, COPY и т. д.) в Dockerfile создаёт новый слой. Docker кэширует эти слои, так что если вы измените одну строку в Dockerfile, пересобрать придётся только этот слой и все последующие.
У предыдущего Dockerfile есть несколько проблем: он использует полный образ Python вместо slim-варианта, выполняет отдельные команды RUN, создавая лишние слои, версии не закреплены, и он не очищает кэши менеджера пакетов, поставляя ненужные файлы. Среди других частых ошибок – небезопасный запуск контейнеров от root и случайное попадание секретов в слои.
Вот улучшенная версия:
FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc libpq-dev && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
COPY pyproject.toml uv.lock ./
RUN uv sync --locked --no-dev --no-install-project
COPY . .
RUN uv sync --locked --no-dev
В предыдущем примере видно, что вместо установки uv из исходников мы копируем готовый бинарник из образа ghcr.io/astral-sh/uv:latest. Это называется паттерном builder («сборщик»). Благодаря ему нам не нужно поставлять все инструменты, необходимые для компиляции кода, – только итоговый бинарник, который нужен для запуска приложения (в данном случае uv).
У Docker есть важные ограничения, о которых стоит знать. Во-первых, образы контейнеров часто привязаны к платформе – образ, собранный для linux/amd64, не запустится нативно на linux/arm64 (Mac на Apple Silicon) без эмуляции, а она медленная. Во-вторых, контейнерам Docker нужно ядро Linux, поэтому на macOS и Windows Docker на самом деле запускает под капотом лёгкую виртуальную машину с Linux, что добавляет накладных расходов. В-третьих, изоляция у Docker слабее, чем у виртуальных машин: контейнеры делят ядро с хостом, а это проблема безопасности в мультитенантных (multi-tenant) средах.
Сегодня всё больше проектов используют nix, чтобы управлять даже «общесистемными» библиотеками и приложениями на уровне отдельного проекта с помощью nix flakes.
Конфигурация
Программное обеспечение по своей природе настраиваемо. В лекции о среде командной строки мы видели, как программы получают опции через флаги, переменные окружения или даже конфигурационные файлы, они же dotfiles. Это верно и для более сложных приложений, и для управления конфигурацией в больших масштабах существуют устоявшиеся паттерны. Конфигурация программы не должна быть зашита в код – её нужно передавать во время выполнения. Пара распространённых способов – переменные окружения и конфигурационные файлы.
Вот пример приложения, которое настраивается через переменные окружения:
import os
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///local.db")
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
API_KEY = os.environ["API_KEY"] # Required - will raise if not set
Приложение можно настраивать и через конфигурационный файл (например, Python-программа может загружать его через yaml.load) – вот такой config.yaml:
database:
url: "postgresql://localhost/myapp"
pool_size: 5
server:
host: "0.0.0.0"
port: 8080
debug: false
Хорошее эмпирическое правило для конфигурации: одну и ту же кодовую базу должно быть можно развернуть в разных средах (разработка, staging, продакшен), меняя только конфигурацию и никогда – код.
Среди множества параметров конфигурации нередко встречаются чувствительные данные, например ключи API. С секретами нужно обращаться осторожно, чтобы случайно их не раскрыть, и их ни в коем случае нельзя включать в систему контроля версий.
Сервисы и оркестрация
Современные приложения редко существуют в изоляции. Типичному веб-приложению могут понадобиться база данных для постоянного хранения, кэш для производительности, очередь сообщений для фоновых задач и разнообразные другие вспомогательные сервисы. Вместо того чтобы сваливать всё в одно монолитное приложение, современные архитектуры часто разбивают функциональность на отдельные сервисы, которые можно разрабатывать, разворачивать и масштабировать независимо.
Например, если мы решили, что нашему приложению пригодится кэш, вместо того чтобы писать свой, мы можем воспользоваться существующими проверенными в бою решениями вроде Redis или Memcached. Мы могли бы встроить Redis в зависимости нашего приложения, собирая его как часть контейнера, но тогда пришлось бы согласовывать все зависимости между Redis и нашим приложением, что может оказаться сложно или вовсе невозможно. Вместо этого мы можем развернуть каждое приложение отдельно, в собственном контейнере. Это обычно называют микросервисной архитектурой: каждый компонент работает как независимый сервис, который общается с другими по сети, как правило, через HTTP API.
Docker Compose – это инструмент для описания и запуска многоконтейнерных приложений. Вместо того чтобы управлять контейнерами по отдельности, вы объявляете все сервисы в одном YAML-файле и оркестрируете их вместе. Теперь наше полное приложение состоит более чем из одного контейнера:
# docker-compose.yml
services:
web:
build: .
ports:
- "8080:8080"
environment:
- REDIS_URL=redis://cache:6379
depends_on:
- cache
cache:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
redis_data:
По команде docker compose up оба сервиса запускаются вместе, и веб-приложение может подключиться к Redis по имени хоста cache (внутренний DNS Docker автоматически разрешает имена сервисов).
Docker Compose позволяет нам объявить, как мы хотим развернуть один или несколько сервисов, и берёт на себя оркестрацию: совместный запуск, настройку сети между ними и управление общими томами для постоянного хранения данных.
Для продакшен-развёртываний обычно хочется, чтобы сервисы docker compose автоматически запускались при загрузке системы и перезапускались при сбое. Распространённый подход – управлять развёртыванием docker compose с помощью systemd:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
[Install]
WantedBy=multi-user.target
Этот unit-файл systemd гарантирует, что ваше приложение запустится при загрузке системы (после того как Docker будет готов), и даёт стандартные средства управления вроде systemctl start myapp, systemctl stop myapp и systemctl status myapp.
По мере того как требования к развёртыванию усложняются – нужна масштабируемость на несколько машин, отказоустойчивость при падении сервисов и гарантии высокой доступности – организации обращаются к продвинутым платформам оркестрации контейнеров вроде Kubernetes (k8s), которые способны управлять тысячами контейнеров в кластерах машин. При этом порог входа у Kubernetes высок, а эксплуатационные накладные расходы значительны, так что для небольших проектов он часто избыточен.
Такая многоконтейнерная схема возможна отчасти потому, что современные сервисы общаются друг с другом через стандартизированные API – HTTP REST API. Например, всякий раз, когда программа взаимодействует с провайдером LLM вроде OpenAI или Anthropic, под капотом она отправляет HTTP-запрос на их серверы и разбирает ответ:
$ curl https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "content-type: application/json" \
-H "anthropic-version: 2023-06-01" \
-d '{"model": "claude-sonnet-4-20250514", "max_tokens": 256,
"messages": [{"role": "user", "content": "Explain containers vs VMs in one sentence."}]}'
Публикация
Когда вы показали, что ваш код работает, возможно, вам захочется распространить его, чтобы другие могли его скачать и установить. Распространение принимает множество форм и неразрывно связано с языком программирования и окружениями, с которыми вы работаете.
Простейшая форма распространения – выложить артефакты, чтобы люди могли скачать их и установить локально.
Такой способ по-прежнему распространён: его можно встретить, например, в архиве пакетов Ubuntu, который по сути представляет собой HTTP-листинг каталога с .deb-файлами.
Сегодня GitHub стал де-факто платформой для публикации исходного кода и артефактов. Исходный код обычно и так открыт для всех, а GitHub Releases позволяет мейнтейнерам прикреплять к версиям, помеченным тегами, готовые бинарники и другие артефакты.
Менеджеры пакетов иногда поддерживают установку прямо с GitHub – либо из исходного кода, либо из готового wheel:
# Install from source (will clone and build)
$ pip install git+https://github.com/psf/requests.git
# Install from a specific tag/branch
$ pip install git+https://github.com/psf/requests.git@v2.32.3
# Install a wheel directly from a GitHub release
$ pip install https://github.com/user/repo/releases/download/v1.0/package-1.0-py3-none-any.whl
Более того, некоторые языки, например Go, используют децентрализованную модель распространения – вместо центрального репозитория пакетов модули Go распространяются напрямую из репозиториев с их исходным кодом.
Пути модулей вроде github.com/gorilla/mux указывают, где живёт код, а go get скачивает его прямо оттуда. Впрочем, у большинства менеджеров пакетов, таких как pip, cargo или brew, есть центральные индексы уже упакованных проектов – так их проще распространять и устанавливать. Если мы запустим
$ uv pip install requests --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: requests==2.32.5 [compatible] (requests-2.32.5-py3-none-any.whl)
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl.metadata
DEBUG No cache entry for: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
то увидим, откуда именно скачивается wheel для requests. Обратите внимание на py3-none-any в имени файла – это значит, что wheel работает с любой версией Python 3, на любой ОС и любой архитектуре. Для пакетов с компилируемым кодом wheel платформенно-специфичен:
$ uv pip install numpy --verbose --no-cache 2>&1 | grep -F '.whl'
DEBUG Selecting: numpy==2.2.1 [compatible] (numpy-2.2.1-cp312-cp312-macosx_14_0_arm64.whl)
Здесь cp312-cp312-macosx_14_0_arm64 означает, что этот wheel собран именно для CPython 3.12 на macOS 14+ для ARM64 (Apple Silicon). Если вы на другой платформе, pip скачает другой wheel или соберёт пакет из исходного кода.
И наоборот: чтобы люди могли найти созданный нами пакет, его нужно опубликовать в одном из таких реестров.
В Python основной реестр – Python Package Index (PyPI).
Как и в случае с установкой, публиковать пакеты можно несколькими способами. Команда uv publish предоставляет современный интерфейс для загрузки пакетов на PyPI:
$ uv publish --publish-url https://test.pypi.org/legacy/
Publishing greeting-0.1.0.tar.gz
Publishing greeting-0.1.0-py3-none-any.whl
Здесь мы используем TestPyPI – отдельный реестр пакетов, предназначенный для того, чтобы протестировать свой рабочий процесс публикации, не замусоривая настоящий PyPI. После загрузки пакет можно установить с TestPyPI:
$ uv pip install --index-url https://test.pypi.org/simple/ greeting
Ключевой вопрос при публикации программ – доверие. Как пользователям убедиться, что скачиваемый пакет действительно исходит от вас и его никто не подменил? Реестры пакетов используют контрольные суммы для проверки целостности, а некоторые экосистемы поддерживают подпись пакетов – криптографическое доказательство авторства.
У разных языков свои реестры пакетов: crates.io для Rust, npm для JavaScript, RubyGems для Ruby и Docker Hub для образов контейнеров. А для приватных или внутренних пакетов организации часто разворачивают собственные репозитории пакетов (например, приватный сервер PyPI или приватный Docker-реестр) либо используют управляемые решения от облачных провайдеров.
Чтобы развернуть веб-сервис в интернете, понадобится дополнительная инфраструктура: регистрация доменного имени, настройка DNS, чтобы домен указывал на ваш сервер, и зачастую обратный прокси вроде nginx, который берёт на себя HTTPS и маршрутизацию трафика. Для более простых случаев – документации или статических сайтов – GitHub Pages предоставляет бесплатный хостинг прямо из репозитория.
Упражнения
- Сохраните своё окружение командой
printenvв файл, создайте venv, активируйте его, сохраните выводprintenvв другой файл и выполнитеdiff before.txt after.txt. Что изменилось в окружении? Почему оболочка предпочитает venv? (Подсказка: посмотрите на$PATHдо и после активации.) Запуститеwhich deactivateи порассуждайте, что делает bash-функция deactivate. - Создайте Python-пакет с
pyproject.tomlи установите его в виртуальное окружение. Создайте lock-файл и изучите его. - Установите Docker и соберите с его помощью сайт курса «Пропущенный семестр» локально, используя docker compose.
- Напишите Dockerfile для простого Python-приложения. Затем напишите
docker-compose.yml, который запускает ваше приложение вместе с кэшем Redis. - Опубликуйте Python-пакет в TestPyPI (не публикуйте в настоящий PyPI, если только вашим пакетом действительно не стоит поделиться!). Затем соберите Docker-образ с этим пакетом и отправьте его в
ghcr.io. - Сделайте сайт с помощью GitHub Pages. Дополнительные (не)баллы: настройте для него собственный домен.
Лицензия CC BY-NC-SA.