В конвейерах командной строки рано или поздно встаёт вопрос сохранения вывода. Перенаправление через знак больше отправляет всё в файл, но экран остаётся пустым, и инженер вслепую ждёт завершения скрипта. Утилита tee решает ровно эту задачу: она читает стандартный ввод, одновременно копирует его на экран и в один или несколько файлов. Название пришло из водопроводного дела: разветвитель-тройник по-английски называется tee fitting, и программа работает точно так же, раздавая один поток сразу в несколько направлений. Инструмент входит в GNU coreutils, а значит, есть в любой системе от сервера до роутера с BusyBox, и именно конвейер с tee стал стандартом в скриптах деплоя, сборки и миграций, где журнал нужен и на экране, и на диске.

Простое перенаправление и потерянный вывод команды

Разберём классическую ситуацию. Инженер запускает деплой и сохраняет протокол:

./deploy.sh > deploy.log

Файл наполняется, но следить за ходом не получается: стандартный вывод уже уходит мимо терминала. Вариант запустить задачу ещё раз, чтобы посмотреть глазами, проходит не всегда: повторный деплой, миграция базы или платёжная операция не безобидны при втором прогоне. Вариант держать второй терминал с tail -f спасает, но запускающий сам по-прежнему ничего не видит.

Справедливости ради, файл и без tee можно смотреть снаружи: перенаправленный вывод продолжает писаться, и tail -f в соседнем окне покажет его движение. Проблема в другом: всё внимание уходит на второй терминал, а основной экран остаётся немым. С tee картина меняется: журнал пишется, и вывод перед глазами, без переключений.

Tee встраивается в конвейер и делает оба дела сразу:

./deploy.sh | tee deploy.log

Вывод команды приходит на стандартный ввод tee, программа копирует его на экран и параллельно в файл. Один поток, два выхода, ничего не теряется. Синтаксис прост: tee [опции] [файл]... Сама утилита файлы не читает, только стандартный ввод, поэтому в одиночку её не запускают: источник потока всегда слева. По умолчанию существующий файл затирается, поэтому для ведущихся журналов нужен другой ключ.

Ключи append, ignore interrupts и режимы ошибок вывода

Ключ -a в длинной форме --append дописывает в конец вместо перезаписи, и это основа журналов с разбивкой по датам:

./backup.sh | tee -a backup-$(date +%F).log

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

Ключ -i (--ignore-interrupts) заставляет tee игнорировать сигналы прерывания. Сценарий такой: длинная задача крутится под tee, по Ctrl+C процесс снимается, и важно, чтобы уже записанный хвост лога дожил до конца без обрыва на середине. С -i утилита спокойно доест входной поток и корректно закроет файл.

Тонкая механика ошибок вывода настраивается коротким -p и длинной формой --output-error с режимами warn, warn-nopipe, exit и exit-nopipe. По умолчанию tee немедленно завершается при ошибке записи в канал. Режим warn-nopipe, который и включает -p, ведёт себя гибче: если один из потребителей конвейера закончил работу раньше остальных, tee продолжит писать в оставшиеся выходы и лишь предупредит о закрытом. Для разветвлённых конструкций, где часть подписчиков короткая, а часть живёт до конца, это ровно нужное поведение.

Любопытная историческая деталь: в версиях coreutils с 5.3.0 по 8.23 дефис в списке файлов означал ещё одну копию потока на стандартный вывод. Затем поведение привели к стандарту POSIX, и теперь дефис считается обычным именем файла, поэтому скрипты, полагавшиеся на прежнюю трактовку, стоит перепроверить. Актуальная документация coreutils 9.12 фиксирует поведение всех режимов, а в компактных сборках с BusyBox набор ключей скромнее, обычно из полезного там только -a.

Запись защищённых файлов через sudo tee

Знакомая ловушка прав доступа: команда sudo echo "строка" > /etc/sysctl.d/99-tuning.conf не сработает. Перенаправление выполняет оболочка от имени обычного пользователя ещё до того, как sudo вступит в игру, а прав на запись в системный каталог у пользователя нет.

Каноничное решение подставляет tee, запущенную с sudo:

cat <<'EOF' | sudo tee /etc/sysctl.d/99-tuning.conf
net.ipv4.ip_forward = 1
vm.swappiness = 10
EOF

Здесь sudo относится именно к tee, поэтому файл открывается на запись с правами root, а на экран при этом летит копия записанного: видно, что именно улетело в конфиг. Для дописывания к существующему системному файлу пригодится sudo tee -a, например при добавлении строки в hosts или в список репозиториев пакетного менеджера. Тот же приём работает с перезаписью юнитов systemd и правкой fstab из скриптов автоматизации, где интерактивный редактор недоступен. Иногда экранная копия только мешает, например когда конфиг скачивается со своего сервера:

curl -s https://example.com/tuning.conf | sudo tee /etc/sysctl.d/99-tuning.conf > /dev/null

Перенаправление в /dev/null глушит экранную копию, а файл всё равно записывается: tee печатает копию всегда, слушает ли её кто-то. Приём экономит и время: не нужно открывать редактор под root ради одной строки, особенно по ssh на далёком сервере.

stderr и код возврата, чтобы лог не обманывал

Tee ловит только стандартный вывод. Поток ошибок течёт мимо конвейера прямо на экран, и в файле их не окажется. Классическое лечение выглядит так:

./job.sh 2>&1 | tee job.log

Перенаправление 2>&1 склеивает оба потока до входа в tee. В bash начиная с четвёртой версии работает и сокращение |&, делающее то же самое. Ошибки можно развести по отдельным файлам через подстановку процесса, но базовый вариант закрывает большинство случаев.

Вторая ловушка касается кода возврата. Конвейер по умолчанию возвращает статус последней команды, то есть статус tee, а она почти всегда успешна. Скрипт с проверкой кода возврата решит, что всё прошло гладко, даже когда сама задача завершилась ошибкой. Лечится одной строкой:

set -o pipefail
./job.sh 2>&1 | tee job.log || echo "задача завершилась со статусом $?"

С pipefail статус всего конвейера отражает статус упавшей команды, и tee больше не маскирует отказ.

Разветвление потока, буферизация и живой ритм обновления

Tee принимает любое число имён файлов и пишет во все одновременно. На этом строится вентиляция журналов: полная копия на диск, сжатый архив и фильтр для алертов в одном запуске. Простые множественные копии работают без хитростей: tee one.log two.log three.log раздаст поток в три файла и на экран, а имена удобно собирать из переменных с датой, раскладывая файлы по каталогам. Подстановка процесса bash вида >(команда) подсунет на место файла любой процесс:

./build.sh 2>&1 | tee full.log >(gzip -c > full.log.gz) | grep -iE 'error|fail'

Один запуск дал три результата: читаемый журнал, его сжатую копию и на экране только строки с ошибками. Если часть подписчиков может закончить раньше остальных, к tee добавляют -p, и поток дотекает в выжившие выходы. Тот же приём прикручивает поток к журналу systemd через systemd-cat или к сборщику статистики без временного файла.

Дальше стоит понять, почему живой ритм иногда сбоит: конвейер собран правильно, а строки в tail -f появляются пачками раз в несколько секунд. Виновата буферизация. Когда вывод команды уходит в терминал, библиотека C рисует его построчно, а когда в канал, переключается на блочный режим и копит по несколько килобайт. Tee тут не при чём: он получает строки уже пачками и честно отдаёт их дальше. Лечение чаще всего простое: утилита stdbuf из того же набора coreutils заставляет команду вести себя построчно:

stdbuf -oL ./app 2>&1 | tee -a app.log

Для программ на Python помогает запуск с ключом -u или переменная PYTHONUNBUFFERED=1, у GNU sed есть флаг -u, а у grep выручает уже знакомый --line-buffered, поэтому фильтры в конвейере тоже перестают копить вывод блоками. После таких настроек журнал оживает и движется строка за строкой, а не очередями.

Протокол обновлений и просмотр лога в живую

Отдельный уют tee приносит в обновлениях и миграциях, где важен каждый шаг. Протокол обновления пакетов сохраняется и параллельно виден:

sudo dnf -y upgrade 2>&1 | tee -a /var/log/upgrade-$(date +%F).log

Через месяц спор о том, что именно ставилось и какие пост-скрипты сыпали предупреждения, закрывается открытием файла с нужной датой. Тот же приём для миграций базы: скрипт изменений идёт в журнал, инженер смотрит за прогрессом, а ревьюер потом читает протокол как обычный текст. Полезно добавить в конец конвейера отметку о завершении: echo "закончено $(date -Is)" | tee -a upgrade.log, по последней строке сразу видно, дошёл ли процесс до конца.

Протоколы хорошо сочетаются с наблюдением из второго окна. Во втором терминале или второй панели tmux достаточно tail -f:

tail -f deploy.log

Хвост журнала растёт на глазах, копипаст работает, историю можно листать. less +F открывает файл в следящем режиме: Ctrl+C приостанавливает, shift+F возобновляет, прокрутка доступна в любой момент, и это удобнее tail, когда по логу нужно блуждать. watch -n1 tail -n 40 deploy.log перерисовывает последние строки раз в секунду, что выручает для дашбордов, а multitail следит сразу за парой журналов в одном экране с подсветкой. В tmux сценарий ещё приятнее: в левой панели деплой, в правой tail -f, переключение по Ctrl+B без потери контекста.

Для задач по расписанию стоит добавить отметки времени. GNU awk умеет проставлять время каждой строке на лету:

./job.sh 2>&1 | awk '{ print strftime("%F %T"), $0; fflush() }' | tee -a job.log

Функция fflush не даёт awk копить строки в буфере, поэтому журнал обновляется в реальном времени. Зато после завершения задачи отмотать историю легко: grep по файлу с отметками времени мгновенно находит нужный час, чего не скажешь о прокрутке живого терминала.

Соседи по наблюдению и порядок сборки конвейера

Перед закреплением конструкции полезно понять границы инструмента. Утилита script из набора util-linux пишет всю сессию целиком, включая управление курсором и цвета, а scriptreplay затем проигрывает её с таймингами:

script -T session.timing -a session.log
scriptreplay session.timing session.log

Такой журнал полнее, но и тяжеловеснее: для отчёта по одной команде достаточно tee, а script выручает, когда нужно сохранить всю сессию с вводом и позициями курсора. При желании показ ускоряется, и длинная сессия пролетает за минуты. Другая классика, именованный канал из mkfifo, решает задачу постоянного подключения наблюдателей: несколько процессов читают из одного канала, писатель ничего не знает о читателях. Но настройка на порядок мудрее, чем аргумент tee, и в коротких сценариях подстановка процесса bash выигрывает у неё по удобству. Выбор на практике простой: точечная копия вывода команды, tee; полная запись сессии, script; долгоживущий канал с несколькими подписчиками, mkfifo.

Сам конвейер обрастает деталями, которые проще один раз зафиксировать, чем вспоминать ночью во время инцидента. Перед тем как закреплять конструкцию в cron или CI, стоит прогнать её по короткому списку:

  1. Склеить стандартный вывод и поток ошибок через 2>&1, чтобы файл отражал всё происходящее, а не половину событий;
  2. Включить set -o pipefail, чтобы код возврата конвейера показывал реальный итог задачи, а не успех tee;
  3. Выбрать режим дописывания -a для ведущихся журналов и продумать ротацию по датам или размеру;
  4. Решить, кому нужны дубли потока, и раздать их через дополнительные файлы или подстановку процесса;
  5. Проверить поведение при Ctrl+C и при досрочном завершении одного из потребителей, добавив при необходимости -i или -p;
  6. Открыть второй терминал с tail -f и убедиться, что наблюдение за работой удобно и не мешает записи;
  7. Прогнать конвейер дважды и убедиться, что файл с -a копится между запусками, а не затирается.

Список звучит длинно, но реальный прогон занимает минуты: две правки в скрипте, один запуск с tail -f рядом и один эксперимент с Ctrl+C. Экономия нервов начинается с первой же ночной задачи, когда лог внезапно становится главным свидетелем. Конвейер после этого становится предсказуемым: файл всегда полный, экран живой, код возврата честный. Именно за это сочетание tee и заслуживает место в каждой шпаргалке системного инженера.