Потерян единственный 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, прежде чем повторять команду