Потерян единственный SSH-ключ на всех ВМ в Yandex Cloud: как зайти обратно
Published: 2026-07-06
Админ уходит, а его ключ оказывается единственным, который cloud-init когда-либо прописал на десятках боевых ВМ. Ни OS Login, ни второго ключа, ни пароля, который кто-то помнит — только хэш пароля в старом дампе секретов — а хэш обратно в пароль не превращается. ssh отвечает Permission denied (publickey), и первая мысль — просто добавить новый ключ через metadata. Не сработало. Вторая мысль — прицепить диск к другой ВМ и починить там. Тоже не сработало, но по другой причине. Дальше — то, что реально сработало, и как я довёл это до автоматизации на весь парк машин, а не на одну.
Почему не работает очевидное
Добавить ключ через yc compute instance add-metadata --metadata-from-file ssh-keys=... — первая идея у всех, и она реально работает примерно в семидесяти процентах случаев. На ВМ, собранных из образа с живым guest-agent'ом, который постоянно опрашивает metadata, ключ подхватывается за минуту. Проблема в том, что ВМ, поднятые вручную через cloud-init user-data, чаще всего применяют этот user-data один раз, при первой загрузке, и больше никогда его не перечитывают. Добавил ключ через metadata — ssh -vvv честно его предложит, сервер так же честно ответит Permission denied, потому что подхватывать было некому.
Вторая идея — прицепить диск к рабочей ВМ, зайти chroot'ом, вписать юзера. Идея верная, реализация в лоб — нет:
$ yc compute instance detach-disk --name my-host --disk-id fhmxxxxxxxxxxxxxxxxx
ERROR: rpc error: code = InvalidArgument
desc = Request validation error: Can't detach boot disk
Yandex Cloud не даёт отцепить диск, пока он boot-диск ВМ — даже остановленной. Это ограничение платформы, не каприз CLI. Значит, надо работать не с самим диском, а с его копией.
Рабочая схема: снапшот вместо живого диска
Остановить ВМ, снять снапшот boot-диска, создать из снапшота новый диск — он ещё ничей не boot, его можно цеплять куда угодно. Прицепить этот новый диск как дополнительный к rescue-VM, зайти туда, chroot, вписать юзера с ключом и passwordless sudo. Отцепить, удалить старую ВМ (диск не трогаем — это rollback), пересоздать ВМ на починенном диске с тем же именем и тем же внутренним IP.
Оригинальный диск при этом нигде не отцепляется — с него только читается снапшот. На выходе — новая ВМ под тем же именем и IP, на диске, идентичном исходному плюс один добавленный юзер.
Снапшот и починенный диск
bashFOLDER_ID=b1xxxxxxxxxxxxxxxxxx
HOST=my-host
DISK_ID=$(yc compute instance get --name $HOST --folder-id $FOLDER_ID --full --format json \
| python3 -c "import json,sys;print(json.load(sys.stdin)['boot_disk']['disk_id'])")
yc compute instance stop --name $HOST --folder-id $FOLDER_ID
SNAP_ID=$(yc compute snapshot create --name ${HOST}-snap-final --disk-id $DISK_ID \
--folder-id $FOLDER_ID --format json | python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")
Размер нового диска нельзя хардкодить — он обязан быть не меньше исходного, а диски на разных машинах разного размера (об этом ниже отдельно, потому что на этом я обжёгся):
bashSRC_SIZE_GB=$(yc compute disk get --id $DISK_ID --folder-id $FOLDER_ID --format json \
| python3 -c "import json,sys;print(-(-int(json.load(sys.stdin)['size'])//1073741824))")
NEW_DISK=$(yc compute disk create --name ${HOST}-boot-fixed --source-snapshot-id $SNAP_ID \
--zone ru-central1-a --type network-hdd --size $SRC_SIZE_GB --folder-id $FOLDER_ID --format json \
| python3 -c "import json,sys;print(json.load(sys.stdin)['id'])")
Rescue-VM, mount, юзер
Небольшая ВМ в той же зоне, которая никогда не является целью восстановления — просто место с шеллом, которому доверяем:
bashyc compute instance attach-disk --name rescue-vm --disk-id $NEW_DISK --folder-id $FOLDER_ID
На rescue-VM:
bashmkdir -p /mnt/rescue
mount /dev/disk/by-id/virtio-${NEW_DISK}-part2 /mnt/rescue
mkdir -p /mnt/rescue/home/devops/.ssh
chroot /mnt/rescue useradd -m -s /bin/bash -G sudo devops 2>/dev/null || true
echo "ssh-ed25519 AAAA... devops" > /mnt/rescue/home/devops/.ssh/authorized_keys
chmod 700 /mnt/rescue/home/devops/.ssh
chmod 600 /mnt/rescue/home/devops/.ssh/authorized_keys
chroot /mnt/rescue chown -R devops:devops /home/devops
echo "devops ALL=(ALL) NOPASSWD:ALL" > /mnt/rescue/etc/sudoers.d/devops
umount /mnt/rescue
Про /dev/disk/by-id/virtio-${NEW_DISK}-part2 — это не случайный путь, а способ не наступить на грабли из следующего раздела. Дальше объясню, зачем именно так.
Пересадка на починенный диск
bashADDR=$(yc compute instance get --name $HOST --folder-id $FOLDER_ID --format json \
| python3 -c "import json,sys;print(json.load(sys.stdin)['network_interfaces'][0]['primary_v4_address']['address'])")
SUBNET=$(yc compute instance get --name $HOST --folder-id $FOLDER_ID --format json \
| python3 -c "import json,sys;print(json.load(sys.stdin)['network_interfaces'][0]['subnet_id'])")
yc compute instance detach-disk --name rescue-vm --disk-id $NEW_DISK --folder-id $FOLDER_ID
yc compute instance delete --name $HOST --folder-id $FOLDER_ID
yc compute instance create --name $HOST --zone ru-central1-a \
--network-interface subnet-id=$SUBNET,address=$ADDR \
--use-boot-disk disk-id=$NEW_DISK,auto-delete=false \
--memory 4 --cores 4 --folder-id $FOLDER_ID
address=$ADDR закрепляет тот же внутренний IP — DNS, конфиги других сервисов, firewall-правила ничего не замечают. Проверка:
bashssh devops@$ADDR 'echo ok; sudo whoami'
На одной машине это заняло минут пятнадцать, из них большая часть — ожидание снапшота 100 ГБ. Дальше выяснилось, что таких машин не одна.
Один ключ редко бывает на одной машине
Проверил остальные машины тем же способом, каким искал причину исходного алерта — прошёлся по metadata всех ВМ и посмотрел, у кого в user-data тот же самый ушедший админ. Из 118 ВМ в папке — 80 с тем же единственным ключом. Базы, очереди, кэши, бастион, и, что хуже всего, VPN-шлюзы и роутеры — то есть сама сеть, через которую вообще идёт доступ ко всему остальному.
Гнать один и тот же скрипт по всем 80 сразу — плохая идея по двум причинам. Первая: часть машин — это кластеры с репликацией (Cassandra, RabbitMQ, etcd), где нельзя ронять больше одной ноды одновременно без риска потерять кворум. Вторая: часть машин — не Linux вообще (роутерные appliance'ы) или управляются control-plane облака напрямую (managed-базы) — туда лезть с chroot нельзя и не нужно.
Разложил всё по группам не по типу сервиса, а по тому, что сломается, если пойдёт не так:
yaml# inventory/hosts.yml
all:
children:
quorum_sensitive: # реплицированные кластеры — строго по одной ноде
hosts:
db-node-01: {}
db-node-02: {}
db-node-03: {}
independent: # без зависимостей друг от друга — можно параллельно
hosts:
cache-node-01: {}
bastion-01: {}
network_path: # это и есть путь до всего остального — руками, последними
hosts:
vpn-gw-01: {}
router-01: {}
yaml- name: Recover access — quorum-sensitive
hosts: quorum_sensitive
serial: 1 # следующую ноду не трогаем, пока эта не вернулась в кластер
tasks:
- include_tasks: tasks/recover_one.yml
- name: wait for cluster health
command: ssh devops@{{ recovered_addr }} "cluster-status-check"
delegate_to: localhost
register: check
until: check.rc == 0
retries: 60
delay: 10
- name: Recover access — independent
hosts: independent
serial: "100%"
tasks:
- include_tasks: tasks/recover_one.yml
# network_path не подключён ни к одному play. Эти хосты — по одному, руками,
# и только после того как проверен запасной внеполосный доступ. Сломать
# собственный VPN-шлюз посреди восстановления — значит отрезать себя
# от всего остального, что как раз чинишь.
Каждая задача сама вычисляет зону, subnet, размер диска и IP хоста по имени из инвентаря — руками добавлять нужно только сам hostname. Так весь радиус поражения виден сразу: что в списке, то и тронется, а группировка честно показывает, где можно параллелить, а где — нет.
Гонка за диском, когда rescue-VM одна на зону
Пока к rescue-VM прицеплен один лишний диск, найти его через lsblk — не проблема, берёшь последний непустой раздел. Проблема начинается, когда несколько хостов одной зоны обрабатываются параллельно, деля одну rescue-VM (плодить их по одной на каждый хост — лишние затраты и время). Два параллельных attach-disk, а следом два скрипта, которые оба хватают "последний раздел в lsblk" — оба возьмут один и тот же диск. Фикс одного хоста запишется на диск другого.
Yandex Cloud даёт каждому прицепленному диску детерминированный путь по его собственному ID, независимо от порядка подключения:
bash$ ls -la /dev/disk/by-id/ | grep virtio
virtio-fhmlrpr6g54rfs0dal0p -> ../../vdb
virtio-fhmlrpr6g54rfs0dal0p-part1 -> ../../vdb1
virtio-fhmlrpr6g54rfs0dal0p-part2 -> ../../vdb2
Отсюда и путь virtio-${NEW_DISK}-part2 в шаге про rescue-VM выше. Проверил это не по документации, а вживую — прицепил диск, зашёл, посмотрел by-id, symlink оказался ровно там, где ожидался. В скрипте для масштаба берём самый большой раздел под этим ID (обычно это и есть корневая ФС), а не гадаем по lsblk:
bashBASE="/dev/disk/by-id/virtio-${NEW_DISK}"
for i in $(seq 1 15); do compgen -G "${BASE}*" >/dev/null 2>&1 && break; sleep 1; done
BEST=""; BEST_SIZE=0
for p in ${BASE}*; do
[ -e "$p" ] || continue
SZ=$(blockdev --getsize64 "$p" 2>/dev/null || echo 0)
[ "$SZ" -gt "$BEST_SIZE" ] && { BEST_SIZE=$SZ; BEST="$p"; }
done
[ -z "$BEST" ] && { echo "FATAL: by-id device not found, refusing to guess" >&2; exit 1; }
Если symlink не появился за 15 секунд — падаем с ошибкой, а не гадаем. Громкий провал лучше тихой записи не на тот диск.
Что реально пошло не так на живом прогоне
Первый же хост за пределами тестового прогона — бастион с диском в 1 ТБ — съел лишних полчаса. yc compute disk create отказал сразу:
ERROR: rpc error: code = InvalidArgument
desc = Request validation error: Disk size must be greater or equal than 1024.0 GB
Причина простая и глупая: я захардкодил --size 100, ориентируясь на пару первых машин, у которых диск и правда был 100 ГБ. У остальных — нет. Лечится тем самым SRC_SIZE_GB выше — размер всегда читается с исходного диска, никогда не хардкодится.
Второй звоночек — тот же бастион, тот же 1 ТБ диск, снапшот:
ERROR: operation (id=...) poll fail: rpc error: code = Unavailable
desc = error reading from server: read tcp ...: read: operation timed out
...14m26s
CLI сдался через 14 с половиной минут ожидания и отрапортовал ошибку. ВМ к этому моменту уже стояла выключенной. Первая реакция — повторить команду. Хорошо, что сначала проверил:
bashyc operation get <operation-id>
done: true, снапшот READY — операция на сервере спокойно доработала ещё почти 40 минут уже после того, как CLI перестал её слушать. Повторный запуск с тем же именем снапшота просто упал бы на "already exists", а бастион простоял бы выключенным ещё столько же зря. С тех пор для дисков больше сотни гигабайт я сначала смотрю yc operation get, и только потом решаю, ретраить или продолжать со следующего шага вручную.
Итого
- Ключ через metadata помогает только там, где что-то реально продолжает её опрашивать — вручную поднятые ВМ часто применяют
user-dataодин раз и больше никогда - Boot-диск нельзя отцепить ни у работающей, ни у остановленной ВМ — работайте с копией через снапшот
- Схема: стоп → снапшот → новый диск из снапшота → mount и фикс на rescue-VM → удалить старую ВМ, пересоздать на починенном диске с тем же именем и IP
- Размер нового диска всегда берите с исходного — хардкод один раз сработает и один раз сломает всё на другой машине
- При параллельной обработке нескольких хостов на одну rescue-VM ищите диск по
/dev/disk/by-id/virtio-<disk-id>, не по порядку вlsblk - Когда хостов много, группируйте их по тому, что сломается, а не по типу сервиса — сетевой путь (VPN/роутеры) чините последним и руками
- "Упавшая" долгая операция CLI на большом диске могла успешно завершиться на сервере — проверяйте
yc operation get, прежде чем повторять команду