Проблема: миллион разных окружений

Как фулстек разработчику, мне доводилось работать с проектами на совершенно разных технологиях. Как правило, нужно было поправить небольшой баг или сделать небольшую фичу. Для задач такого разряда стек технологий обычно не имеет значения: отладка примерно одинаковая что на JavaScript, что на Haskell, Go или Python.

Написать немного кода мне, в общем-то, никогда не было сложно на любом языке, с которым я работал.

Но вот что всегда было настоящей проблемой — это запустить и протестировать проект. На это запросто уходили дни: найти нужные версии компиляторов/интерпретаторов, дебаггера, пакетного менеджера и всякого сопутствующего тулинга.

Более того, когда ты постоянно переключаешься между 5-10 кодовыми базами, даже если стек примерно одинаковый, его актуальность может отличаться.

В одном проекте у тебя node@22, в другом - node@16, и нужно постоянно прибегать к менеджерам версий конкретного инструмента, если он есть (в JS мире, благо, есть nvm)

В Go я использую много инструментов, которые собираются разными версиями компилятора: например, mockery, golangci-lint, и несмотря на то, что Go обещает сохранять обратную совместимость до конца, данный тулинг собирается разными версиями Go и в нём со временем меняется конфигурация. В итоге, обновившись в одной кодовой базе на свежий go@1.26, я вынужден буду обновить весь тулинг в более старых кодовых базах, либо сделать даунгрейд Go на своей машине. Ни того, ни другого я не хочу делать, когда мне нужно поправить 2 строчки кода и протестировать.

Просто пиши нормальный README

Конечно, можно и нужно писать документацию по проекту. Но годами проверенная (и грустная) правда в том, что документация устаревает слишком быстро, и никто её не обновляет.

Когда я прихожу в новый проект и пытаюсь запустить его, я сталкиваюсь с ситуацией, что с момента последней актуализации README поменялось много вводных, оставшихся в голове у автора. Как итог — приходится всё равно тратить много времени на установку нужных зависимостей, попутно обновляя документацию, которая вновь безнадежно устареет к приходу новичка через пару лет.

За годы своей работы я видел актуальный README только в популярных опен-сорс проектах, но не во внутренних проектах компании.

Можно пытаться следить за актуальностью таких вещей, но сама необходимость принуждать себя это делать говорит о несовершенстве решения.

Хочется окружения "с одной кнопки" на любом стеке.

В идеале, я бы хотел, чтобы моё окружение запускалось "с одной кнопки": зашел в директорию, запустил редактор и можешь работать. Не устанавливая вручную ничего из необходимого на свой компьютер. Некоторый конфиг, который бы описывал всё необходимое для работы.

Цель — вообще не думать о том, что нужно устанавливать, чтобы начать работать над проектом.

Хочется, чтобы окружение хранилось в проекте в виде некоторого конфига, который применяется при входе в директорию. Это было бы ценно и для команды: не только я сам не буду тратить время на установку окружения, но и все мои коллеги. Потенциально большая экономия времени, учитывая, что в среднем у меня уходит на запуск чего-то в незнакомом стеке примерно день-два (и, соответственно, каждому коллеге я могу потенциально сэкономить столько времени).

Становится ясно, что нужен некий конфиг с нужными зависимостями проекта (как библиотек самого языка, так и нужных бинарей).

Почему бы просто не использовать пакетный менеджер языка?

У каждого зрелого языка есть свой менеджер зависимостей: npm (Node.js), cargo (Rust), pip (Python), composer (PHP). Они управляют зависимостями, написанными на том же языке, которые непосредственно импортируются в код проекта.

В то же время они никак не помогут быстро развернуть всё остальное, что также требуется для разработки, но не импортируется непосредственно в код проекта:

  • LSP сервер для подсказок в IDE/редакторе

  • Сам компилятор ЯП и его пакетный менеджер (например, нужную версию Node.js и npm)

  • Дебаггер, пакетный менеджер языка

  • Сторонние CLI (например для psql для PostgreSQL).

Всё это приходится устанавливать на свой компьютер, и ещё тяжелее, когда проект становится легаси (а это судьба абсолютно любого проекта). Спустя 5-10 лет довольно сложно найти древние зависимости подходящих версий так, чтобы получилось проект хотя бы запустить.

Просто не надо сидеть на легаси - обновляйся!

Проблемы с даже запуском проекта через 5-10 лет могут навести на мысль о том, что надо почаще переходить на более модные технологии, и не доводить до такой судьбы.

Увы, это не поможет: любые модные технологии через 5-10 лет станут таким же легаси. Будут стеки современнее, чем сегодняшние Rust и Go, и тоже никто не будет понимать, как их запустить.

Решение: описание среды через Nix

Есть такая замечательная штука — Nix. Это пакетный менеджер, построенный на принципах декларативности и полной воспроизводимости. В данной статье мы не будем обсуждать то, каким образом достигается полная воспроизводимость — мы сфокусируемся на практической пользе.

Как я использую Nix в кодовых базах

Для того, чтобы любой разработчик смог запустить проект "с одной кнопки", в проекте должен лежать файл flake.nix — это список программ, которые загрузятся для работы над проектом.

Так выглядит файл, который разворачивает окружение для компьютеров на Linux (x86 и ARM) и MacOS (ARM):

{
  description = "simple Go dev shell from nixos-unstable, frozen by flake.lock";

  # Откуда скачиваем пакеты (можно добавлять и свои приватные репозитории)
  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-utils.url = "github:numtide/flake-utils";
  };

  outputs = { self, nixpkgs, flake-utils, ... }:
    # С какими системами должны быть совместимы пакеты
    flake-utils.lib.eachSystem [
      "aarch64-darwin"
      "x86_64-linux"
      "aarch64-linux"
    ]
    (system:
      let
        pkgs = import nixpkgs {
          inherit system;
        };
        hostShell = builtins.getEnv "SHELL";
        myShell = if hostShell != "" then hostShell else "${pkgs.zsh}/bin/zsh";
      in
      {
        # Какие пакеты устанавливаем локально
        devShells.default = pkgs.mkShell {
          buildInputs = [
            pkgs.go
            pkgs.golangci-lint
            pkgs.delve
            pkgs.goose
            pkgs.sqlc
            pkgs.go-mockery
            pkgs.gopls
			pkgs.go-jet
            pkgs.golangci-lint-langserver
            pkgs.postgresql_16
            pkgs.tbls
          ];

          # не обращать внимания: специфичные вещи, нужные, чтобы работал дебаггер в Go
		  hardeningDisable = [ "fortify" ];
        };

        # Какие пакеты устанавливаем в CI/CD
        devShells.ci = pkgs.mkShell {
          buildInputs = [
            pkgs.go
            pkgs.golangci-lint
          ];
        };
      }
    );
}

Синтаксис, конечно, на первый взгляд выглядит тяжело, но самое главное находится в секции devShells.default — это то, что нужно для локальной разработки проекта. Там перечислены мои типичные зависимости для разработки на Go: кодогенераторы, линтеры, LSP, дебаггер.

Все эти вещи приходится ставить по отдельности для Go, как и для многих других языков, в которых нет волшебной команды вроде npx do-something-without-installation.

Далее мне достаточно написать nix develop и всё перечисленное само установится на компьютер. Обычно это занимает до 5 минут в первый раз (не считая самой настройки конфига), пока я пью кофе - точно быстрее, чем убить пол дня на поиск всего.

А также, единожды инвестировав время в настройку конфига flake.nix, у моих коллег появляется возможность тратить в общей сумме не более 5 минут для запуска проекта.

Таким образом можно описывать любые окружения для любых языков, хоть для Go, хоть для Haskell (среди хаскеллистов Nix особенно популярен, так как версия компилятора влияет на всё, вплоть до миноров), хоть для Ruby, хоть для Perl — и быть уверенным, что у всех это окружение далее запустится одинаково, так как оно зафиксируется в flake.lock.

Но Nix всё равно придётся установить

Чтобы это чудо работало и нам не пришлось устанавливать кучу тулинга, нам всё равно придётся установить одну единственную программу — сам Nix.

Или, может, даже его не устанавливать?

В общем, можно даже не устанавливать Nix на свой компьютер — тем более, что подобные программы, что неконтролируемо скачивают кучу всего, выглядят не очень безопасно — с этим сложно спорить (хотя мы живём в эру ИИ-агентов, которые чего только не делают на наших компьютерах).

Nix внутри контейнера (Docker / OCI / Podman)

Конечно, довольно страшно наблюдать, как при входе в директорию на твой компьютер автоматически устанавливается куча разных бинарей.

Нужна изоляция, чтобы было не страшно устанавливать что угодно, и чтобы, не дай бог, никакие программы не запустились и не украли какие-нибудь токены в $HOME (ну мало-ли, всегда полезно об этом переживать). Чтобы у программ вообще не было доступа за пределы рабочей директории.

Да и, будем честны, просто лень ставить еще одну программу (сам Nix). Цель статьи - настроить всё так, чтобы не ставить никаких новых программ для работы больше никогда в жизни. Но контейнерами, скорее всего, все уже и так пользуются, и у всех наверняка уже есть Docker или Podman.

Вот как может выглядеть наш Dockerfile с Nix:

FROM ubuntu:24.04

# Устанавливаем необходимые зависимости
RUN apt-get update && apt-get install -y \
    curl \
    xz-utils \
    git \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# Задаем переменные окружения, необходимые для установки и работы Nix от root
ENV USER=root
ENV PATH="/root/.nix-profile/bin:$PATH"

# Устанавливаем Nix в single-user режиме (без демона, что идеально для Docker)
RUN curl -L https://nixos.org/nix/install | sh -s -- --no-daemon

# Включаем экспериментальные функции: nix-command и flakes
RUN mkdir -p /root/.config/nix && \
echo "experimental-features = nix-command flakes" > /root/.config/nix/nix.conf

# Создаем рабочую директорию
WORKDIR /app

# По умолчанию запускаем bash, чтобы можно было сразу ввести nix develop
CMD ["/bin/bash"]

Далее нам нужно собрать image и выполнить внутри него nix develop, немного подождать и магическим образом внутри контейнера появится весь необходимый тулинг.

А как это использовать с моей IDE / редактором?

Для того, чтобы можно было жить "внутри" контейнера, работая при этом в вашем привычном редакторе / IDE, нужно включить Remote Development — это фича, которая позволяет работать на удаленных серверах. Поскольку контейнер это тоже что-то вроде "удаленной машины", в него тоже можно таким же образом подключиться.

Почти все современные IDE и редакторы - клиент-серверные. Клиентская часть запускается на вашем компьютере, сохраняя все ваши настройки (тему, плагины и подобное), а внутри контейнера проинициализируется серверная часть (потому что ей нужен доступ до бинарей дебаггера и LSP для подсказок, и подобных вещей — они работают внутри контейнера).

Для того, чтобы подключиться из IDE / редактора в контейнер, удобнее всего использовать открытый стандарт DevContainers: по сути, это адаптер между вашим контейнером и IDE, который прячет детали настройки "серверной" части вашей IDE внутри контейнера. Это еще один небольшой слой абстракции, который позволяет уже не выполнять команды в терминале, а нажать одну кнопку в вашем редакторе, и запустить контейнер с нужной средой.

Большинство IDE / редакторов работают с DevContainers из коробки (VS Code, Zed, Jetbrains IDE). Для некоторых, возможно, потребуется плагин. А с терминальными редакторами (Vim/Neovim, Helix, Emacs) вообще проще жить внутри контейнера.

Чтобы ваша IDE предложила использовать DevContainers, потребуется еще небольшой конфиг — ниже минимальный .devcontainer/devcontainer.json, который импортирует рядом лежащий .devcontainer/Dockerfile, созданный ранее:

{
  "name": "Name of your image",
  "build": {
    "dockerfile": "Dockerfile",
  },
}

Nix на хосте

Конечно, нередко бывают ситуации, когда из контейнера работать сложновато: возникают какие-то сетевые проблемы, с которыми некогда разбираться, а подключиться к кластеру Kubernetes надо здесь и сейчас, бага горит и надо дебажить.

И здесь открывается ещё одно преимущество Nix: тот же самый конфиг мы можем использовать и на хосте. Если бы мы, например, устанавливали всё нужное в контейнер через apt install, то нам пришлось бы заново делать всё то же самое и на нашем компьютере, если в контейнере работать окажется не очень удобно. И процесс установки тогда бы отличался между контейнером (в котором у нас Ubuntu) и хостом (на котором у нас может быть другой дистрибутив Linux или вообще MacOS). Явно не то, что мы хотим делать в спешке, верно?

Поэтому также полезно установить Nix и на свой компьютер, делается это не сложно, установщик есть на официальном сайте.

Итог

Все слои абстракции в одной картинке

Всё, впрочем, опционально. Для многих не является проблемой установить всё необходимое вручную, без Nix.

Некоторым вовсе не нужна изоляция, но нужна воспроизводимость.

Некоторым подойдёт просто подключаться внутрь контейнера и не нужна интеграция с IDE / редактором.

А некоторым нужно всё вместе.

Ниже — схема всех слоёв абстракций, которые оборачивают друг друга, и можно выбрать тот баланс, который подходит Вам.

Тем не менее, парочка конфигураций в проекте не просит есть и пить, и никому не помешает — полезно иметь возможность для всего.

Как я живу с этим?

У меня в данный момент на компьютере не установлено вообще ничего для разработки, кроме Nix и Docker. Ни Go, ни node, вообще ничего.

❯ which go
go not found

❯ which mockery
mockery not found

❯ which golangci-lint
golangci-lint not found

Когда я захожу в директорию проекта, где есть flake.nix - я просто пишу nix develop, и всё (в первый раз) скачивается, а потом инициализируется из кэша:

bash-5.3$ which go
/nix/store/nkhf1yv637cp91y79zap7jxmm98v8pvj-go-1.25.2/bin/go
bash-5.3$ which mockery
/nix/store/qzzbjxgk4ai311ibriyyj91bn5z75520-go-mockery-3.5.5/bin/mockery
bash-5.3$ which golangci-lint
/nix/store/pgswzj0kk73xzs9y6lr84y4ik8j9bgmm-golangci-lint-2.5.0/bin/golangci-lint

Важная деталь: Nix ничего не устанавливает в глобальный $PATH, а вообще монтирует отдельный Volume, поэтому все бинари изолированы и никогда не конфликтуют.

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Утомляет ли Вас настраивать окружение в новых для вас кодовых базах?
50%Да1
50%Нет1
0%Да, но не знал, что можно проще0
Проголосовали 2 пользователя. Воздержавшихся нет.