为什么在容器外修改了文件后容器内的文件没有同步更新?——一次 Docker 文件挂载和 Bind mount 的探索

这是 2024 年的第一篇文章,由于今年似乎大家铺天盖地地发布「2023 年终总结/记录/Wrapped」,考虑到所谓的新年无非是我们在 UTC +8 的一个日期的变化,并不会因为「到了新的一年」而「就能做出 xx 改变」,我决定不以此对过去的一年进行公开总结,而是按照日常的计划持续推进。

此时有个小问题可以让读者思考:我们知道 UTC 是大家公认的零时区,往左右各 + 12 个时区,比如中国在 UTC+8,那 +12 再往东就是 -12 了,也就是说在那个边界上大家的时间是差了接近两天?

Scenario

言归正传,我们从一个实际的场景开始这次文章的探索,在一个服务需求中,我们有两个单独的服务:

  • 一个服务被称为索引器,会定期从外部索引数据并保存到本地的一个 SQLite 数据库中,数据库的名字叫做 tx.db,这个服务是一个单一的 Binary ,通过 crontab 每分钟定时运行
  • 另一个服务是一个 Web 服务,它会连接到上面那个数据库中,并将数据库中的数据组合后输出,这个服务是容器化运行的

Web 服务容器的 docker-compose.yml 文件内容如下:

version: '3'

services:
  indexer_api:
    image: ghcr.io/n0vad3v/indexer_api:latest
    restart: always
    volumes:
      - ./tx.db:/tx.db

此时我们引出四个问题:

  1. 如果外面的索引器更新了这个数据库文件,容器内的 Web 服务是否可以同步获取到最新数据?
  2. 有一个需求需要清空整个数据库文件并且将本地的一个版本(一个完整的 tx.db 文件)给同步到服务器上
    1. 如果我们在服务器上把 tx.db 删了,然后 rsync 一个 tx.db 上去,那么此时容器内的 Web 服务是否可以同步获取到最新数据?
    2. 如果我们不删服务器上的 tx.db 文件而是只是 rsync,此时容器内的 Web 服务是否可以同步获取到最新数据?
  3. 如果我不是直接把 tx.db 作为 volume,而是把这个文件放到一个叫 data 的目录里面,然后把这个目录挂载到容器里面,此时容器内的 Web 服务是否可以在 /data/tx.db 下同步获取到最新数据?

以上操作均不重启容器

此时请读者思考一下,不要急着向下翻,然后我在这里塞个图作为分割线~

Test

我们来试试看,为了方便观测,我们额外在容器的 volumes: 下加入一个 - ./1.txt:/1.txt 的文本文件,内容是 1 ,容器启动后我们在外部将 1.txt 的文件内容改为 2 ,可以看到容器内是同步的。

# cat 1.txt
1
# cat 1.txt
2

此时我们将外面的 1.txt 文件删除,会发现容器内这个文件还在,并且内容还是 2。

我们在外面重新创建一个 1.txt 并将文件内容改为 3,会发现容器内查看文件内容依然是 2。

对于目录而言,我们在 data 目录下放了若干个文件,然后在容器外对文件进行修改,删除,重新创建,发现在容器内的 /data 目录下,这些文件的操作是同步的。

所以便回答了上面三个问题,分别是:

  1. 不能
  2. 不能

但是为什么?

Inode

我们第一反应可以想到 Inode 的变化,以文件为例,测试中我们发现在测试开始前和修改文件内容时,容器内外的 Inode 保持一致:

容器内:

# ls -li 1.txt
8949736 -rw-r--r--. 1 1000 1000 2 Jan  1 07:51 1.txt

容器外:

ls -li 1.txt 
8949736 -rw-r--r--. 1 nova nova 2 Jan  1 15:51 1.txt

但是一旦文件被删除重建或者被 rsync 覆盖了之后, 容器外的 Inode 就变了,变成:

ls -li 1.txt 
8949625 -rw-r--r--. 1 nova nova 2 Jan  1 15:54 1.txt

而此时容器内还是保持原有的 Inode 的信息,所以这里我们的猜想就缩小到了:为什么 Inode 变了之后容器内就不能同步更新了?

对于目录而言,data 目录内部文件的变更没有导致这个目录本身 Inode 的变化,所以容器内外目录内部的文件一直是保持同步的。

通过搜索我们看到这么个帖子: File mount does not update with changes from host · Issue #15793 · moby/moby · GitHub ,其中 cpuguy83 的解释如下:

If you are using some editor like vim, when you save the file it does not save the file directly, rather it creates a new file and copies it into place. This breaks the bind-mount, which is based on inode. Since saving the file effectively changes the inode, changes will not propagate into the container. When the container is restarted the new inode. If you edit the file in place you should see changes propagate.

This is a known limitation of file-mounts and is not fixable.

所以我们了解了由于我们使用 -v 来挂载文件,这个时候使用到的是 Docker 的 Bind mount (Bind mounts | Docker Docs)。

而这是 Bind Mount 的特性,只有在 Inode 是一样的情况下才会在两边同步文件的修改,一旦 Inode 变了,那这里的同步关系就消失了,这也解释了为什么删除文件之后重建的文件是不能反应到容器内部的修改的。

如果在容器外的某个文件被修改且更换了 Inode ,那么就需要重启容器来重新建立这个绑定关系了。

Bind mount

关于 Bind mount 的 man 说明如下:

Bind mount operation
    Remount part of the file hierarchy somewhere else. The call is:

      mount --bind olddir newdir

    or by using this fstab entry:

      /olddir /newdir none bind

    After this call the same contents are accessible in two places.

    It is important to understand that "bind" does not create any
    second-class or special node in the kernel VFS. The "bind" is
    just another operation to attach a filesystem. There is nowhere
    stored information that the filesystem has been attached by a
    "bind" operation. The olddir and newdir are independent and the
    olddir may be unmounted.

可以简单理解为将一个目录(或者文件) mount 到另一个目录(或者文件)下(而不是传统意义上的将一个块设备 mount 到一个目录下),其中文件的部分有点类似硬链接, 不过区别是硬链接是个文件系统的实现,而 bind mount 是个操作系统内核级别的操作,且硬链接不支持目录到目录的连接,可以参考 12.04 - Why are hard links not allowed for directories? - Ask Ubuntu

P.S,说到硬链接就想到了一些奇怪的公司的 PTSD 面试题,叫做「硬链接和软链接的区别」,回顾一下可以发现硬链接其实很像 Bind mount ,因为两个「文件」是共享的 Inode.

然后随便搜到个文章 面试 | Linux 下软链接和硬链接的区别 - 知乎 发现还是我自己写的,就更加 PTSD 了 😅

Big File

说起 Inode 我们可以知道,在硬链接中多个文件是共享了同一个 Inode,同时我们知道以下信息:

  • Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件
  • Inode信息中有一项叫做"链接数",记录指向该inode的文件名总数,这时就会增加1,反过来,删除一个文件名,就会使得inode节点中的"链接数"减1。当这个值减到0,表明没有文件名指向这个inode,系统就会回收这个inode号码,以及其所对应block区域。

以上来源: 理解inode - 阮一峰的网络日志

那我们设想这么个场景,假设我们的容器需要把日志写到一个外部文件中,比如和上文一样还是叫做 tx.db,也是通过 Bind mount 的方式实现,这个时候我们发现这个文件已经写了 10G+ 了,我们的机器上已经快没有空间了,这个时候我们在系统上想丢掉这个日志应该怎么做?

  • > tx.db 把日志变成空文件?
  • rm -f tx.db 删掉日志文件

有了前文的铺垫这个时候读者肯定可以容易明白,第一种方式是可行的,而第二种方式在容器外删除了文件之后由于只有容器内占用了对应的文件的 Inode,外面系统看不到这个文件(没有 Inode),但是由于 Inode 依然被容器占有,所以通过 df -h 等工具会发现系统可用空间是一点都没变少,这个时候如果想通过 docker cp 的方式恢复文件都不可行:

docker cp 83764c055df5:/tx.db ./
Error response from daemon: mount /home/nova/indexer_api/tx.db:/var/lib/docker/overlay2/26b9ab72d273a7f905308b285a88a9a4d45c3943cbb4f029a33afa03efffa344/merged/tx.db, flags: 0x5000: not a directory

而且在容器内也删不掉了:

# rm -f tx.db
rm: cannot remove 'tx.db': Device or resource busy

顺便,如果我们去 /var/lib/docker/overlay2/26b9ab72d273a7f905308b285a88a9a4d45c3943cbb4f029a33afa03efffa344/merged 这个目录下观察的话:

total 10192
-rwxr-xr-x.  1 root root        0 Jan  2 09:36 1.bin
-rwxr-xr-x.  1 root root        0 Jan  2 09:36 1.txt
lrwxrwxrwx.  1 root root        7 Dec 18 08:00 bin -> usr/bin
...
drwxr-xr-x.  1 root root     4096 Jan  2 09:36 etc
drwxr-xr-x.  2 root root     4096 Dec 10 05:08 home
...
-rwxr-xr-x.  1 root root        0 Jan  2 09:36 tx.db

会发现其实 bind mount 进去的文件体积都是 0 (比如 1.bin1.txttx.db),这个时候如果要「释放」真正的空间的话一般只能通过重启容器来处理了。

有兴趣的读者可以继续思考一下在这种情况下我们如何把这个文件拿出来(恢复)。

以上,便是这 2024 年博客的第一篇文章我们探索的问题啦,不知道通过对这个问题的探索你是否有学到了一些奇怪的知识呢?

新年快乐!

References

  1. File mount does not update with changes from host · Issue #15793 · moby/moby · GitHub
  2. 理解inode - 阮一峰的网络日志
  3. 12.04 - Why are hard links not allowed for directories? - Ask Ubuntu
  4. https://man7.org/linux/man-pages/man8/mount.8.html