Skip to content

文件传输网关的文件系统改造

改造背景

老架构介绍

  • 老的架构中,采用的是JUICEFS挂载的方式,采用本地文件系统来进行文件的增删改查,JUICEFS因其内部的优化机制诸如分片、redis缓存元数据等,对文件的处理性能被认为是有很大优势的,为当时的文件存储选择,但是也正是其中的许多“盲盒”的优化手段造成了后续中出现的一些重大问题,以下是几次事故的主要情况和当时解决方案
    • xxx年xx月xx日xx部门反馈文件传输失败,后发现redis集群出现了单节点内存占用为100%问题,读写已经不可用了。事故发生在晚上下班时间,需要对redis集群进行扩容,需要对原来的8g*(3Mater+3Slaver)集群扩展为16g(3Master+3Slaver)模式,当晚redis运维人员均已经下班,后续通过不断电话联系层层找人处理终于将集群进行了扩容,回顾事件发生,发现juicefs并不适配redis的分片集群模式,juicefs为了保证文件处理时候的事务一致性原因,采用了Hash Tag,确保文件的元数据信息写入到一个节点中,这将丧失分片集群的优势,导致redis集群单个节点内存数据过高而其他节点基本不会用到内存的情况,造成了严重的资源浪费和不稳定风险(该问题出现了两次)

    • xxx年xx月xx日磁盘占用过高问题:/ 挂载下磁盘使用率达到了90%+

    • xxx年xx月xx日服务器打补丁失败问题:服务器出现安全问题的时候需要打安全补丁,但是如果不提前卸载juicefs的时候打补丁会失败,这是因为自动打补丁的服务会检测服务器启动前后的挂载是否一致,如果不一致,打补丁会提示失败

此处仅仅记录JUICEFS在公司业务应用出现的一些问题,这与JUICEFS在公司的实际应用情况紧密关联,并非贬低或捏造JUICEFS本身存在问题,例如文中提到的打补丁失败问题,JUICEFS挂载可以注册为系统服务解决该问题,还有缓存文件占用磁盘空间太大引发告警问题,可以通过设置JUICEFS的缓存文件大小来设置,总而言之,JUICEFS依旧不失为一个高性能的文件挂载方案

改造原因

  • 公司的要求:平台确保安全、高可用,JUICEFS曾经不正确的使用姿势引发了上级领导对该技术的信任危机
  • 当前SFTP引擎采用VPS方式部署(KVM虚拟机),后续考虑到使用到K8S容器部署带来的单个容器需要启动SFTP引擎和JUICEFS进程问题,会加大应用部署的复杂度(需要考虑到SFTP引擎和JUICEFS进程这两个进程同时存活,如果出现SFTP引擎进程存活但是JUICEFS挂掉了的情况,那么K8S依旧会认为服务是正常的,但是这个时候实际上服务已经不可用了),这对运维变更来说是挑战
  • Redis的资源浪费和公司对资源使用高效的要求,且考虑到redis的存储方案迁移(从分片集群模式改成Sentinel模式)涉及到的风险

改造方案

  • 整体方案:自定义虚拟文件系统,SFTP引擎对文件的操作直接映射为对S3的操作,例如SFTP的文件上传操作会直接映射为S3的CopyObject操作,SFTP的文件列表方案直接映射为S3的ListObject操作

  • 详细实现

    • 自定义S3FileSystemProvider,实现FileSystemProvider接口
    • 自定义S3FileSystem,实现BaseFileSystem接口,BaseFileSystem来自于apache mina sshd组件
    • 自定义S3Path,实现BasePath接口,BasePath来自于apache mia sshd组件
    • 自定义S3FileChannel,实现FileChannel接口
    • 使用spi将自定义的文件系统核心类S3FileSystemProvider注册到SFTP引擎中
  • S3FileSystemProvider核心方法实现

    • newFileSystem和getFileSystem,该方法传递了一个URI类型的参数,在java虚拟文件系统的设计范式中,所有的虚拟文件系统都可以通过某个URI来定位到,例如默认的本地文件系统,其URI格式为"file:///",其中"file"表示本地文件系统的schema,最后一个"/"表示根目录,因此"file:///"这个URI表示的是一个本地虚拟文件系统,在我们自定义的文件系统中,我们要求URI格式为"s3:///",因此getFileSystem和newFileSystem方法需要检测URL是否满足这一要求,检测完URI后,我们直接创建一个S3FileSystem返回给调用方
    • getScheme方法,该方法返回文件系统的scheme,我们直接返回"S3"这个字符串即可
    • getPath方法,该方法接收一个URI类型的对象作为入参,刚才也说过了,不同的文件系统采用的URI来进行区分,通过URI的schema来决定使用何种文件系统(是本地文件系统还是自定义S3文件系统),URI中的path信息决定了我们需要使用到对应文件系统的那一层级的具体文件或者文件夹
    • newByteChannel方法,该方法需要重点关注Path对象和OpenOption的Set集合类对象(options),该方法用于创建SeekableByteChannel对象,SeekableByteChannel属于NIO中的一种Channel,实现了Channel接口,该Channel的特殊之处在与可以设置和查看该Channel的position位置(我们可以假想一种情况,如果我们某一次文件传输发生了部分成功部分失败问题,那么我们可以使用SeekableByteChannel重新定位到上次传输失败的地方,实现文件的续传),我们回到newByteChannel的方法参数,Path对象用于确定我们需要处理的文件,options用于确定我们创建的Channel的读写模式,举个例子,如果应用层需要写入一个新的文件,且如果存在了同名文件的情况下需要报错,那么options可能会包含StandardOpenOption.CREATE_NEW,如果应用曾需要写入一个新的文件,且如果存在了同名文件的情况下不需要报错,那么options可能包含了StandardOpenOption.CREATE,如果要读取一个文件的时候,那么options可能包含了StandardOpenOption.READ,我们在考虑实现newSeekableChannel的时候,应当考虑到我们的S3是否提供了充足的API接口来保证所有情况都能满足,如果不满足,需要将不支持(或者强行支持带来了非常大的性能损耗)的情况额外说明补充,防止后续应用上层的错误使用,例如在此次实践中,我们将只支持对StandardOpenOption.WRITE、StandardOpenOption.READ、StandardOpenOption.CREATE、StandardOpenOption.CREATE_NEW、StandardOpenOption.TRUNCATE_EXISTING、StandardOpenOption.APPEND的支持,而对于其他(例如StandardOpenOption.SYNC)情况,我们将直接抛出IOException
    • newFileChannel方法:该方法直接使用newSeekableByteChannel的逻辑即可
    • newDirectoryStream方法,这个方法要求主要是用于文件(目录)列表查询,参数是一个Path对象,要求返回的数据是DirectoryStream对象,DirectoryStream是一个接口,因此我们也需要去实现自己的S3DirectoryStream,并实现iterator()方法,在该方法内,我们直接映射到S3的ListObject接口,返回我们需要查询的列表数据,并将这些数据转换为iterator类
    • createDirectory方法,该方法用于创建一个目录,基本的实现逻辑应该是首先判断是否有同名文件或者同名目录,如果有的话直接跑出FileAlreadyExistsException,如果没有,需要获取参数Path的parent目录,如果parent目录不存在,我们也需要抛出IOException,最后我们直接映射到S3的PutObject方法,创建一个/结束的空对象来表示目录
    • delete方法,该方法用于删除文件或目录,我们的基本实现逻辑为先判断文件文件或者目录是否存在,不存在抛出NoSuchFileExcpetion,如果是文件删除,我们直接调用S3的DeleteObject接口即可,如果是目录删除,我们需要循环调用ListObject接口,返回使用S3的DeleteObjects批量方式进行对象删除
    • copy方法,该方法用于拷贝文件或者目录,基于现实(我们团队当前并没有使用合适的的分布式锁机制来控制并发,之所以没有redis、zk这种工具,完全是因为技术领导对一致性的忽视以及对系统简单的极致追求,且领导认为尤其在B2B领域的文件交换场景中,无需额外关注并发问题,对此我持有保留意见)我并没有去实现目录拷贝功能,因为目录拷贝通常意味着大批量文件的操作,如果存在有客户端操作同一个目录的的情况,容易导致数据错乱;文件的拷贝映射为S3的CopyObject操作(我们姑且看看后续是否会有业务影响)
    • move方法,该方法用于文件/目录移动或者文件/目录重命名,基本逻辑为:先判断是否是同一个文件,如果是同一个文件,直接返回,否则判断原文件是否存在,不存在需要抛出NoSuchFileException,判断目标文件是否存在,如果目标文件存在,需要抛出FileAlreadyExistsException;需要注意的是,如果是目录的移动或者重命名,伴随着S3的多次拷贝(S3不支持批量拷贝)和批量删除,在没有上锁的情况下多端操作容易导致不可控或者数据错乱,且考虑到在当前业务中,目录移动和重命名是低频操作,因此暂时不支持非空目录的移动和重命名操作
    • readAttributes方法,该方法为比较重要的方法之一,用于查询文件的属性,需要返回一个BasicFileAttributes对象,鉴于S3的目录特殊原因(目录/结束的对象,且多级文件的结构中,目录并非一定要存在),我们使用S3的head方法来判断普通文件是否存在,使用s3的ListObjects来判断是否是目录,之所以不是用HeadObject来判断是否是目录,是因为多级文件的结构中,目录并非一定是存在的,例如aaa/bbb/ccc/ddd.jpg,对象aaa/bbb/ccc/并非一定存在,使用HeadObject的时候会返回404,但是aaa/bbb/ccc/这一层逻辑目录是的的确确的存在
    • 其他方法再次不再一一详细展开,后续有必要我再加以补充,对于对接SFTP的自定义文件系统而言,上述详细讲解的这几个方法已经足够apache-mina-sshd-sftp组件使用
  • S3FileChannel核心方法实现

    • read(ByteBuffer dst)方法:该方法接收一个ByteBuffer类型的dst参数,要求将Channel对应的文件数据读取到dst中,在该方法中我们仅仅需要建立和S3的Http连接,然后通过S3的GetObject接口获取到对象的InputStream,具体的实现方式可以使用HttpClient5或者官方SDK,然后根据dst的可写入数据长度,读取InputStream的对应长度的数据写入dst即可,如果InputStream已经读取完成,直接返回-1即可,该方法运用在文件系统读取文件数据的时候(例如SFTP下载文件)
    • write(ByteBuffer src)方法:该方法接受一个ByteBuffer类型的src参数,要求将ByteBuffer对应的数据写入到Channel对应的文件中,同read方法,我们仅仅需要建立和S3的Http连接,然后通过S3的PutObject接口将数据进行上传,但是在这里我们额外需要考虑一点,我们在进行S3的文件上传操作的时候,通常是使用到一个InputStream,SDK或者HttpClient从这个InputStream中读取数据然后进行上传,但是这个地方的ByteBuffer显然不能当作InputStream来处理,它不能完整的读取到一个文件的所有内容,有可能只是文件的一部分,因此在这里我更倾向于使用Netty自定义一个Http客户端,建立和S3的TCP连接后,逐步将ByteBuffer的数据推送给S3
    • FileChannel position(long newPosition)方法:该方法用于Channel对象设置新的position,假想一种情况,我们在使用SFTP进行文件下载的时候,文件下载到一半的时候,因为网络原因断开了SFTP的TCP连接,后续我们重新连接到SFTP服务器的时候我们肯定是想着能够接着下载,这时候SFTP客户端会发起一个GET操作,并指定了上次文件下载的位置,然后Channel只要从新的position开始读取数据即可,这就是这个方法的用图
    • public long position() throws IOException:该方法用于获取当前Channel的读写位置,我们在每次对Channel进行读写的时候,应当记录读取和写入的位置信息,该方法被调用的时候直接返回对应的位置信息即可
    • protected void implCloseChannel():该方法用于在调用FileChannel的close方法的时候的一个回调方法,可用于关闭文件资源(例如HTTP连接)
    • 其他方法再次不再一一详细展开,后续有必要我再加以补充,对于对接SFTP的自定义文件系统而言,上述详细讲解的这几个方法已经足够apache-mina-sshd-sftp组件使用
  • S3FileSystem的核心方法实现

    • public MySftpPath getDefaultDir():该方法用于获取文件系统的默认目录,通常直接返回/目录(根目录)即可
    • public boolean isReadOnly():该方法用于返回文件系统是否是只读文件系统,根据自己的业务来设定即可,在SFTP自定义文件系统的场景里面,读写都需要支持,因此该方法返回false即可
    • public FileSystemProvider provider():返回创建文件系统的FileSystemProvider,此处直接创建S3FileSystem的S3FileSystemProvider对象
    • public String getSeparator():目录分隔符,返回"/"即可
  • S3Path的核心方法实现

几点有意思的想法

  • 为什么上传文件敢于不加锁,因为SFTP本身就是基于IO多路复用的且请求响应的多数据包,其本身不提供SFTP的加锁操作(原子传输),在使用本地文件系统的过程中,并发上传服务器同名文件,本身就存在不可控风险,即使操作系统提供了锁机制,因此,在使用S3文件系统的时候,我们可以不加锁(事实上我在SFTPGO、mina-sftp-server组件以及原始的SSH协议中使用了并发上传,发现文件的sha256值一直都会变化),当然我们在具体实现S3FileChannel的时候可以实现lock操作,但是SFTP服务上层并不保证会调用fileChannel.lock方法,而事实上,apache-mina-sftp-server组件的源码中,也没有主动去发起lock的操作
  • 批量操作不加锁:
  • SFTP只保证安全传输,不保证文件传输准确(正如CBG所用,最后的保证需要手动sha256文件)

踩坑记录

  • 打成springboot jar包后S3文件系统无法使用,后面发现java的SPI机制和springboot的jar包格式有冲突,使用java -jar springboot包运行的时候加载不到service文件,java的spi机制只适用于普通jar包,springboot jar是插件改造过后的jar包,内部存储和普通jar包格式已经不相同了,后续弃用了java spi注入S3FileSystemProvider的方式,适用bean方式构造了S3FileSystemProvider
  • 务必同样一个所有的S3Path对象都由同一个S3FileSystemProvider构造出来(使用单例bean),Files工具类操作多个Path的时候,多个Path采用不同的FileSystemProvider会有额外的处理方式,例如Files.move(src,dst),如果src和dst不是同一个S3FileSystemProvider,会采用从src获取文件流传入dst文件流的方式,这时候效率就慢了(本来仅仅需要一个s3 copy操作和一个s3 delete操作)

改造后性能比较

新老文件系统兼容和历史文件处理