Привет, Хабр!

Пайплайн зелёный, шаг деплоя завершился с нулевым кодом, последняя строка в логе — «Deploy complete». На сервере при этом крутится сборка недельной давности.

Разбор занял полчаса и упёрся в одну строку: переход в каталог назначения не удался, потому что каталога не существовало. Скрипт этого не заметил, распаковал архив туда, где случайно оказался, перезапустил сервис со старой версией и честно отчитался об успехе. В начале файла стоял set -e, и автор был уверен, что этого достаточно.

Оболочка устроена так, что почти любая ошибка в ней ошибкой не считается — выполнение просто идёт дальше со следующей строки. Разберём пять мест, где это стоит дороже всего, и заодно посмотрим, почему привычный набор флагов в первой строке закрывает не всё.

Скрипт, который не заметил падения

Начнём с исходного поведения, потому что от него зависит остальное.

#!/bin/bash

cd /opt/myapp
tar -xzf /tmp/release.tar.gz
systemctl restart myapp
echo "Deploy complete"

Если каталога нет, cd напечатает ошибку в поток ошибок и вернёт единицу. Скрипт продолжит работу в том каталоге, где был, и распакует релиз туда — чаще всего в домашний каталог того, кто запустил пайплайн.

Минимум, который это чинит:

#!/bin/bash
set -euo pipefail

Первый флаг завершает скрипт при ненулевом коде возврата, второй — при обращении к необъявленной переменной, третий заставляет конвейер отдавать код первой упавшей команды, а не последней.

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

grep some-string /nonexistent/file | sort
echo $?
grep: /nonexistent/file: No such file or directory
0

Файла нет, grep вернул двойку, sort принял пустой ввод и завершился нулём — и нулём завершился весь конвейер. Проверка «упало или нет» на такой конструкции всегда показывает успех.

В жизни это выглядит так:

pytest tests/ | tee test.log

Тесты падают, tee записывает лог и отдаёт ноль, задача в пайплайне зелёная. Флаг pipefail делает её честно красной, и обычно это первое, что стоит включить в чужом наследстве.

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

shellcheck deploy.sh
In deploy.sh line 3:
cd /opt/myapp
^-----------^ SC2164: Use 'cd ... || exit' in case cd fails.

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

Например, тут будет такой линк: www.shellcheck.net/wiki/SC2164

Флаг, который держит слово не везде

set -e обещает остановить скрипт на первой ошибке и в целом обещание выполняет, но есть три контекста, где он молчит, и оболочка об этом не предупреждает.

Первый — условие. Любая команда, чей код проверяется, ошибкой не считается, и это правильно:

set -e

if ! rm -rf "$TMP_DIR"; then
    echo "не удалось убрать временный каталог" >&2
fi

А вот следующий код выглядит так же безобидно и ведёт себя совсем иначе:

set -e

check_and_deploy() {
    validate_config          # упадёт — функция не прервётся
    upload_artifact
}

if check_and_deploy; then
    echo "готово"
fi

Функция вызвана в условии, а значит set -e не действует внутри неё целиком, до самого конца. Падение проверки конфига не остановит ни функцию, ни скрипт, и артефакт уедет на прод непроверенным. Отдельно обидно то, что повторное включение флага прямо внутри функции ничего не меняет — поведение остаётся прежним.

Второй контекст — подстановка команд:

set -e
VERSION=$(cat /nonexistent/version.txt)
echo "Deploying $VERSION"
cat: /nonexistent/version.txt: No such file or directory
Deploying

Скрипт продолжает работу с пустой переменной. Лечится это отдельной настройкой, про которую знают немногие:

set -euo pipefail
shopt -s inherit_errexit

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

set -e
count=1
((count--))          # результат 0, код возврата 1, скрипт молча выходит
echo "эта строка не выполнится"

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

count=$(( count - 1 ))
# либо
((count--)) || true

Три контекста стоит запомнить списком: условие, подстановка команд, арифметика. Всё остальное set -e покрывает прекрасно.

Объявление переменной, которое съедает код возврата

Эта ошибка встречается реже предыдущих, зато прячется лучше всех — она переживает и set -e, и pipefail, и ревью.

set -euo pipefail

get_version() {
    local version=$(curl -sf https://api.example.com/version)
    echo "$version"
}

VERSION=$(get_version)
echo "Deploying $VERSION"

Сервис недоступен, curl возвращает ошибку, а скрипт продолжает работу с пустой версией. Причина в том, что local — это команда, и код возврата у строки берётся от неё, а не от подстановки. Объявление переменной прошло успешно, с этим не поспоришь.

Канонический пример из документации анализатора показывает разницу в одну строку:

$ f() { local foo=$(false) && echo "ошибка спрятана"; }; f
ошибка спрятана

$ f() { local foo; foo=$(false) && echo "ошибка спрятана"; }; f
$

Во втором варианте объявление и присваивание разделены, код возврата принадлежит подстановке, и set -e наконец срабатывает. Так же ведут себя declare, readonly и export:

var=$(false);        echo $?    # 1
export var=$(false); echo $?    # 0

Анализатор про это знает и отмечает предупреждением:

In deploy.sh line 4:
    local version=$(curl -sf https://api.example.com/version)
    ^-------------------------------------------------------^
    SC2155: Declare and assign separately to avoid masking return values.

Про один случай он умалчивает: комбинацию local -r foo=$(cmd) анализатор пропускает, хотя маскировка там ровно та же. Разработчики объясняют это тем, что корректная альтернатива получается слишком громоздкой, а увидеть предупреждение можно отдельным ключом check-extra-masked-returns.

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

Самая дорогая по последствиям ошибка и самая безобидная на вид.

BACKUP_DIR=/var/backups/$APP_NAME
rm -rf $BACKUP_DIR/*

Пока переменная заполнена, всё работает. Стоит ей оказаться пустой — из‑за опечатки, из‑за неудачной подстановки, из‑за запуска без окружения — и путь превращается в /var/backups/, а команда убирает всё его содержимое. Оболочка не станет уточнять, действительно ли вы имели в виду именно это.

Флаг set -u здесь помогает только наполовину: он ловит необъявленную переменную, а объявленная и пустая проходит проверку спокойно. Полное решение состоит из двух частей:

: "${APP_NAME:?переменная APP_NAME обязательна}"
BACKUP_DIR="/var/backups/$APP_NAME"
rm -rf "${BACKUP_DIR:?}"/*

Первая строка завершает скрипт с внятным сообщением, если значения нет. Вторая форма прямо в команде удаления — дешёвая страховка ровно от того случая, ради которого всё это пишется.

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

FILE="my report.pdf"
rm $FILE          # две команды: rm my и rm report.pdf
rm "$FILE"        # один файл

Тот же механизм ломает обход файлов, и здесь ошибка выглядит совсем естественно:

for f in $(ls *.log); do        # развалится на именах с пробелами
    gzip "$f"
done

for f in *.log; do              # шаблон раскрывает сама оболочка
    gzip "$f"
done

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

Цикл, из которого не возвращаются переменные

Ошибка, которая ставит в тупик, потому что код выглядит абсолютно логичным.

count=0

cat servers.txt | while read -r host; do
    if ping -c1 -W1 "$host" >/dev/null 2>&1; then
        count=$(( count + 1 ))
    fi
done

echo "Доступно серверов: $count"
Доступно серверов: 0

Внутри цикла счётчик исправно растёт, а после цикла снова ноль. Правая часть конвейера выполняется в отдельном процессе: цикл получает собственную копию переменных, добросовестно её меняет и умирает вместе со всеми изменениями. Переменная действительно увеличивалась, просто не у вас.

Убираем конвейер — и всё работает:

count=0

while read -r host; do
    if ping -c1 -W1 "$host" >/dev/null 2>&1; then
        count=$(( count + 1 ))
    fi
done < servers.txt

echo "Доступно серверов: $count"

Когда данные приходят не из файла, а из команды, помогает подстановка процесса:

while read -r host; do
    count=$(( count + 1 ))
done < <(grep -v '^#' servers.txt)

Анализатор эту ситуацию отмечает прямо, и по номеру правила в его вики лежит объяснение с примерами.

Заодно стоит объяснить два элемента, которые в примерах стоят не для красоты. Флаг -r отключает интерпретацию обратной косой черты — без него путь вида C:\temp превратится в C:temp, и анализатор ругается на это отдельным правилом.

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

while IFS= read -r line; do
    printf '[%s]\n' "$line"
done < input.txt

Пустое присваивание перед read действует только на эту команду и остальной скрипт не трогает.

Уборка, которая не выполняется

Последняя ошибка накапливается неделями и обнаруживается по забитому разделу.

#!/bin/bash
set -euo pipefail

curl -s https://api.example.com/dump > /tmp/dump.json
process /tmp/dump.json > /tmp/result.json
upload /tmp/result.json
rm /tmp/dump.json /tmp/result.json

Пока всё идёт хорошо, файлы убираются. Стоит обработке упасть — и set -e завершит скрипт раньше строки с удалением. Ирония в том, что чем добросовестнее настроена обработка ошибок, тем надёжнее мусор остаётся на диске. При запуске раз в пять минут по расписанию раздел заполняется за несколько дней.

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

Правильная схема состоит из временного каталога с уникальным именем и уборки через ловушку на выход:

#!/bin/bash
set -Eeuo pipefail

WORK_DIR=$(mktemp -d)
cleanup() {
    local code=$?
    rm -rf "$WORK_DIR"
    if (( code != 0 )); then
        echo "скрипт завершился с кодом $code" >&2
    fi
    exit "$code"
}
trap cleanup EXIT

curl -sf https://api.example.com/dump > "$WORK_DIR/dump.json"
process "$WORK_DIR/dump.json" > "$WORK_DIR/result.json"
upload "$WORK_DIR/result.json"

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

Обратите внимание на заглавную E в наборе флагов. Без неё ловушка на ошибку не сработает, если падение случилось внутри функции:

set -euo pipefail
trap 'echo "ошибка!"' ERR

myfunc() { nonexistent_command; }
myfunc
line 4: nonexistent_command: command not found

Скрипт завершился, ловушка промолчала. С флагом -E вывод другой:

line 4: nonexistent_command: command not found
ошибка!

Для ловушки на EXIT буква не нужна — та отрабатывает всегда, — но набор -Eeuo pipefail проще запомнить целиком, чем помнить, когда какая буква требуется.

То, что ломается при переносе на другую машину

Шестой пункт формально выходит за обещанные пять, но встречается ровно там же — в скриптах, написанных с расчётом на окружение автора.

Чаще всего не хватает команд. Скрипт разбирает ответ API через jq, на ноутбуке разработчика тот стоит, на минимальном образе контейнера нет:

for cmd in curl jq systemctl; do
    command -v "$cmd" >/dev/null 2>&1 || {
        echo "не найдена команда: $cmd" >&2
        exit 1
    }
done

Пять строк в начале превращают невнятное «command not found» посреди работы в понятный отказ до того, как что‑либо изменилось на диске.

Следом идут различия между реализациями утилит. Классика здесь — правка файла на месте, где GNU‑версия принимает флаг без аргумента, а версия из macOS требует суффикс:

sed -i 's/old/new/' config.conf        # сломается на macOS
sed -i '' 's/old/new/' config.conf     # сломается на Linux

Переносимый вариант не пытается угадать систему, а пишет во временный файл:

sed 's/old/new/' config.conf > "$WORK_DIR/config.new" \
    && mv "$WORK_DIR/config.new" config.conf

И третье — предположение о текущем каталоге. Скрипт с относительными путями ломается при запуске из планировщика или просто из другого места:

SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
source "$SCRIPT_DIR/lib/common.sh"

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

Где проходит граница применимости

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

Отсюда и первые строки любого нового файла, которые стоит писать не задумываясь:

#!/usr/bin/env bash
set -Eeuo pipefail
shopt -s inherit_errexit

Дальше держите в голове, что это страховка, а не гарантия. Она не поможет там, где команда вызвана в условии, где переменная объявлена и пуста, где цикл ушёл в подконтекст, где local съел код возврата. Именно поэтому кавычки ставятся всегда, обязательные переменные проверяются формой с вопросительным знаком, временные файлы создаются через mktemp и убираются ловушкой, а анализатор стоит в пайплайне рядом с тестами:

- name: Shell lint
  run: shellcheck --severity=warning $(git ls-files '*.sh')

Но главное решение здесь не про флаги. У оболочки есть предел применимости, и большинство болезненных историй начинается с того, что его перешли незаметно. Скрипт на полсотни строк, который копирует, распаковывает и перезапускает сервис, — её территория, и переписывать его на другом языке смысла нет.

Скрипт на четыреста строк с разбором JSON, циклами по вложенным структурам и ветвлением по кодам возврата — уже нет: там вы боретесь с языком, а не решаете задачу.

Если некоторые из этих ловушек оказались неожиданными, можно заодно проверить, насколько уверенно вы ориентируетесь в Linux в целом. Короткий вступительный тест поможет увидеть, какие темы уже закрыты, а где остались пробелы.

Когда пайплайн зелёный, а релиз всё равно не доехал до сервера, проблема часто не в одной неудачной команде, а в том, что система не показывает, где именно сломался сценарий. Умение читать такие сбои, правильно настраивать CI/CD и диагностировать окружение помогает быстрее находить причину, безопаснее автоматизировать деплой и в итоге меньше времени тратить на «магические» ошибки в продакшене.

Если хотите глубже разобраться в инфраструктуре и диагностике таких ситуаций, приходите на бесплатные открытые уроки OTUS:

  • 10 сентября в 20:00. «Настройка GitLab Runners». Записаться

  • 24 сентября в 19:00. «Сможет ли ИИ починить Linux‑сервер: где заканчиваются подсказки и начинается инженерная диагностика». Записаться

Весь список бесплатных уроков августа можно найти в дайджесте.