起因
这几年在公司开发我都是用4年前买的mac笔记本的,4年他终究是顶不住了,时不时出现内存不足提示,严重影响工作效率,于是跟公司申请了一台Windows台式电脑。 因为旧的项目使用的是php的swoole开发,新电脑不想把本机环境搞乱,直接使用docker来部署,也很省事,可没想到通过容器文件挂载的方式启动swoole程序,启动时间长达2分钟,这在代码调试的时候简直不能忍受。
思考
前面我们说到使用到了挂载文件的方式在容器内启动swoole程序,那么为什么光是启动就这么慢呢?
docker-compose.yml
yaml
version: "3.8"
services:
user_service:
image: xxxx.com/right/swoft-base:local
container_name: user_service
volumes:
- ./user_service:/app/user_service
ports:
- 1003:1003
working_dir: /app/user_service
command: sh -c "php bin/swoft http:start"
network_mode: host
分析
我们可以看到,在启动容器时,docker-compose会将本地的user_service目录挂载到容器的/app/user_service目录下,然后启动swoft程序。
理论上文件都拷贝进去容器了,文件都在容器内的操作系统,启动不就应该像宿主主机一样吗?
在宿主主机操作系统中我们读取文件是这样的(容器内也可以理解为一个操作系统)
应用程序发起请求
应用程序通过系统调用(如read())请求读取文件,将请求传递给系统内核。
内核处理请求
内核接收到请求后,根据文件描述符等信息,找到对应的文件,并确定要读取数据的位置。
文件系统层读取文件
内核请求文件系统层去读取文件。文件系统层会根据文件的存储位置等信息,从磁盘等存储设备中读取文件数据。如果数据在页缓存中,直接从缓存读取;否则,从磁盘读取数据到页缓存,再从缓存中读取。
数据返回
文件系统层将读取到的数据返回给内核,内核再将数据返回给应用程序,应用程序便可以对这些数据进行后续处理。
难道因为挂载的原因,在启动程序读取项目文件时跟在宿主主机不一样?带着这个因为我去查阅了资料
原理解析
经过查阅资料可知,当容器内程序发起读操作文件时,会发生这些事情:
容器内发起读请求
容器内的应用程序(如一个运行在容器中的Python脚本)发起对挂载文件的读请求。例如,应用程序执行open('/container/path/file.txt', 'r')来打开文件,并准备读取内容。
容器文件系统层处理
容器的文件系统层(通常是联合文件系统,如OverlayFS)接收到读请求。它会检查挂载点信息,确定这个文件实际上映射到了宿主机的哪个路径。例如,容器内的/container/path/file.txt映射到了宿主机的/host/path/file.txt。
请求传递给Docker守护进程
容器文件系统层将读请求传递给Docker守护进程。Docker守护进程是Docker架构中的核心组件,负责管理容器的生命周期和资源分配等。
Docker守护进程与宿主机内核交互
Docker守护进程将读请求转发给宿主机的内核。内核根据挂载点的映射关系,找到宿主机文件系统中对应的文件。内核会调用文件系统驱动程序(如ext4驱动程序,如果宿主机使用ext4文件系统)来处理读请求。
宿主机文件系统读取数据
宿主机的文件系统驱动程序从磁盘(或缓存中,如果数据已经在缓存中)读取文件的内容。这个过程可能涉及到磁盘I/O操作,数据从磁盘的特定扇区读取到内存中。
数据回传给容器
读取到的数据首先传递回宿主机内核,然后通过Docker守护进程返回给容器的文件系统层。最终,数据被传递给容器内的应用程序,应用程序就可以读取到文件的内容了。
原来如此,挂载文件的读取还需要经过docker守护进程转发给宿主机内核,再经过文件系统层,最后才到达应用程序。这就导致了读取一个文件相比于直接在宿主机上读取文件,需要多花费一倍的时间。
实验
为了验证上述原理,我们可以做一个实验。循环10000次去读取同一个文件,打印出这挂载的文件和容器的文件不同读取时间。
/home/README.md 为容器内的文件
/app/README.md 为挂载的文件
shell脚本
bash
#!/bin/bash
# 检查是否提供了文件路径参数
if [ -z "$1" ]; then
echo "Usage: $0 <file_path>"
exit 1
fi
# 获取文件路径参数
file_path="$1"
# 检查文件是否存在
if [ ! -f "$file_path" ]; then
echo "File not found: $file_path"
exit 1
fi
# 初始化总耗时
total_elapsed_time=0
# 打开文件1万次
i=1
while [ $i -le 10000 ]; do
# 获取开始时间(毫秒)
start_time=$(date +%s%3N)
# 读取文件内容
cat "$file_path" > /dev/null
# 获取结束时间(毫秒)
end_time=$(date +%s%3N)
# 计算耗时(毫秒)
elapsed_time=$((end_time - start_time))
# 累加总耗时
total_elapsed_time=$((total_elapsed_time + elapsed_time))
# 增加计数器
i=$((i + 1))
done
# 输出总耗时和平均耗时
echo "Total elapsed time: $total_elapsed_time milliseconds"
实验结果与分析
看到这个结果,我们可以得出结论,在容器内读取挂载文件,需要经过docker守护进程、文件系统层、内核等多个步骤,耗时比直接在宿主机上读取文件要多。 挂载文件(挂载进容器的文件)的访问速度比容器文件(容器内的文件)要慢,这也是为什么容器内程序启动时间长的原因。 既然这样,我们直接通过容器文件来启动程序不就好了吗?
解决方法
我们可以直接通过容器文件来启动程序,这样就不用挂载文件了。
修改docker-compose.yml
yaml
version: "3.8"
services:
user_service:
image: XXXX.com/right/swoft-base:local
container_name: user_service
volumes:
- ./user_service:/app/user_service
- ./rsync:/app/rsync
ports:
- 1003:1003
working_dir: /app/user_service2
command: sh -c "sh /app/rsync/cron.sh &&php bin/swoft http:start"
network_mode: host
bash
#!/bin/bash
rsync -azv --delete --exclude-from=/app/rsync/exclude /app/user_service/ /app/user_service2
(
while true; do
rsync -azv --delete --exclude-from=/app/rsync/cron_exclude /app/user_service/ /app/user_service2
sleep 2
done
) &
exclude文件
.git
.idea
.vscode
cron_exclude文件
bash
.git
.idea
.vscode
vendor
runtime
*.log
在这个示例,我将项目user_service挂载进/app/user_service目录,同时在容器内通过rsync工具复制到user_service2目录,使用容器内user_service2来启动服务,避免的挂载文件的读写,读写文件更高效。 然后启动一个shell脚本监听挂载文件目录的文件变化并同步给启动目录user_service2中。
定时同步的脚本是cron.sh,它会每隔2秒同步一次,同步时会排除掉.git、.idea、.vscode、vendor、runtime、*.log等目录,这样可以避免同步过多无用的文件。
注意当前前提是镜像内已安装好rsync包,如果没有安装,可以安装一下。
容器内的程序启动命令也修改为sh /app/rsync/cron.sh &&php bin/swoft http:start,这样容器内的程序会先同步一次,然后再启动swoft程序。
这样,容器内的程序就能直接访问宿主机的目录了,启动时间也大幅度缩短。直接从几分钟变成几秒钟,虽然还需要几秒钟但这个速度足够可以进行本地的开发调试了。
怎么说呢?也算是曲线救国了
总结
通过容器文件挂载的方式启动swoole程序,启动时间长达2分钟,这在代码调试的时候简直不能忍受。
通过实验,我们可以得出结论,在容器内读取文件,需要经过docker守护进程、文件系统层、内核等多个步骤,耗时比直接在宿主机上读取文件要多。
既然这样,我们直接通过容器文件来启动程序不就好了吗?
通过定时同步的方式,我们可以将宿主机的user_service目录同步到容器的user_service2目录,这样容器内的程序就能直接访问宿主机的目录了。
这样,容器内的程序就能直接访问宿主机的目录了,启动时间也大幅度缩短。