Bash-скрипт — текстовый файл с командами, которые bash выполняет по очереди. Первая строка #!/bin/bash говорит системе, чем его запускать, chmod +x даёт право на запуск, а ./script.sh запускает. Ниже — путь от первого скрипта до аргументов, условий, циклов и защиты от ошибок, с живым выводом Debian 12 и разбором сообщений, на которых спотыкаются новички.
Кавычки решают, сколько аргументов получит скрипт
Первый скрипт
Создай файл hello.sh с двумя строками:
#!/bin/bash
echo "Привет, $(whoami)! Сегодня $(date +%F)."
Первая строка, shebang, называет интерпретатор: файл выполнит /bin/bash. $(команда) подставляет в текст вывод другой команды. Запустить файл как программу пока не выйдет, причём по двум разным причинам:
./hello.sh
hello.sh
bash: ./hello.sh: Permission denied
bash: hello.sh: command not found
Первая ошибка — у файла нет права на выполнение. Вторая — без ./ bash ищет команду только в каталогах из PATH, а текущего каталога там нет. Скрипт можно отдать интерпретатору напрямую, тогда право на выполнение не нужно:
bash hello.sh
Привет, dev! Сегодня 2026-10-18.
Или выдать право один раз и запускать как обычную программу. Что значит +x, разобрано в материале о правах доступа.
chmod +x hello.sh
./hello.sh
Привет, dev! Сегодня 2026-10-18.
Переменные
Переменную задают без пробелов вокруг = и читают через $. В двойных кавычках подстановка работает, в одинарных — нет:
#!/bin/bash
name="Anna"
count=$(ls /home/dev/docs | wc -l)
echo "Привет, $name! Файлов в docs: $count"
Привет, Anna! Файлов в docs: 2
Пробелы вокруг = превращают присваивание в вызов команды name — частая ошибка, о ней ниже.
Аргументы: $1, $2 и $#
Всё, что написано после имени скрипта, попадает в нумерованные переменные. $0 — сам скрипт, $1 — первый аргумент, $2 — второй, $# — сколько их всего. Скрипт args.sh печатает их:
#!/bin/bash
echo "Скрипт: $0"
echo "Аргументов: $#"
echo "Первый: $1"
echo "Второй: $2"
Путь с пробелом остаётся одним аргументом, только если он в кавычках:
./args.sh /home/dev/docs "My Files"
./args.sh /home/dev/docs My Files
Скрипт: ./args.sh
Аргументов: 2
Первый: /home/dev/docs
Второй: My Files
Скрипт: ./args.sh
Аргументов: 3
Первый: /home/dev/docs
Второй: My
Внутри скрипта действует то же правило: "$1" в кавычках — одно значение, $1 без кавычек bash снова разрежет по пробелам. Все аргументы целиком передаёт дальше "$@". Значение по умолчанию задаёт ${2:-/home/dev/backups}: если второго аргумента нет, подставится путь после :-.
Условия и коды возврата
Каждая команда, завершаясь, сообщает код: 0 — успех, любое другое число — ошибка. Последний код лежит в $?:
ls /home/dev/nope
echo $?
ls: cannot access '/home/dev/nope': No such file or directory
2
На кодах держатся && и ||: первая запускает вторую команду при успехе, вторая — при ошибке.
mkdir -p /home/dev/backups && echo "каталог готов"
cp /home/dev/nope /tmp/ || echo "не скопировал"
каталог готов
cp: cannot stat '/home/dev/nope': No such file or directory
не скопировал
Условие в bash пишут через if и двойные квадратные скобки. Скрипт check-fixed.sh здоровается с администратором отдельно:
#!/bin/bash
name=$1
if [[ "$name" == "admin" ]]; then
echo "Привет, администратор"
else
echo "Привет, ${name:-гость}"
fi
./check-fixed.sh admin
./check-fixed.sh "Anna Petrova"
./check-fixed.sh
Привет, администратор
Привет, Anna Petrova
Привет, гость
Самые нужные проверки внутри [[ ]]:
| Проверка | Истина, когда |
|---|---|
-d "$path" | это существующий каталог |
-f "$path" | это существующий обычный файл |
-z "$text" | строка пустая |
-n "$text" | строка непустая |
"$a" == "$b" | строки совпадают |
$n -lt 10 | число меньше 10; ещё есть -le, -gt, -ge, -eq, -ne |
Циклы и функции
for перебирает список, например файлы по шаблону. Переменную цикла берут в кавычки, иначе имя с пробелом развалится на части:
#!/bin/bash
for file in /home/dev/docs/*.txt; do
echo "файл: $file"
done
файл: /home/dev/docs/notes.txt
файл: /home/dev/docs/План встречи.txt
Повторяющийся кусок выносят в функцию. $* внутри неё — все её аргументы одной строкой:
#!/bin/bash
log() {
echo "[$(date +%T)] $*"
}
log "начинаю копирование"
log "готово"
[16:34:09] начинаю копирование
[16:34:09] готово
Когда команда в скрипте падает
По умолчанию bash не останавливается на ошибке, а идёт дальше. Скрипт copy-report.sh не нашёл файл и всё равно рапортует об успехе, а код возврата у него 0:
#!/bin/bash
cp /srv/reports/today.csv /home/dev/docs/
echo "Отчёт скопирован"
cp: cannot stat '/srv/reports/today.csv': No such file or directory
Отчёт скопирован
Строка set -e в начале останавливает скрипт на первой упавшей команде. Тот же скрипт с ней завершается после ошибки cp с кодом 1, без ложного «скопирован». Ещё две строгие опции: set -u считает ошибкой обращение к незаданной переменной, а set -o pipefail делает ошибочным весь конвейер, если упала любая его часть. Без неё код конвейера — код последней команды:
grep ERROR /var/log/app.log | wc -l
echo $?
set -o pipefail
grep ERROR /var/log/app.log | wc -l
echo $?
grep: /var/log/app.log: No such file or directory
0
0
grep: /var/log/app.log: No such file or directory
0
2
Поэтому скрипты обычно начинают с set -euo pipefail — все три опции одной строкой. У set -e есть исключения: он не срабатывает внутри условия if и в командах слева от && и ||, поэтому важные проверки всё равно пишут явно.
Временные файлы удаляют через trap: команда в нём выполнится при выходе из скрипта, даже если тот упал. В tmpfile.sh последняя команда false имитирует ошибку:
#!/bin/bash
set -euo pipefail
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
echo "Временный файл: $tmp"
sort /etc/passwd > "$tmp"
false
./tmpfile.sh
echo $?
ls /tmp
Временный файл: /tmp/tmp.0bWwRupTnf
1
Скрипт упал с кодом 1, но ls /tmp ничего не нашёл: trap убрал файл.
Главная мысль: скрипт для работы, а не для опыта, начинается с
#!/bin/bashиset -euo pipefail, а каждая переменная в нём стоит в двойных кавычках.
Скрипт целиком: резервная копия каталога
Всё вместе — скрипт backup.sh, который упаковывает каталог в архив с датой. Он проверяет аргументы, пишет ошибки в поток ошибок через >&2 и выходит с разными кодами — так его удобно запускать из cron или таймера systemd, где ошибку увидят только по коду и журналу:
#!/bin/bash
set -euo pipefail
if [[ $# -lt 1 ]]; then
echo "Использование: $0 КАТАЛОГ [КУДА]" >&2
exit 1
fi
source_dir=$1
target_dir=${2:-/home/dev/backups}
if [[ ! -d "$source_dir" ]]; then
echo "Нет каталога: $source_dir" >&2
exit 2
fi
mkdir -p "$target_dir"
archive="$target_dir/$(basename "$source_dir")-$(date +%F).tar.gz"
tar -czf "$archive" -C "$(dirname "$source_dir")" "$(basename "$source_dir")"
echo "Готово: $archive"
Три запуска — без аргументов, с несуществующим каталогом и с нормальным:
./backup.sh
./backup.sh /home/dev/nope
./backup.sh /home/dev/docs
Использование: ./backup.sh КАТАЛОГ [КУДА]
Нет каталога: /home/dev/nope
Готово: /home/dev/backups/docs-2026-10-18.tar.gz
Коды возврата у первых двух запусков — 1 и 2. Файл с пробелом в имени попал в архив целиком:
tar -tzf /home/dev/backups/docs-$(date +%F).tar.gz
docs/
docs/План встречи.txt
docs/notes.txt
Как найти ошибку: bash -x и ShellCheck
bash -x печатает каждую команду перед выполнением, уже с подставленными значениями. Строки трассировки начинаются с +:
bash -x ./check-fixed.sh admin
+ name=admin
+ [[ admin == \a\d\m\i\n ]]
+ echo 'Привет, администратор'
Привет, администратор
ShellCheck читает скрипт, ничего не запуская, и находит типичные ловушки. В Debian и Ubuntu он ставится пакетом shellcheck. Вот что он говорит о скрипте, который удаляет отчёты, не беря имя файла в кавычки:
shellcheck cleanup.sh
In cleanup.sh line 2:
cd /home/dev/reports
^------------------^ SC2164 (warning): Use 'cd ... || exit' or 'cd ... || return' in case cd fails.
Did you mean:
cd /home/dev/reports || exit
In cleanup.sh line 4:
rm $file
^---^ SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
rm "$file"
For more information:
https://www.shellcheck.net/wiki/SC2164 -- Use 'cd ... || exit' or 'cd ... |...
https://www.shellcheck.net/wiki/SC2086 -- Double quote to prevent globbing ...
Вторая подсказка — ровно та ошибка, что ломает имена с пробелами. На файле My Report.csv этот скрипт промахивается:
rm: cannot remove 'My': No such file or directory
rm: cannot remove 'Report.csv': No such file or directory
Ошибки, на которых спотыкаются
Permission denied. У файла нет права на выполнение: chmod +x script.sh или запуск через bash script.sh.
command not found. Скрипт запущен по имени без ./, а его каталога нет в PATH. Пиши ./script.sh или положи файл в ~/bin.
Файл сохранён с переводами строк Windows. Редактор Windows ставит в конце строк символ \r, и система ищет интерпретатор /bin/bash\r. В Debian 12 с bash 5.2 ошибка выглядит так:
bash: ./crlf.sh: cannot execute: required file not found
В Ubuntu 22.04 с bash 5.1 тот же файл даёт старую формулировку:
bash: ./crlf.sh: /bin/bash^M: bad interpreter: No such file or directory
Лечится удалением \r: sed -i 's/\r$//' crlf.sh или утилитой dos2unix, а в редакторе стоит включить окончания строк LF.
Пробелы вокруг знака =. Строка name = "Anna" — это вызов команды name с аргументами = и Anna:
./assign.sh: line 2: name: command not found
unary operator expected. В одинарных скобках переменная без кавычек: пустая исчезает, значение с пробелом распадается на два слова. Скрипт check.sh с условием if [ $name = "admin" ] падает и так и так:
./check.sh
./check.sh "Anna Petrova"
./check.sh: line 3: [: =: unary operator expected
./check.sh: line 3: [: too many arguments
Помогают кавычки вокруг переменной или двойные скобки [[ ]], где такого разбиения нет.
unbound variable. Под set -u скрипт обратился к аргументу, которого не передали:
./unset.sh: line 3: $1: unbound variable
Проверяй $# в начале или задавай значение по умолчанию: ${1:-...}.
Скрипт запустили через sh. В Debian и Ubuntu sh — это dash, а не bash. [[ ]], массивы и source он не знает, причём ошибка не всегда останавливает скрипт:
sh check-fixed.sh admin
check-fixed.sh: 3: [[: not found
Привет, admin
Условие сломалось, и скрипт молча ушёл в ветку else. Запускай через bash или ./script.sh с shebang #!/bin/bash. Тот же подвох ждёт в строке crontab: её выполняет /bin/sh.
unexpected end of file. Не закрыт блок: забыт fi, done или кавычка. bash показывает конец файла, а не место, где блок открыт:
./nofi.sh: line 6: syntax error: unexpected end of file
Частые вопросы
Чем ./script.sh отличается от source script.sh
./script.sh запускает отдельный процесс bash: переменные, которые скрипт задаёт, исчезают вместе с ним. source выполняет команды в текущей оболочке. Скрипт setvar.sh задаёт report_dir=/home/dev/reports:
./setvar.sh
echo "каталог: $report_dir"
source setvar.sh
echo "каталог: $report_dir"
каталог:
каталог: /home/dev/reports
Как запускать свой скрипт из любого каталога
Положи его в ~/bin. В Debian и Ubuntu ~/.profile добавляет этот каталог в PATH при входе, если он существует. Проверить можно в новой оболочке входа:
mkdir -p ~/bin && cp backup.sh ~/bin/
bash -l -c 'command -v backup.sh'
/home/dev/bin/backup.sh
Как передать аргумент с пробелом
В кавычках: ./backup.sh "/home/dev/My Files". А внутри скрипта эту переменную тоже пиши в кавычках — "$1", иначе bash разрежет путь заново, как на схеме выше.
Где потренироваться
Скрипты Bash — тема в карте навыков DevOps, рядом с потоками и конвейерами. Проверить себя по Linux можно в тесте навыков: 8 вопросов, баллы видны без регистрации. Как склеивать команды через | и куда уходят ошибки, разобрано в материале о конвейерах и перенаправлении.