Сегодня хочу рассказать об одном случае из жизни, когда невинная ошибка при написании скрипта командной оболочки привела к удалению базы данных, используемой в продакшне. Расскажу я и о том, как ShellCheck (инструмент для линтинга и анализа скриптов, выходящий под лицензией GPLv3) мог бы обнаружить эту ошибку и предотвратил бы катастрофу. Да, сразу скажу, что я — автор ShellCheck.
Происшествие
Вот — описание того печального происшествия, о котором я хочу рассказать. Следующее я взял из поста на StackOverflow:
Наш разработчик закоммитил код с огромной ошибкой и мы нигде не можем найти нашу базу данных MongoDB. Пожалуйста, спасите нас!!!
Он вошёл на сервер и сохранил следующий код в ~/crontab/mongod_back.sh:
#!/bin/sh DUMP=mongodump OUT_DIR=/data/backup/mongod/tmp // 备份文件临时目录 TAR_DIR=/data/backup/mongod // 备份文件正式目录 DATE=`date +%Y_%m_%d_%H_%M_%S` // 备份文件将以备份时间保存 DB_USER=Guitang // 数据库操作员 DB_PASS=qq____________ // 数据库操作员密码 DAYS=14 // 保留最新14夭的备份 TAR_BAK="mongod_bak_$DATE.tar.gz" // 备份文件命名格式 cd $OUT_DIR // 创建文件夹 rm -rf $OUT_DIR/* // 清空临时目录 mkdir -p $OUT_DIR/$DATE // 创建本次备份文件夹 $DUMP -d wecard -u $DB_USER -p $DB_PASS -o $OUT_DIR/$DATE // 执行备份命令 tar -zcvf $TAR_DIR/$TAR_BAK $OUT_DIR/$DATE // 将备份文件打包放入正式目 find $TAR_DIR/ -mtime +%DAYS -delete // 删除14天前的旧备洲
А потом выполнил команду ./mongod_back.sh, после чего последовало множество сообщений об отказе в доступе. Дальше — он нажал Ctrl + C и сервер автоматически выключился.
Потом он связался с AliCloud, инженер подключил диск к другому, работающему серверу, что позволило бы нашему разработчику проверить диск. Далее, он понял, что некоторые папки куда-то пропали, включая /data/, где была база данных MongoDB!
P.S. Он не сделал снепшот диска.
В общем то это — кошмар для любого инженера.
Анализ причин данного происшествия — это интересная задачка, для решения которой достаточно будет базовых навыков написания Shell-скриптов. Если вы хотите попытаться сами во всём разобраться — попробуйте, сейчас — самое время это сделать. А если вам нужны подсказки — вот что выдал ShellCheck после анализа данного скрипта.
Далее я в подробностях разберу этот случай и расскажу о том, как ShellCheck мог бы предотвратить катастрофу.
Что пошло не так?
Вот — код минимального воспроизводимого примера (MCVE), запуск которого позволит любому всерьёз и надолго испортить себе жизнь:
#!/bin/sh
DIR=/data/tmp // Директория, которую нужно удалить
rm -rf $DIR/* // Удаление директории
Тут имеется одна фатальная ошибка, которая заключается в том, что последовательность //
в shell-скриптах — это не комментарий. Это — путь к корневой директории, аналогичный /
.
На некоторых платформах строка с командой rm
сама по себе может привести к тяжёлым последствиям, так как она сводится к rm -rf /
с ещё несколькими аргументами. Правда, реализация этой команды в наши дни часто такого не позволяет. Случай, о котором я рассказал, произошёл на Ubuntu, а в ней GNU-реализация rm
не дала бы выполнить такую команду:
$ rm -rf //
rm: it is dangerous to operate recursively on '//' (same as '/')
rm: use --no-preserve-root to override this failsafe
Система сообщает о том, что опасно проводить рекурсивные действия над директорией //
(что то же самое, что и /
), и предлагает, если надо, отключить эту защитную меру, с помощью опции --no-preserve-root
.
Именно тут в игру вступает команда присвоения значения переменной.
Оболочка воспринимает присвоение значения переменной и команды как две стороны одной медали. Вот что сказано об этом в стандарте POSIX:
«Простая команда» — это последовательность необязательных команд присвоения значений и перенаправлений, расположенных в любом порядке, за которыми, что тоже необязательно, следуют слова и перенаправления. Эта последовательность завершается оператором управления.
(«Простая команда» отличается от «сложной команды», которая представляет собой структуру наподобие инструкций if
или циклов for
, содержащую одну или большее количество простых или сложных команд.)
Это означает, что var=42
и echo «Hello»
— это простые команды. В первой имеется одно необязательное присвоение значения и нет необязательных слов. Во второй нет необязательных присвоений значений, но есть два необязательных слова.
Это так же означает, что в состав отдельной простой команды может входить и то и другое: var=42 echo «Hello»
.
Если не вдаваться в подробности стандарта, то окажется, что операция присвоения значения переменной в простой команде действительна только при выполнении вызванной команды. А если в простой команде нет имени команды — команда присвоения действует на уровне текущей оболочки. Последнее из вышеприведённых утверждений объясняет конструкцию var=42
. А вот с первым утверждением, пожалуй, стоит разобраться.
Эта возможность может пригодиться в том случае, когда нужно указать значение переменной, действительное лишь при выполнении одной команды и не затрагивающее ту же переменную, существующую на уровне оболочки:
$ echo "$PAGER" # Показать текущее значение переменной PAGER
less
$ PAGER="head -n 5" man ascii
ASCII(7) Linux Programmer's Manual ASCII(7)
NAME
ascii - ASCII character set encoded in octal,
decimal, and hexadecimal
$ echo "$PAGER" # Текущее значение PAGER не изменилось
less
Именно это нечаянно и было сделано в вышеописанном случае. Так же, как в предыдущем примере новое значение PAGER
действовало лишь во время выполнения команды man
, тогда область действия DIR
была ограничена //
:
$ DIR=/data/tmp // Директория, которую нужно удалить
bash: //: Is a directory
$ echo "$DIR" # Переменная не установлена
(эта команда ничего не выводит)
Это значит, что конструкция rm -rf $DIR/*
превратилась в rm -rf /*
и в итоге её не отловила проверка, ориентированная на rm -rf /
.
(А почему команда rm
просто не отказывалась бы выполняться и при передаче ей /*
? Дело в том, что она не видит конструкцию /*
, так как оболочка сначала раскрывает эту конструкцию и команда rm
видит лишь /bin /boot /dev /data
и так далее. Хотя rm
, очевидно, может отказать пользователю и в удалении директорий первого уровня, это уже начнёт мешать правильному применению данной команды, что, в соответствии с философией Unix — тяжкий грех.)
Как ShellCheck мог бы помочь предотвратить эту проблему?
Проанализируем в ShellCheck опасный фрагмент скрипта, который, напомню, выглядит так:
#!/bin/sh
DIR=/data/tmp // The directory to delete
rm -rf $DIR/* // Now delete it
Вот что у нас получилось (тут можно поэкспериментировать с интерактивным примером):
$ shellcheck myscript
In myscript line 2:
DIR=/data/tmp // The directory to delete
^-- SC1127: Was this intended as a comment? Use # in sh.
In myscript line 3:
rm -rf $DIR/* // Now delete it
^----^ SC2115: Use "${var:?}" to ensure this never expands to /* .
^--^ SC2086: Double quote to prevent globbing and word splitting.
^-- SC2114: Warning: deletes a system directory.
Мы уже обсудили две проблемы этого скрипта, своевременное обнаружение которых могло бы помочь предотвратить неприятности:
- Анализатор ShellCheck обратил внимание на то, что первая последовательность
//
, вероятно, была задумана как комментарий (вики-страница SC1127). - ShellCheck указал на то, что вторая последовательность
//
приведёт к воздействию на системную директорию (вики-страница SC2114).
А третье замечание программы относится к универсальным способам защиты от ошибок. И применение этого совета, даже если оставить без внимания две предыдущих рекомендации, тоже позволило бы предотвратить катастрофу:
- ShellCheck предложил использовать конструкцию вида
rm -rf ${DIR:?}/*
для того чтобы остановить выполнение команды в том случае, если переменная по любой причине будет пустой или неустановленной (вики-страница SC2115).
Это свело бы на нет последствия целой плеяды ошибок, способных привести к тому, что переменная окажется пустой, включая echo /tmp | read DIR
(подоболочки), DIR= /tmp
(неправильная расстановка пробелов) и DIR=$(echo /tmp)
(потенциальные сбои форка или команды).
Итоги
Shell-скрипты — это очень удобный инструмент, но при их написании можно допустить массу ошибок. Многие из ошибок, носящих синтаксический характер, которые в других языках обнаружились бы очень быстро, приведя к возникновению сбоя, в нашем случае приводят лишь к тому, что скрипты начинают вести себя неправильно. Последствия неправильного поведения скриптов могут быть практически любыми — от слегка неприятных до катастрофических. Много примеров подобных скриптов можно найти здесь и здесь.
Если в мире есть инструменты для проверки скриптов — почему бы ими не воспользоваться? Даже если (или — даже не «если», а «когда»!) вы редко пишете shell-скрипты, вы можете установить shellcheck, воспользовавшись своим менеджером пакетов, а так же — установить подходящий плагин для своего редактора, вроде Flycheck (Emacs) или Syntastic (Vim) и просто, до определённого момента, обо всём этом забыть.
Когда вы, после установки этих инструментов, будете писать скрипт, редактор автоматически выведет предупреждения и даст советы. Вы можете обращать внимание на сообщения о стилистических погрешностях, а можете и не обращать, но, всё равно, возможно, вам стоит поглядывать на уведомления о неожиданных ошибках и на предупреждения. Это вполне может спасти вашу базу данных.
Совершали ли вы опасные ошибки при написании shell-скриптов?
Автор: ru_vds