写在文章开头
这是笔者在2022年左右写的一篇关于docker基本概念和实践的教程,随着AI的出现,开发者对于此类运维工具的学习和上手成本逐步降低,真正稀缺的,是正确地理解技术,而理解它要弄明白三件事:
- 它为什么这样设计
- 能解决什么
- 边界和风险在哪
而本文也将带读者把这篇旧文重过一遍,从软件架构痛点,引入docker技术实现内幕,结合一次完整的安装与配置步骤,让读者在“会用”之外,更懂它背后的设计理念、局限与风险。
SharkChili· 禅与计算机程序设计的艺术
开源贡献
- mini-redis:教学级 Redis 精简实现 · https://github.com/shark-ctrl/mini-redis
关注公众号,回复【加群】获得笔者联系方式加入技术交流群
为什么需要docker?docker解决了哪些问题?
开发者常遇到这样一件事:本地的系统跑得好好的,一部署到服务器就冒出各种环境、配置问题。于是人们想,能不能把整个环境连同系统整体打包,做到一次构建,随处快速部署?这也是计算机哲学一贯的主张——可复用。
我们最先想到的方案可能是虚拟机:它确实能把整套运行环境打包、迁移后直接部署。但缺点很明显——它把宿主机的整套guest内核、lib依赖库工具链以及应用层一并复制,一个完整 web 系统动辄几十GB的磁盘空间,启动要几十秒,一台物理机跑三五个虚拟机就内存告急。于是就有了 docker:从系统内核层面看,docker容器共享宿主系统内核,打包时只带必要的依赖和应用程序,磁盘空间占用小,启动亚秒级,这也是目前环境统一打包部署的主流方案之一。
相比虚拟机,docker 还带来几个直观的好处:
- 快速拉取:发送方把程序打包成镜像存到仓库(码头),其他用户直接拉取即可,整个过程就像一只鲸鱼拖着货物来回运输。
- 高效部署:改完代码,用 docker build 重建镜像、docker run 快速重新部署,环境不用手工重装。
- 统一管理:使用
docker之前,各程序启动命令不一——例如 nginx 用nginx、tomcat 用./startup.sh,而docker把启动/停止封装成统一命令(docker start/stop 容器)。 - 安全隔离:
docker使用基于 OCI 运行时(containerd 与 runc)实现,通过cgroup和namespace限制进程组所用的CPU、内存等资源。而且相对于虚拟机而言,该技术创建的隔离空间更快、更高效,容器间资源受限、互不干扰,部署更安全。
上文提到一个 OCI 的概念,我们可以把 docker run 想象成一条流水线。当我们键入 docker run 时:containerd 负责拉取镜像、看管容器生命周期,底层 runc(一个底层程序,按一套“造容器”的流程)按照给定的 cgroup、pivot_root、namespace 约束,完成容器构建。链路如下,这也回答了 docker run 是怎么把容器造出来的:
docker 三大核心概念
镜像:英文名叫image,就是我们打包好的应用——一个可复用的文件集合(运行文件、环境配置等),也是构建容器的模板/原料。
容器:拿镜像当模板,跑出来的一个带有隔离空间的运行实例,也就是 docker 的最终产物。
仓库:我们需要的镜像肯定都保存在某个仓库中,每次运输镜像都需要从这个仓库里查找
结合我们上述的三大概念,假设现在要快速构建一个 redis 容器,对应流程为:从仓库中拉取官方 redis 镜像,结合我们的配置约束,将镜像构建成一个隔离在宿主机的容器。
从设计层面理解docker的思想
隐藏
我们都知道传统虚拟机通过完整拷贝原始宿主文件和内核资源给使用者一份完整的使用体验,但这种重量级的拷贝操作使得虚拟机系统对于资源占用非常庞大,从整体资源利用率的角度来看,使用率也不高。
设计容器第一个考虑的问题,是如何隔离容器与宿主机的之间的文件系统,即限定容器活动于给定的物理空间。针对此问题,docker采用联合挂载 + chroot/pivot_root 换根(runc 实际用 pivot_root)综合解决这个问题。
我们不妨将这两个技术拆开来说,先来说说chroot,它是Linux内核系统调用,这里我们以docker实现的语言go来查看这个函数,从函数语义逻辑可以看出,改函数逻辑本质将传入的path作为Linux chroot的入参,将当前程序文件系统的根目录修改为path,由此将进程的文件系统空间局限在我们分配的虚拟根目录:
funcChroot(pathstring)(errerror){var_p0*byte//解析入参path生成_p0_p0,err=BytePtrFromString(path)//......//基于go语言的Syscall调用chroot并传入_p0即需要作为根的地址_,_,e1:=Syscall(SYS_CHROOT,uintptr(unsafe.Pointer(_p0)),0,0)ife1!=0{err=errnoErr(e1)}return}chroot 切换根目录的机理如下图所示:
隐藏文件隔离还不够,如何让虚拟根文件夹看起来像宿主容器一样,即具备/etc、/usr、/bin 这些目录呢?答案就是我们上文所说的联合挂载技术,docker通过分层技术将基础镜像(系统根文件系统)、依赖库、应用程序构建为一个个只读镜像。执行docker run的时候,这些镜像通过联合挂载到类似于 /var/lib/docker/overlay2/…/merged目录从而构建为一个完整的容器,期间对于所有写操作,都通过cow技术实现,例如:
- 修改镜像只读层里已有的文件(如 /etc/nginx/nginx.conf、/etc/passwd),docker 才通过 copy-up 把整个文件拷到可写层再改,只读层没有的文件直接在可写层新建,不发生 copy-up。而 /etc/hostname、/etc/hosts、/etc/resolv.conf 是 Docker 单独 bind-mount 进来的,不走 OverlayFS。
- java程序生成写入日志时,若只读层没有,则直接在写层生成对应文件。
如下图所示:
隔离
我们通过chroot约束容器的操作路径,但容器依然可以访问宿主资源,例如:键入ps依然可以访问docker外部容器进程,所以我们需要更进一步的隔离手段,将其操作空间进行约束。
查阅docker官网我们看到这样一段话:
Docker is written in the Go programming language and takes advantage of several features of the Linux kernel to deliver its functionality. Docker uses a technology called namespaces to provide the isolated workspace called the container. When you run a container, Docker creates a set of namespaces for that container.
翻译过来就是:Docker是用Go编程语言编写的,它利用了Linux内核的多种特性来实现其功能。Docker 使用一种名为namespaces的技术来创建隔离的工作区,这个工作区就被称为容器。从底层视角来看,真正创建容器进程的是 OCI 运行时 runc:它先用 unshare 建新 namespace、setns 加入已有 namespace,再用 clone 造出容器进程。docker 引擎只经 containerd 把任务交给 runc。
intclone(int(*fn)(void*),void*stack,intflags,void*arg,...);这里我们着重介绍flags那些针对命名空间隔离的标志,如下图所示,docker在进行clone调用时,通过如下标志位结合按位或进行hostname、进程、挂载点隔离:
- CLONE_NEWUTS:隔离 hostname(容器以为它叫 my-container 而不是宿主机名)
- CLONE_NEWPID:隔离进程列表(容器内从 PID 1 起看自己的进程树),注意只对 clone 之后的子孙进程生效。
- CLONE_NEWNS:独立 mount namespace——容器内挂载/卸载的文件树视图,宿主机及它进程看不到、也不受影响(不是“宿主机进程对容器不可见”,那是 PID namespace 的职责)。
- CLONE_NEWNET:隔离网卡/IP/路由/端口(对应后文 network namespace)。
- CLONE_NEWIPC / CLONE_NEWUSER:分别隔离信号量共享内存等进程间通信设施、以及容器内 root 对宿主机的用户映射。
行为限制
我们通过隐藏和隔离限制了容器的工作空间和执行范围,但并没有对其资源进行限定,所以在极端情况下,容器依然可以无限制的消耗宿主资源,所以就有了cgroup的概念,docker通过mem_limit指明容器的上限,如下所示,笔者将个人的java程序内存上限设置为4g:
backend:container_name:blog-backend mem_limit:4g # 容器总内存上限 environment:JAVA_OPTS:>--Xms2g-Xmx2g # 只限制Java堆-XX:MaxMetaspaceSize=256m基于cgroup技术,当资源达到上限后,系统对其进行限流(CPU超载),CPU 被节流(变慢、不杀),内存超限则先触发内核回收,回收不掉再由 OOM Killer 杀进程(退出码 137)。
100行代码理解docker
思路说明
docker 的设计核心,即:
- 联合挂载复用镜像
- chroot 隐藏宿主目录
- clone 调用隔离 namespace
- cgroup 约束容器资源
说明:本例为最小演示,只落地 chroot 与 namespace,联合挂载需要成型 rootfs、cgroup 需要内核 cgroup 接口,均超出本例范围,故不实现——上面四支柱这里只兑现两样,先把期望收窄。
从设计层面来说,docker的设计本质是利用Linux内核函数从操作空间、操作权限、资源占用对其进行隔离,我们不妨以一个最小化的例子来复刻学习docker对于资源隔离这一优秀的设计理念。
我们例子也很简单,即指定一个文件空间(以本次为例则是/home/sharkchili/tmp)作为我们本次容器目标工作空间,通过chroot对宿主主机进行隐藏,并通过clone调用限定可操作范围,使之具备独立的hostname、进程、挂载目录。我们最终期望达到:
- 键入
ls /只能看到 tmp 目录下的 rootfs 与预制文件夹(dir1、dir2、dir3) - 键入
hostname看到自定义主机名,说明 UTS 隔离生效 - 键入
ps只看到容器内进程(PID 1 的/bin/sh与 ps 自身),看不到宿主机进程,说明 PID namespace 隔离生效
前置准备
因为要 chroot 换根,而我们的“根”里没有 /bin/sh、/etc 等,shell 连自己都起不来,所以必须先在目标目录准备完整的rootfs,以笔者为例,即针对/home/sharkchili/tmp执行如下指令:
sudocurl-Ohttps://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.3-x86_64.tar.gzsudotar-xzfalpine-minirootfs-3.20.3-x86_64.tar.gz&&sudormalpine-minirootfs-*.tar.gz再建三个测试目录,让“预制好的 dir1/dir2/dir3”名副其实:mkdir -p /home/sharkchili/tmp/{dir1,dir2,dir3}
完成安装后,我们就可以得到完整的root目录:
功能落地
基于上述的设计,我们也就有了明确的思路,对应容器配置的核心步骤为:
- Go 程序监听用户输入参数,例如传入
/bin/sh时就创建一个隔离的子进程执行 shell - clone 调用(发生在父进程 run 中)通过
syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS隔离主机名、进程号视图和挂载表 - 子进程在 child 中执行 chroot 将
/home/sharkchili/tmp设置为根,通过os.Chdir("/")切到新根,并挂载/proc(mount -t proc proc /proc)让ps只反映本 namespace - 最后 exec 替换当前进程镜像,启动我们输入的命令
最终代码如下所示,当我们键入sudo go run main.go run /bin/sh 即希望创建一个隔离的容器执行shell时,这段代码逻辑为:
- 步入 run 分支,复用终端输入输出并通过 SysProcAttr 配置隔离的 namespace
- 通过
/proc/self/exe重新执行当前程序,把参数换成child /bin/sh,于是再次进入 main() 落到 child 分支 - main 分支步入 child 函数
- child 分支通过 chroot 切换根目录,通过
os.Chdir("/")切到新根,并挂载/proc(mount -t proc proc /proc) - 执行 shell 并启动
funcmain(){switchos.Args[1]{case"run":run()case"child":child()default:panic("what?")}}funcrun(){fmt.Printf("Running %v as a container\n",os.Args[2:])//重新执行当前go程序,并加上child参数,例如:sudo go run main.go run /bin/sh//子进程收到的就是 child /bin/sh//由此走到main分支执行child逻辑cmd:=exec.Command("/proc/self/exe",append([]string{"child"},os.Args[2:]...)...)//复用父进程终端输入和输出cmd.Stdin=os.Stdin cmd.Stdout=os.Stdout cmd.Stderr=os.Stderr// 创建新的 UTS、PID、Mount namespace,分别隔离主机名、进程号视图和挂载表cmd.SysProcAttr=&syscall.SysProcAttr{Cloneflags:syscall.CLONE_NEWUTS|syscall.CLONE_NEWPID|syscall.CLONE_NEWNS,}//启动并等待子进程must(cmd.Run())}funcchild(){// 子进程: 在新的namespace里运行fmt.Printf("Running %v in the child process as container\n",os.Args[2:])// 修改hostname验证是否与宿主机隔离must(syscall.Sethostname([]byte("sharkchili")))//将容器看到的根目录设置为 /home/sharkchili/tmpmust(syscall.Chroot("/home/sharkchili/tmp"))//将工作目录切到根目录must(os.Chdir("/"))// 在挂载namespace里重挂/proc,ps/top才能反映本namespace的进程(PID隔离可观测)os.MkdirAll("/proc",0755)// 保险:确保挂载点存在must(syscall.Mount("proc","/proc","proc",0,""))// 执行我们输入的命令must(syscall.Exec(os.Args[2],os.Args[2:],os.Environ()))}// 判断是否存在异常,若有则直接panic终止funcmust(errerror){iferr!=nil{panic(err)}}测试
需要注意的是上述代码涉及Linux函数调用,所以mac或者windows环境无法直接运行,我们需要直接编译并打包到Linux环境才能验收。所以笔者将程序部署到个人服务器并执行如下指令:
sudogo run main.go run /bin/sh可以看到,程序启动一个容器并执行shell:
查看hostname,输出的确实是笔者设置的sharkchili:
查看根目录,除了之前安装的rootfs以外,剩下的都是笔者tmp目录预制的文件夹,由此可知chroot隐藏生效:
实测结论:容器内ps只列出容器进程(PID 1 的 /bin/sh 与 ps 自身)、看不到宿主机进程——说明 PID namespace 隔离生效。
Running [/bin/sh] as a container
Running [/bin/sh] in the child process as container
/ # ps
PID USER TIME COMMAND
1 root 0:00 /bin/sh
6 root 0:00 ps
docker安装和配置
有了 AI,对于这种固定的安装部署工作其实没有任何压力,但本着完整性,这里还是基于Ubuntu 24.04(代号noble)环境给出完整步骤。
第一步:清理可能冲突的旧包(没装过也安全,提示找不到包属正常)
sudoapt-getremove-ydockerdocker-engine docker.io containerd runc第二步:装依赖并添加官方 GPG key(用阿里云源)
sudoapt-getupdatesudoapt-getinstall-yca-certificatescurlsudoinstall-m0755-d/etc/apt/keyringssudocurl-fsSLhttps://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg-o/etc/apt/keyrings/docker.ascsudochmoda+r /etc/apt/keyrings/docker.asc第三步:添加 apt 源(代号 / 架构已按本机写死:noble/amd64)
echo"deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.aliyun.com/docker-ce/linux/ubuntu noble stable"\|sudotee/etc/apt/sources.list.d/docker.list>/dev/null第四步:安装 Docker(含 compose、buildx 插件)
sudoapt-getupdatesudoapt-getinstall-ydocker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin第五步:把当前用户加入 docker 组(之后免sudo用 docker)
sudousermod-aGdocker$USERnewgrpdocker# 立即生效,或退出 SSH 重新登录第六步:验证(四条都成功即完成)
docker--versiondockercompose versionsudosystemctl statusdocker--no-pagerdockerrun hello-world第七步:配镜像加速器(必配:国内直连registry-1.docker.io会被拒,docker run hello-world会报connection refused)。公共加速地址时效性强,失效就搜“docker 镜像加速”换一个。
sudotee/etc/docker/daemon.json<<'EOF' { "registry-mirrors": ["https://docker.m.daocloud.io"] } EOFsudosystemctl restartdocker第八步:开机自启——docker 服务本身不用配,apt 装完已默认enabled(systemctl is-enabled docker返回enabled),容器要单独配--restart unless-stopped,机器重启后容器才自动拉起(手动docker stop过的不拉)。
基于docker部署nginx代理服务
完成 docker 基础环境配置安装之后,我们就来演示两个比较常见的程序部署——先来说说 nginx。整体按“拉取镜像 → 启动验证 → 观察验收 → 进入容器基础操作”几步走。
拉取镜像 pull 并验证
从公共镜像仓库(如 Docker Hub 或已配好的阿里云镜像源,原网易蜂巢仓库已关停)拉取 nginx 镜像:
dockerpull nginx完成后键入如下命令查看是否有nginx镜像:
dockerimages启动验证
用-d后台启动,并配端口映射-p 8080:80(否则浏览器访问不到默认页):
dockerrun-d-p8080:80 nginx键入下面这个指令查看 nginx 是否运行成功:
dockerps可以看到 nginx 已经成功运行了。
观察验收
nginx 默认端口是80,配端口映射(-p 8080:80)后在浏览器键入 ip 地址能看到下图就说明部署成功:
进入容器基础操作
用docker ps找到容器 id:
dockerps|grepnginx拿着CONTAINER ID运行docker exec -it <容器ID> bash,如下所示:
dockerexec-it5a2438be1163bash可以看到,我们就像进入一个新的操作系统一样操作的 nginx 容器。用ps -ef确认 nginx 是否在容器中运行(容器内可能没有ps命令,先装一下):
apt-getupdateapt-getinstallprocps也可以用which nginx看 nginx 位置,最后exit退出:
whichnginx停止 nginx:用docker ps找到容器 id,用stop即可。
dockerstop 5a2438be1163补充:docker 网络模式(nginx 端口映射的原理)
我们都知道docker的隔离性,网络也是个隔离性的一部分,Linux使用了命名空间来进行资源的隔离,比如pid namespace就是用来隔离进程的,mount namespace是用来隔离文件系统的,network namespace是用来隔离网络的.每一个network namespace都提供了一个独立的网络环境,包括网卡路由iptables规则等等,都是与其他network namespace隔离的.
docker容器在默认情况下,一般会分配一个独立的network-namespace,也就是网络类型中的Bridge模式(可理解为虚拟机的 NAT模式)。
因为 Bridge 模式用了独立的network namespace,容器对宿主机不可直接访问,所以需要用docker run -p把宿主机端口与容器端口做映射,外部用户才能通过宿主机端口访问到容器。
还有一种类型是
Host 模式(主机模式),如果指定使用Host模式,容器不会获得独立的network namespace,而是和宿主机共用一个,此时容器不会虚拟出自己的网卡、配置自己的 IP,而是直接用宿主机的 IP 和端口——相当于直接在宿主机上使用网络。还有一种网络类型是
None.也就是没有网络,这种情况docker将不会和外界的任何东西进行通讯。
结合上文 bridge(NAT)模式,用实操体会端口映射:固定映射用-p,随机映射用-P。
dockerrun-d-p8081:80 nginx把宿主8081映射到容器80,访问宿主 8081 即到容器 nginx,docker也支持随机分配映射端口,用-P即可。
dockerrun-d-Pnginx关于更多docker常用命令
因为有 AI,这些指令笔者就不逐一演示了,只列出常见命令,读者可结合 AI 动手实践理解。
| 目的 | 命令 | 说明 |
|---|---|---|
| 卸载软件包 | yum remove docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin | 用安装时同款包名卸载 |
| 彻底清理 | rm -rf /var/lib/docker | 删镜像、容器、卷及自定义配置 |
| 搜索镜像 | docker search java | 搜远程仓库镜像(NAME/DESCRIPTION/STARS/OFFICIAL) |
| 下载镜像 | docker pull java | 拉取镜像 |
| 列出镜像 | docker images | 看本地镜像(REPOSITORY/TAG/IMAGE ID/CREATED/SIZE) |
| 删除镜像 | docker rmi java | 删除本地镜像 |
| 创建并启动容器 | docker run … | 最常用,-d后台、-P随机端口、-p指定端口(ip:hostPort:containerPort等四格式)、--network指定网络(bridge/host/none/container:…) |
| 停止容器 | docker stop <容器id> | 先docker ps找容器 id |
| 进入容器 | docker exec -it <容器id> bash | 进容器操作 |
| 退出容器 | exit | 退出容器回宿主机 |
| 删除容器 | docker rm <容器id> | 删除容器 |
| 查看日志 | docker logs <容器id> | 查看容器日志 |
| 查看性能 | docker stats | 查看容器资源占用 |
小结
**小结:**把 docker 串成一条线——从仓库取镜像(应用与环境整体打包成一体),用镜像跑出隔离的容器,容器之间之所以相互隔离,靠 chroot/联合挂载隐藏文件系统、namespace 隔离进程与网络、cgroup 约束资源这三件套,配合 docker build/run/pull,就能做到一次构建、随处快速部署。
AI 时代,纯手工一条条敲这些命令已不再是价值所在——固定、模式化的部署操作交给 AI 即可复现,真正稀缺的,是看懂它为什么这样设计。本文用“100 行代码”这个最小单元,亲手把 chroot 隐藏宿主目录、namespace 隔离进程与网络这两个核心机制跑成可验证的实例,再由它引申到 cgroup 资源限制。理解边界与风险,才谈得上用对。带着"先借 AI 摸清原理,再用最小案例落地印证,最后回到日常应用"这条路径去读技术,是这个时代更准确的用法。
参考
docker入门利用docker部署web应用:http://t.csdn.cn/PYAr8
只需三步,完美卸载Docker:https://blog.csdn.net/wangerrong/article/details/126750198
解决docker启动报错"Error starting daemon: SELinux is not supported with the overlay2 graph driver on this:https://blog.csdn.net/haoding205/article/details/82492263
Docker 是怎么工作的?:https://mp.weixin.qq.com/s/TA9oDm6_BqBShgVkAOVx3A
Linux Clone 系统调用:深入理解进程与线程的创建基石:https://geek-blogs.com/blog/linux-clone/
Go语言100行代码实现 Docker:https://zhuanlan.zhihu.com/p/1933280608718656864