Жёсткая и символическая ссылка изнутри: почему одна переживает переезд, а вторая нет
Одна и та же команда ln создаёт две совершенно разные вещи в зависимости от флага -s, и разница проявляется ровно в тот момент, когда вы переносите файлы на другой диск, копируете сайт на новый сервер или разворачиваете бэкап. Жёсткая ссылка переживает переезд, символическая — часто ломается. Чтобы перестать гадать, какая поведёт себя как, нужно понять, на что именно указывает каждая из них: одна — на физический файл, вторая — на текстовую строку с путём.
Содержание
Инод: где на самом деле живёт файл
Файл в Linux — это не запись в директории, а inode: структура на диске, в которой файловая система хранит метаданные (владелец, права, размер, время изменения) и указатели на блоки данных. Имя файла, которое вы видите в ls, — это отдельная сущность, запись в каталоге, связывающая текстовую строку с номером inode. Каталог — это, по сути, таблица «имя → номер inode» на конкретной файловой системе.
Посмотреть номер inode можно так:
ls -li /etc/hosts
# 131074 -rw-r--r-- 1 root root 220 авг 20 10:03 /etc/hosts
stat /etc/hosts | grep Inode
# Device: 803h/2051d Inode: 131074 Links: 1
Важная деталь — inode пронумерован в пределах конкретной файловой системы (конкретного Device из вывода stat). Номер 131074 на /dev/sda1 и номер 131074 на /dev/sdb1 — два разных файла, просто совпала нумерация: каждая файловая система ведёт свой учёт inode независимо от других.
Из этого прямо вытекает поведение ссылок: жёсткая — это ещё одна запись «имя → номер inode» в той же файловой системе, а символическая — отдельный файл со своим inode, который хранит путь как текст.
Жёсткая ссылка: второе имя того же файла
Когда вы делаете ln target linkname, ядро не копирует данные и не создаёт новый файл — оно добавляет ещё одну запись в каталоге, которая указывает на тот же номер inode, что и target. У самого inode есть счётчик Links (в stat — поле Links, в ls -l — второй столбец), и при создании жёсткой ссылки этот счётчик увеличивается на единицу.
echo "рабочий конфиг" > /etc/app/config.yaml
ln /etc/app/config.yaml /etc/app/config-active.yaml
stat /etc/app/config.yaml | grep Links
# Links: 2
ls -li /etc/app/config.yaml /etc/app/config-active.yaml
# 205311 -rw-r--r-- 1 root root 16 сен 08 12:00 /etc/app/config-active.yaml
# 205311 -rw-r--r-- 1 root root 16 сен 08 12:00 /etc/app/config.yaml
Оба имени указывают на inode 205311 — это не «оригинал и копия», а два равноправных имени одного файла. Изменения через любое из имён видны через оба, потому что данные общие.
Отсюда прямое следствие для удаления. Команда rm (системный вызов unlink) не «стирает файл» — она удаляет одну запись из каталога и уменьшает счётчик Links на единицу. Реальные блоки данных на диске освобождаются только тогда, когда счётчик доходит до нуля и ни один процесс не держит файл открытым. Поэтому:
rm /etc/app/config.yaml
stat /etc/app/config-active.yaml | grep Links
# Links: 1
Файл никуда не делся — данные доступны через оставшееся имя config-active.yaml. В этом смысл фразы «пока жива хотя бы одна ссылка — файл существует»: удаление имени и удаление данных — разные события, они совпадают только когда исчезает последняя ссылка.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверСимволическая ссылка: файл, который содержит путь
ln -s target linkname устроена принципиально иначе. Она создаёт новый, отдельный inode со своим собственным номером, но данные в нём — это не содержимое target, а сам путь до target, записанный как текст.
ln -s /etc/app/config-active.yaml /etc/app/config.yaml
ls -li /etc/app/config.yaml
# 205892 lrwxrwxrwx 1 root root 30 сен 08 12:05 /etc/app/config.yaml -> /etc/app/config-active.yaml
stat /etc/app/config.yaml | grep Inode
# Inode: 205892
Номер inode другой (205892, а не 205311) — это независимый файл особого типа (l в правах доступа), и его «содержимое» — строка /etc/app/config-active.yaml. Когда ядро встречает символическую ссылку при разрешении пути, оно читает эту строку и подставляет её вместо самой ссылки — это называется dereference (разыменование) и происходит прозрачно для большинства программ.
На ext4 и похожих файловых системах короткий путь вообще не занимает отдельный блок данных — он хранится прямо в inode самой ссылки (fast symlink), поэтому короткая symlink почти ничего не весит на диске. Но принцип не меняется: это текст, который читается и интерпретируется заново при каждом обращении.
У symlink-записи нет счётчика Links, который бы что-то «спасал» — важен не он, а то, существует ли путь, записанный внутри. Если target переименовали, удалили или перенесли, symlink остаётся физически на месте, но превращается в «висячую» ссылку (dangling symlink): она есть, но ведёт в никуда.
rm /etc/app/config-active.yaml
cat /etc/app/config.yaml
# cat: /etc/app/config.yaml: Нет такого файла или каталога
ls -l /etc/app/config.yaml
# lrwxrwxrwx 1 root root 30 сен 08 12:05 /etc/app/config.yaml -> /etc/app/config-active.yaml
Сама ссылка (inode 205892) никуда не делась, ls -l её прекрасно видит — но она мертва, потому что строка внутри указывает на несуществующий путь. Подробнее о том, что именно происходит с данными в момент rm и почему это не то же самое, что уничтожение файла, разобрано в статье про что происходит с данными при удалении файла.
Почему жёсткая ссылка не выходит за пределы файловой системы
Жёсткая ссылка — это запись «имя → номер inode» в конкретной файловой системе, а номера inode не уникальны глобально, они уникальны только в пределах одного Device (одного смонтированного раздела). Ядро физически не может создать в файловой системе A запись, указывающую на inode файловой системы B — это просто разные адресные пространства. Поэтому попытка сделать жёсткую ссылку между разделами всегда завершится ошибкой:
mount | grep -E '/$| /data'
# /dev/sda1 on / type ext4 (rw,relatime)
# /dev/sdb1 on /data type xfs (rw,relatime)
ln /data/backup.tar.gz /root/backup.tar.gz
# ln: failed to create hard link '/root/backup.tar.gz' => '/data/backup.tar.gz': Invalid cross-device link
Это же ограничение проявляется незаметно, если /data — не отдельный физический диск, а просто отдельная точка монтирования (LVM-том, tmpfs, сетевой ресурс) — с точки зрения ln это всё равно «другое устройство». Проверить, в одной ли файловой системе находятся два пути, можно через stat -c %d: разные числа на выходе — разные файловые системы, жёсткая ссылка между ними невозможна в принципе.
Символическая ссылка этого ограничения не знает: она хранит текстовую строку, а строка может указывать куда угодно — на другой раздел, на NFS, на путь, которого пока не существует. Разрешение сработает в момент чтения, а не в момент создания — ln -s /куда-угодно /link создаётся успешно, даже если цели ещё нет.
Ещё одна асимметрия: обычными средствами нельзя сделать жёсткую ссылку на директорию (запрещено на уровне большинства файловых систем, чтобы не создавать циклы в дереве каталогов), а символическую — можно. Именно поэтому трюк current -> release-2026-08-28 в деплое веб-приложений всегда делают через ln -s.
Что переживает перенос, а что ломается
Когда вы переносите файлы — копированием, архивом, rsync, между дисками или между серверами — поведение ссылок зависит не от их «важности», а от того, что именно копируется: данные или текст.
| Свойство | Жёсткая ссылка | Символическая ссылка |
|---|---|---|
| На что указывает | На inode (сам файл) | На путь (текст) |
| Работает между файловыми системами | Нет | Да |
| Работает на директории | Нет (как правило) | Да |
| Переживает удаление оригинала | Да, пока жива хоть одна ссылка | Нет — превращается в висячую |
| Переживает переименование/перенос цели | Да (это то же самое имя того же файла) | Нет, если путь внутри устарел |
| Занимает место на диске | Отдельная запись, данные не дублируются | Отдельный маленький inode + текст пути |
Видна командой file/readlink | Как обычный файл | Явно как ссылка, с целью |
Жёсткая ссылка переживает переезд, потому что она и есть файл — просто под другим именем, и куда бы вы её ни скопировали внутри той же файловой системы, данные остаются доступны. Символическая ссылка не «содержит» файл — она содержит адрес, и если адрес при переезде изменился, ссылка не узнаёт об этом сама и слепо продолжает указывать на старую строку.
Отдельно стоит развести относительные и абсолютные символические ссылки — это частая причина сюрпризов после переноса:
ln -s /var/www/app/releases/2026-08-28 /var/www/app/current # абсолютная
ln -s releases/2026-08-28 /var/www/app/current # относительная
Абсолютная ссылка хранит полный путь от корня — если вы перенесёте весь каталог /var/www/app на новый сервер под другим путём (скажем, /srv/app), ссылка так и будет искать /var/www/app/releases/..., которого там больше нет. Относительная ссылка хранит путь относительно своего собственного расположения — если перенести директорию целиком, сохранив внутреннюю структуру, относительная ссылка продолжит работать даже под новым абсолютным путём, потому что она не знает про абсолютные пути вообще.
Перенос, бэкапы и деплой: где это аукается на практике
Теория выше объясняет несколько классических ситуаций, с которыми сталкивается почти каждый, кто переносит проект между дисками или серверами.
Копирование с cp без флагов теряет символические ссылки как ссылки. По умолчанию cp разыменовывает symlink и копирует содержимое цели, превращая ссылку в обычный файл-копию. Чтобы сохранить ссылки ссылками:
cp -a /var/www/app/ /srv/app/ # -a = archive, сохраняет symlink как symlink
rsync по умолчанию сохраняет символические ссылки, но не жёсткие. Флаг -a копирует symlink как symlink (со старым путём внутри), но если на источнике несколько имён указывают на один inode — например, инкрементальные бэкапы через жёсткие ссылки, как делает rsnapshot или ручные снапшоты через cp -al, — обычный rsync -a продублирует данные под каждым именем отдельно, и экономия места исчезнет:
rsync -aH /backups/daily.0/ user@newserver:/backups/daily.0/ # -H = preserve hard links
Без -H каждая жёсткая ссылка на приёмнике станет независимым файлом со своим inode — формально данные те же, но связь «это один и тот же файл» и экономия места теряются навсегда. По умолчанию tar тоже сохраняет обе связи внутри архива, но ключ --hard-dereference разворачивает жёсткие ссылки в полноценные копии при архивации — иногда это нужно для самодостаточности архива, иногда становится источником неожиданного роста его размера.
Деплой через current -> release-N рвётся, если синхронизировать релизы, но забыть про симлинк. Классическая схема capistrano-style деплоя держит current как symlink на последний релиз. Если скопировать releases/, но не пересоздать current (или перенести сам symlink с абсолютным путём старого сервера), приложение не найдёт свои файлы на новом месте — ошибка будет выглядеть как «файл не найден», хотя файл физически есть, просто ссылка смотрит не туда.
Конфиги вроде sites-enabled в nginx — тоже симлинки, и это осознанный выбор. /etc/nginx/sites-enabled/site.conf обычно является symlink на файл в sites-available/. Если при переносе сервера скопировать архивом с --dereference (он разворачивает ссылки в файлы) только sites-enabled, а sites-available не перенести, всё будет выглядеть рабочим, пока кто-то не отредактирует файл и не обнаружит дублирование логики.
В контейнерах symlink на путь хоста просто не резолвится — файловая система контейнера изолирована, и /host/data/... внутри неё ничего не значит. Жёсткие ссылки внутри одного слоя образа работают нормально (это одна файловая система), но не переживают копирование между слоями через COPY --from= в multi-stage сборке без явного сохранения связи — на выходе получаются независимые копии.
После любого переноса стоит явно проверить целостность ссылок, а не полагаться на то, что «всё скопировалось»:
# найти все "битые" символические ссылки в дереве
find /srv/app -xtype l
# найти файлы с более чем одной жёсткой ссылкой (nlink > 1) —
# это те, где стоит проверить, что связь сохранилась и на приёмнике
find /backups/daily.0 -xdev -type f -links +1
find -xtype l находит symlink, чья цель на данный момент недоступна — если после переноса список не пустой, значит какие-то пути ссылались на что-то, чего на новом месте не появилось (или появилось не по тому адресу). Второй вариант полезен на источнике перед переносом: он показывает, какие файлы вообще завязаны на механизм жёстких ссылок, чтобы осознанно выбрать инструмент копирования, который эту связь не потеряет.
При планировании переноса между дисками стоит заранее понимать границы файловых систем на сервере — это разобрано в статье про перенос данных на новый диск без простоя, а для крупных хранилищ, где пересекаются несколько точек монтирования, пригодится материал про миграцию файлового хранилища на терабайты. Если непонятно, почему занятое место на диске не сходится с суммой размеров файлов (в том числе из-за особенностей учёта inode), это подробно разобрано в статье что такое inode и почему место есть, а файл не создаётся.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Как быстро понять, symlink передо мной или жёсткая ссылка, просто глядя на ls -l?
Символическая ссылка всегда начинается с буквы l в правах доступа и заканчивается на -> путь, где видна цель. Жёсткая ссылка визуально неотличима от обычного файла — единственный признак — это значение Links больше 1 в stat или ls -l, и совпадающий номер inode (ls -li) с другим файлом.
Можно ли восстановить символическую ссылку, если файл, на который она указывала, переехал в другое место?
Да, но только вручную — пересоздайте ссылку с новым путём: ln -sf /новый/путь /link. Ключ -f перезапишет существующую ссылку. Автоматически symlink на новый путь не переключится — он хранит статичный текст, а не «умную» связь.
Что произойдёт, если удалить файл, пока он открыт другим процессом?
Для жёсткой ссылки rm уберёт имя из каталога, но пока процесс держит файл открытым, данные не освобождаются — процесс продолжает читать и писать в них, хотя по имени файл уже не найти. Для символической ссылки удаление самой symlink не влияет на процесс, который уже открыл файл через неё: ядро давно разрешило путь до реального inode, ссылка была нужна только на этапе открытия.
Почему жёсткую ссылку нельзя сделать на директорию, а символическую — можно?
Жёсткие ссылки на каталоги запрещены на уровне большинства файловых систем, потому что дерево каталогов должно оставаться деревом без циклов. Символическая ссылка на каталог циклов не создаёт так же — она разрешается заново при каждом обращении, и ядро умеет безопасно оборвать слишком глубокую цепочку таких ссылок.
Есть ли предел на количество жёстких ссылок или на длину цепочки символических?
У жёстких ссылок предел задаёт счётчик Links конкретной файловой системы — на практике в него упираются только редкие кейсы с огромным числом ссылок на один файл. У символических ссылок ограничена глубина цепочки при разыменовании — ядро останавливается после определённого числа вложенных переходов, чтобы не зациклиться на ссылке, ведущей саму на себя.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →