code:https://github.com/moby/moby/tree/v0.1.0
代码结构
├── archive.go
├── archive_test.go
├── auth # 主要用于服务器认证
│ ├── auth.go
│ └── auth_test.go
├── AUTHORS
├── changes.go
├── commands.go
├── container.go
├── container_test.go
├── contrib # 一些脚本的源码
│ ├── install.sh
│ ├── mkimage-busybox.sh
│ └── README
├── deb # 系统打包源码
│ ├── debian
│ │ ├── changelog
│ │ ├── compat
│ │ ├── control
│ │ ├── copyright
│ │ ├── docs
│ │ ├── rules
│ │ └── source
│ │ └── format
│ ├── etc
│ │ ├── docker-dev.upstart
│ │ └── docker.upstart
│ ├── Makefile -> ../Makefile
│ ├── Makefile.deb
│ └── README.md -> ../README.md
├── docker # 实现docker核心功能
│ └── docker.go # 程序入口
├── docs # 文档目录
│ ├── images-repositories-push-pull.md
│ └── README.md
├── graph.go
├── graph_test.go
├── image.go
├── LICENSE
├── lxc_template.go
├── mount_darwin.go
├── mount.go
├── mount_linux.go
├── network.go
├── network_test.go
├── NOTICE
├── puppet # 基于puppet部署工具的相关源码
│ ├── manifests
│ │ └── quantal64.pp
│ └── modules
│ └── docker
│ ├── manifests
│ │ └── init.pp
│ └── templates
│ ├── dockerd.conf
│ └── profile
├── rcli # Remote Command-Line Interface 一个简单协议的命令行界面
│ ├── http.go
│ ├── tcp.go
│ └── types.go
├── README.md
├── registry.go
├── runtime.go
├── runtime_test.go
├── state.go
├── sysinit.go
├── tags.go
├── term # term的跨平台支持
│ ├── term.go
│ ├── termios_darwin.go
│ └── termios_linux.go
├── utils.go
├── utils_test.go
└── Vagrantfile
后面go.mod发展
参考链接
https://learn-docker-the-hard-way.readthedocs.io
本文概述
本文讲述笔者在学习docker过程中的心路历程,从docker的整体框架开始初识docker,之后开始阅读v0.1.0版本的docker源码,然后跟着教程用go实现了一个mini的docker,最后再去回看docker的源码,总结而言,是一个理论指导实践,实践加深理论的理论与实践的循环学习过程。
初识docker
Docker is a set of platform as a service(PaaS) products that use OS-level virtualization to deliver software in packages called containers.
这是wikipedia上对docker的定义,这句话高瞻远瞩地从宏观上告诉了我们docker是什么,确实很难找到比这更为准确的定义,但是,OS-level的虚拟化技术又是指什么,为什么要有docker,容器又有什么用呢?
在接触docker之前一个相关的概念是虚拟机,它可以模拟完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统。它与docker一样,都是一种虚拟化技术,不同点在于:
- Docker 属于 Linux 容器的一种封装,提供简单易用的容器使用接口。它并不是完整的操作系统,只是相当于在正常进程外套了壳。容器内的进程接触到的各种资源都是虚拟的,但本质上都是在调用底层系统,不过通过命名空间等技术实现了与底层系统的有效隔离。
- 虚拟机通过在物理主机上运行一个完整的操作系统来实现虚拟化。每个虚拟机都有自己的操作系统内核、系统进程和设备驱动程序。
简单而言,很多时候我们运行一些程序并不需要一个整个操作系统环境,只需要某些接口就能work,那从资源、从运行时间等角度考虑,就没必要模拟整个操作系统(虚拟机),只需要模拟一部分(容器)。
我们已经明确了这个东西是有用的, 那怎么用呢?
docker 有镜像(Image)、容器(Container)、仓库(Respository)三个基本概念。其实可以用代码库的概念来类比,仓库就如github、gitlab等公开或者私有的仓库,镜像就像是代码里的仓库,这是不变的,而容器就是我将仓库里的代码拉到的本地版本,可以本地修改,也可以通过某些操作将这种修改传递到仓库的镜像上。当然,镜像与容器更像是类与实例的关系。使用docker过程即,明确需求获取对应的仓库镜像,创建本地容器,根据业务的具体要求修改容器。
那这又是如何实现的呢,这样一个虚拟化的技术,这样一个从镜像到容器的过程docker又是怎么做的呢?
这个答案其实在docker的架构中都能找到答案,docker服从C/S架构。Clinet即用户与Docker Daemon通信的客户端,即命令行终端,其包装命令发送api请求。而Docker的服务端是送耦合的结构,各模块各司其职,有机组合。其中daemon是常驻后台运行的进程,接受客户端请求并管理docker容器,engine是真正处理客户端请求的后端请求。具体为:
- docker client和daemon建立通信,client发送请求给daemon
- daemon作为主体部分,提供server功能,能让其接受client的请求
- engine执行处理内部的一系列工作
- 每个工作是一个job形式存在,需要镜像时,从docker registry中下载,通过graphdrive镜像管理驱动下载镜像并存储(Graph形式)。
- networkdrive负责创建配置容器的网络
- 运行用户指令或者限制容器资源时,通过execdrive完成
- execdrive以及networkdrive通过libcontainer具体实现
从镜像到容器:docker run命令发生了什么?
(1) Docker Client接受docker run命令, 发送http请求给到server; (2)Docker Server接受以上HTTP请求,并交给mux.Router,mux.Router通过URL以及请求方法来确定执行该请求的具体handler; (3)mux.Router将请求路由分发至相应的handler,具体为PostContainersCreate;(4) PostContainersCreate中create job被创建 (5)create job调用graphdriver并将rootfs下镜像加载到docker容器对应位置 (6) 上述过程无误,则类似上述过程创建start job并交由networkdrive来实现,制止创建容器并最终执行用户要求启动的命令。详见参考连结
可以发现,本质上就是client与server的通信,是serve内部组件的通信,接收到client指令,server负责根据命令分发至对应handle, 对应handle创建相应job, 而engine则根据不同的job调用不同的后端组件来实现job, 实现job后再将结果返回到client。
容器的OS-level虚拟化支持主要是由libcontainer提供的, 它是Docker架构中一个使用Go语言设计实现的库,设计初衷是希望该库可以不依靠任何依赖,直接访问内核中与容器相关的API。正是由于libcontainer的存在,Docker可以直接调用libcontainer,而最终操纵容器的namespace、cgroups、apparmor、网络设备以及防火墙规则等。这一系列操作的完成都不需要依赖LXC或者其他包。
从docker v0.1.0开始
已经基本了解了docker的架构和它的一些模块,那接下来想要深入了解它最好的方法自然是阅读源码,当前github上moby仓库目前的版本代码量极大,并不容易阅读,于是从docker的v0.1.0版本代码开始,代码文件并不多,且已经具备了docker的核心功能。
入口函数位于/docker/docker.go
// ./docker/docker.go
func main() {
if docker.SelfPath() == "/sbin/init" {
// Running in init mode
docker.SysInit()
return
}
// FIXME: Switch d and D ? (to be more sshd like)
fl_daemon := flag.Bool("d",false, "Daemon mode")
fl_debug := flag.Bool("D",false, "Debug mode")
flag.Parse()
rcli.DEBUG_FLAG = *fl_debug
if *fl_daemon {
if flag.NArg() != 0 {
flag.Usage()
return
}
if err := daemon(); err !=nil {
log.Fatal(err)
}
}else {
if err := runCommand(flag.Args()); err !=nil {
log.Fatal(err)
}
}
}
可以看到,代码运行首先判断docker可执行文件的绝对路径是否在/sbin/init目录下,如果在,则设置docker容器启动之前的环境 ,进行初始化。如果不存在则根据参入的命令行参数:去选择是启动docker deamon 还是执行 docker cli 的命令调用。
SysInit
系统的初始化设置位于/sysinit.go
//sysinit.go
func SysInit() {
iflen(os.Args) <= 1 {
fmt.Println("You should not invoke docker-init manually")
os.Exit(1)
}
var u = flag.String("u", "", "username or uid")
var gw = flag.String("g", "", "gateway address")
flag.Parse()
setupNetworking(*gw)
changeUser(*u)
executeProgram(flag.Arg(0), flag.Args())
}
其中包含
- 网络设置:根据参数中的网关ip添加网关路由
- 用户设置:根据参数中的username或uid,通过系统调用设置uid和gid
- 启动docker程序:启动docker程序,根据命令行参数决定启动docker deamon还是docker cli的命令
daemon
如果输出参数为-d 则启动daemon
// ./docker/docker.go
func daemon()error {
// NewServer在commands.go中
service, err := docker.NewServer()
if err !=nil {
return err
}
return rcli.ListenAndServe("tcp", "127.0.0.1:4242", service)
}
启动daemon是在创建server对下岗,然后通过该对象启用tcp服务。而创建server其实就是在创建运行时对象
// command
func NewServer() (*Server,error) {
rand.Seed(time.Now().UTC().UnixNano())
if runtime.GOARCH != "amd64" {
log.Fatalf("The docker runtime currently only supports amd64 (not %s). This will change in the future. Aborting.", runtime.GOARCH)
}
runtime, err := NewRuntime()
if err !=nil {
returnnil, err
}
srv := &Server{
runtime: runtime,
}
return srv,nil
}
在创建runtime时,首先会在 /var/lib/docker目录下创建对应的文件:containers,graph文件夹,然后创建对应的镜像tag存储对象,通过名为lxcbr0的卡的网络创建网络管理,最后创建dockerhub的认证对象AuthConfig,详见代码注释。
func NewRuntime() (*Runtime,error) {
return NewRuntimeFromDirectory("/var/lib/docker")
}
func NewRuntimeFromDirectory(rootstring) (*Runtime,error) {
runtime_repo := path.Join(root, "containers")
// 创建/var/lib/docker/containers目录
if err := os.MkdirAll(runtime_repo, 0700); err !=nil && !os.IsExist(err) {
returnnil, err
}
// 创建/var/lib/docker/graph目录,同时创建Graph对象
g, err := NewGraph(path.Join(root, "graph"))
if err !=nil {
returnnil, err
}
// 创建var/lib/docker/repositories目录,同时创建TagStore对象
repositories, err := NewTagStore(path.Join(root, "repositories"), g)
if err !=nil {
returnnil, fmt.Errorf("Couldn't create Tag store: %s", err)
}
// 通过名为lxcbr0的卡的网络创建网络管理
netManager, err := newNetworkManager(networkBridgeIface)
if err !=nil {
returnnil, err
}
// 读取认证文件
authConfig, err := auth.LoadConfig(root)
if err !=nil && authConfig ==nil {
// If the auth file does not exist, keep going
returnnil, err
}
// 创建runtime对象
runtime := &Runtime{
root: root, // /var/lib/docker
repository: runtime_repo, // /var/lib/docker/containers
containers: list.New(), // container/list(list.New())双向链表
networkManager: netManager, // NetworkManager
graph: g, // Graph
repositories: repositories, // TagStore
authConfig: authConfig, // AuthConfig
}
// 读取/var/lib/docker/containers目录,实际就是所有之前运行过的容器的目录
// 检查配置中的id和所加载的容器id是否一样,以此判断容器信息是否被更改过
if err := runtime.restore(); err !=nil {
returnnil, err
}
return runtime,nil
}
创建好serve后 配置tcp服务端
// ./rcli/tcp.go
func ListenAndServe(proto, addrstring, service Service)error {
// 创建监听器
listener, err := net.Listen(proto, addr)
if err !=nil {
return err
}
log.Printf("Listening for RCLI/%s on %s\n", proto, addr)
defer listener.Close()
for {
// 接受tcp请求
if conn, err := listener.Accept(); err !=nil {
return err
}else {
gofunc() {
if DEBUG_FLAG {
CLIENT_SOCKET = conn
}
// 处理请求
if err := Serve(conn, service); err !=nil {
log.Printf("Error: " + err.Error() + "\n")
fmt.Fprintf(conn, "Error: "+err.Error()+"\n")
}
conn.Close()
}()
}
}
returnnil
}
请求处理过程于/rcli/type.go LocalCall()所示
, 具体为获取请求中的参数然后调用call,call根据参数是否有值来执行不同方法,如果没有参数,则执行runtime的help方法;如果有参数,进行参数的处理,处理逻辑:获取第二个参数,作为docker后的命令,然后获取命令之后的所有参数,整条命令进行日志打印输出,之后再通过cmd命令和反射技术找到对应的cmd所对应的方法,最后将参数传入方法,执行cmd对应的方法,结果返回connect中。connect在此作为io.Writer类型参数,命令结果将写入到其中。
runCommand
runCommand即客户端模式,详见代码注释
func runCommand(args []string)error {
var oldState *term.State
var errerror
// 检查标准输入模式,并确保环境变量NOARW非空
if term.IsTerminal(0) && os.Getenv("NORAW") == "" {
oldState, err = term.MakeRaw(0)
if err !=nil {
return err
}
defer term.Restore(0, oldState)
}
// FIXME: we want to use unix sockets here, but net.UnixConn doesn't expose
// CloseWrite(), which we need to cleanly signal that stdin is closed without
// closing the connection.
// See http://code.google.com/p/go/issues/detail?id=3345
// TCP连接服务端并传递参数
if conn, err := rcli.Call("tcp", "127.0.0.1:4242", args...); err ==nil {
// 启动一个goroutine从连接中接收stdout并写入os.Stdout
receive_stdout := docker.Go(func()error {
_, err := io.Copy(os.Stdout, conn)
return err
})
// 启动一个goroutine将os.Stdin的数据发送到连接中
send_stdin := docker.Go(func()error {
_, err := io.Copy(conn, os.Stdin)
if err := conn.CloseWrite(); err !=nil {
log.Printf("Couldn't send EOF: " + err.Error())
}
return err
})
if err := <-receive_stdout; err !=nil {
return err
}
if !term.IsTerminal(0) {
if err := <-send_stdin; err !=nil {
return err
}
}
}else {
// 如果连接失败,创建一个本地的docker服务器
service, err := docker.NewServer()
if err !=nil {
return err
}
// 使用本地docker服务器调用rcli.LocalCall来执行命令,将标准输入和标准输出传递给docker服务器
if err := rcli.LocalCall(service, os.Stdin, os.Stdout, args...); err !=nil {
return err
}
}
if oldState !=nil {
term.Restore(0, oldState)
}
returnnil
}
本章总结
从源码可以发现,docker v0.1.0完全基于lxc来实现。其初始化阶段先实现切换用户全线,添加网桥默认路由等功能,而后创建server端与client,server端通过docker daemon进行管理,将相关容器数据加载到runtime对象中,创建服务监听client端请求,并根据对应请求调用相应模块进行处理。
动手实现minidocker
纸上得来终觉浅,绝知此事要躬行。了解docker最好的当然是自己动手写一个。主要参考教程实现docker的虚拟化功能,具体包括namspace、cgroups、文件系统以及网络配置。
实验环境
Linux version 6.1.55-1-MANJARO + go1.21.2 linux/amd64
命名空间
cmd := exec.Command("/bin/zsh")
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
cmd.Env = os.Environ()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err !=nil {
log.Fatal(err)
}
启动一个新的shell并通过syscall.SysProAttr来配置进程属性,创建新的命名空间:
- CLONE_NEWUTS:隔离主机名和域名等系统标识。具体表现为:容器内更改hostname 主机名不受影响 hostname newname
CLONE_NEWPID:隔离进程 ID。具体表现为:容器内echo $$查看当前pid号,从1开始。但是pd -ef仍能看见主机内进程:
- 解决方案: mount -t proc proc /proc top确实看不到了 但是父节点挂了被卸载掉了
- 先mount —make-rprivate / 再mount -t proc proc
- CLONE_NEWNS:新的 Mount 命名空间,用于隔离文件系统挂载点。
- CLONE_NEWNET:新的网络命名空间,用于隔离网络设备。具体表现为 route -n 没有ip没有路由表都需要重新配置
- CLONE_NEWIPC:新的 IPC 命名空间,用于隔离进程间通信资源。具体表现为 主机ipcmk -Q创建消息队列 容器内ipcs无法看到
通过li nu x 的命令进行实验可以发现确实达到了各种隔离,且提高了对命名空间的理解。
但是当前仍然存在问题,即使用了主机内的文件系统,pwd仍能看到主机。
文件系统
下载文件系统https://cdimage.ubuntu.com/ubuntu-base/releases/16.04/release/并进行挂载。
switch os.Args[1] {
case "run":
fmt.Println("run mode: run pid", os.Getpid(), "ppid", os.Getppid())
initCmd, err := os.Readlink("/proc/self/exe") //读取当前进程可执行文件的路径
if err !=nil {
fmt.Println("get init process error", err)
return
}
os.Args[1] = "init"
cmd := exec.Command(initCmd, os.Args[1:]...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWNET | syscall.CLONE_NEWIPC,
}
cmd.Env = os.Environ()
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err !=nil {
log.Fatal(err)
}
return
case "init":
fmt.Println("init mode: run pid", os.Getpid(), "ppid", os.Getppid())
pwd, err := os.Getwd()
fmt.Println("pwd:", pwd)
if err !=nil {
fmt.Println("pwd", err)
return
}
path := pwd + "/ubuntu"
syscall.Mount("", "/", "", syscall.MS_BIND|syscall.MS_REC, "")
if err := syscall.Mount(path, path, "bind", syscall.MS_BIND|syscall.MS_REC, ""); err !=nil {
fmt.Println("Mount", err)
return
}
if err := os.MkdirAll(path+"/.old", 0700); err !=nil {
fmt.Println("mkdir", err)
return
}
//syscall.PivotRoot会报错invalid argument 可以先执行 unshare -m命令,然后将 ubuntu/.old 文件夹删除
//原因是systemd会将 fs 修改为 shared,pivot root 不允许 parent mount point和 new mount point 是 shared。
//参考:https://www.retainblog.top/2022/10/26/%E4%BD%BF%E7%94%A8Golang%E5%AE%9E%E7%8E%B0%E8%87%AA%E5%B7%B1%E7%9A%84Docker%EF%BC%88%E4%BA%8C%EF%BC%89/
err = syscall.PivotRoot(path, path+"/.old")
if err !=nil {
fmt.Println("pivot root", err)
return
}
//syscall.Chroot("./ubuntu-base-16.04.6-base-amd64") //Chroot切换进程文件系统 进程的根文件系统变化 但是进程的命名空间无变化
syscall.Chdir("/")
defaultMountFlags := syscall.MS_NOEXEC | syscall.MS_NOSUID | syscall.MS_NODEV
syscall.Mount("proc", "/proc", "proc",uintptr(defaultMountFlags), "")
cmd := os.Args[2]
err = syscall.Exec(cmd, os.Args[2:], os.Environ())
if err !=nil {
fmt.Println("exec proc fail", err)
return
}
fmt.Println("forever exec it")
return
}
fmt.Println("hello world")
}
增加init模式配置容器,此外运行多个容器进程会使用同一份根文件系统目录 容器的修改会改变其他 使用联和文件系统, 运行完在/root/mnt会出现两个容器空间, 运行完自动销毁。
网络配置
容器内部网络无法访问互联网,通过网桥实现。具体的linux内部配置为,minidocker即通过调用系统接口的该配置的代码实现。
# 允许防火墙 路由转发
iptables -A FORWARD -j ACCEPT
# 内核允许路由转发, 修改值为1
sudo bash -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
# 创建网桥
brctl addbr br0
# 设置网桥开启状态
sudo ip linkset br0 up #开启后通过ifconfig可以看到网桥 但是没有ip地址
# 为网桥分配ip地址
ip addr add 192.168.15.6/24 dev br0
# 创建veth设备 veth设备一般成对出现
sudo ip link add veth-red type veth peer name veth-red-br
sudo ip link add veth-bule type veth peer name veth-blue-br
# 开启主机上的veth网卡
sudo ip linkset veth-red-br up
sudo ip linkset veth-blue-br up # ifconfig可见
# 将veth-red放到red namespace里 进程的网络命名空间 先启动进程查看进程pid
sudo ip linkset veth-red netns 进程id
sudo ip linkset veth-blue netns 进程id
# veth设备一端链接到网桥上
sudo ip linkset veth-red-br master br0
sudo ip linkset veth-blue-br master br0
# 为veth设备添加ip 容器内
sudo ip linkset veth-red up
sudo ip addr add 192.168.15.5/24 dev veth-red # 设置后ifconfig可以看到veth-red route -n 路由表也配置上了192.168.15.0网段
sudo ip linkset veth-blue up
sudo ip addr add 192.168.14.7/24 dev veth-blue
# 现在两个容器可以访问 但是无法访问外网,需要通过网桥转发到eth0网卡
# 添加网关路由 容器内
ip route add defaule via 192.168.15.6 dev veth-red
ip route add default via 192.168.15.6 dev veth-blue
# 但是网络包出去后,回来后还需要
# 防火墙nat设置
iptables -t nat -A POSTROUTING -s 192.168.15.0/24 -j MASQUEREAD
cgroups
之前通过命名空间达到资源隔离,但是这种隔离无法对硬件资源的使用隔离,cgroups实现对cpu使用率进行限制。内核基本已经完成,只需要调用相关接口就行了。
cd /sys/fs/cgroup/ # 文件夹下包含cpu memory等 为cgroup的子系统
# cpu.cfs_period_us代表了cpu运行一个周期的时长
# 修改memory_limit_in_bytes文件可以设置程序的限制内存大小
cgroups的实现即通过系统接口实现对容器内部资源管理文件的修改。
再探docker源码
可以发现docker v0.1.0版本基本实现来功能,但其实还是不少问题,或者说功能点的欠缺。如采用的是aufs文件系统,而其在2.6.32 Linux内核中并不完全支持。后续的版本主要围绕其隔离程度、安全性、稳定性、功能等进行补充优化。
比如:
- 从LXC转向自有的libcontainer:后续版本中,Docker从依赖LXC转向了自己的容器运行时接口libcontainer(现在已经演化为containerd),这改善了安全性和可移植性。
- 安全性增强:引入了更多的安全特性,比如AppArmor、SELinux策略支持,以及后来的用户命名空间,这些都增加了容器的隔离性和安全性。
- 网络和存储驱动:引入可插拔的网络和存储驱动,支持多种网络配置和持久化存储选项。
- Orchestration and Scaling:引入了Docker Compose, Docker Swarm等工具,为容器编排和扩展提供支持。
- 界面和API:改进了命令行界面(CLI),增加了REST API,为自动化和集成提供了更强的支持。
- 开放和标准化:Docker开始参与并推动开放容器标准,例如Open Container Initiative (OCI)。
问题
可以发现,docker本质上还是基于linux的namespace和cgroup等接口实现的,但是也有docker for windows, 它是如何实现的呢。
可以发现两者结构很类似。与 Linux 类似,Windows 也新抽象出来了 CGroup 和 Namespace 的概念,并提供出一个新的抽象层次 Compute Service,即宿主机运算服务(Host Compute Service,hcs)。相较于底层可能经常重构的实现细节,hcs 旨在为外部(比如 Docker 引擎)提供较稳定的操作接口。
所以,本质上就是通过加抽象来解决问题。
总结
Docker is a set of platform as a service(PaaS) products that use OS-level virtualization to deliver software in packages called containers.
再去回看wikipad对docker的定义,说的确实不错,容器即隔离,docker是云原生时代必不可少的一项产品,上云需要虚拟化,而虚拟化需要docker,这种轻量的、可扩展的容器技术能够有效避免重复造轮子,能够极大了改善传统的环境配置问题,让软件服务能够即插即用。
参考链接
http://en.wikipedia.org/wiki/Docker_(software))
https://learn-docker-the-hard-way.readthedocs.io/zh-cn/latest/Part1/1_docker.go/
https://zhuanlan.zhihu.com/p/25773225
https://zhuanlan.zhihu.com/p/551753838
https://www.kancloud.cn/infoq/docker-source-code-analysis/80525
https://www.imooc.com/article/335433vo
https://www.yzktw.com.cn/post/1281202.html
https://insights.thoughtworks.cn/can-i-use-docker-on-windows/