7天docker入门:第3天Dockerfile实战

引言

这是docker入门教程系列的第3篇,如果完成了前面2篇,我想你应该是初步学会使用Docker了:

如果没看,我建议你去看看,官方的教程,真的很好不枯燥。

那么接下来,你可能会考虑如何在项目中应用Docker,所以,我们今天主要是讲解如何编写Dockerfile以及一些实践技巧。

别人的学习经历

作者也是一边学Docker,一边记录。所以,我把我的学习经历分享更你,共勉,一起加油!

截止写本教程前,我完成了如下内容:

  • 《Docker技术入门与实战 第3版》看了60%,最主要是docker基础(容器、镜像、卷、网络)、dockerfile以及docker compose等
  • 实践完了官方的2个教程:getting-started特定语言指南(go)
  • 实践过程中根据:Docker中文文档 的目录,在脑海中建立了一个docker知识体系的认知,这个网站内容方面有些啰嗦且过时,参考意义更大。
    达成了初步的目标:
  • 为自己的开源分布式IM项目:Coffeechat 编写了3个Dockerfile并且成功运行
  • 每次手动启动多个Docker容器很麻烦,于是通过官方文档+查阅资料+调试实践花了半天,Docker compose的编排搞定了mysql,其他的redis还在研究中。
    在这期间,使用的编辑器有:
  • Visual Studio Code + Docker插件,其中可能是本机环境问题,compose文件没有智能补全,改为Goland编写compose文件
  • Goland + Docker插件
  • MacOS Big Sur,Docker destkop等

最后,啰嗦一下,不管是VS Code也好,Goland也好,选一款合适的IDE + Docker插件,会让我们少很多记忆负担~

Dockerfile回顾

完整内容请移步:特定语言指南(go)

一个简单的go项目

这是一个简单的Web项目(使用echo框架,但是我们并不需要关心框架细节),处理GET请求,返回一个“Hello Docker”。

源码只有一个main.go文件和2个go mod文件,内容如下:

$ git clone https://github.com/olliefr/docker-gs-ping
$ cd docker-gs-ping && vim main.go
package main

import (
    "net/http"
    "os"

    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := echo.New()
    e.Use(middleware.Logger())
    e.Use(middleware.Recover())

    e.GET("/", func(c echo.Context) error {
        return c.HTML(http.StatusOK, "Hello, Docker! <3")
    })
    e.GET("/ping", func(c echo.Context) error {
        return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
    })

    httpPort := os.Getenv("HTTP_PORT")
    if httpPort == "" {
        httpPort = "8080"
    }

    e.Logger.Fatal(e.Start(":" + httpPort))
}

执行后输出:

$ go run main.go
   ____    __
  / __/___/ /  ___
 / _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.2.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
                                    O\
⇨ http server started on [::]:8080

此时,再启动一个终端,发送一个http请求:

$ curl http://localhost:8080/
Hello, Docker! <3

基础Dockerfile

针对上述简单的Go项目,很容易编写一个Dockerfile将其改造成Docker项目:

# syntax=docker/dockerfile:1

FROM golang:1.16-alpine

WORKDIR /app

# 拷贝2个go mod文件
COPY go.mod ./
COPY go.sum ./
RUN go mod download

# 拷贝main.go源文件
COPY *.go ./

# 编译,-o:指定编译的程序名
RUN go build -o /docker-gs-ping

EXPOSE 8080

CMD [ "/docker-gs-ping” ]

简单的解释一下:

  • FROM:必须得在第一行,指明继承的基础镜像,就想面向对象编程基础一个类一样。如果要自己实现基础镜像,可以基于scratch惊喜,它的大小几乎为0。
  • WORKDIR:工作目录,后面所有的相对路径都是基于这里。
  • COPY:拷贝文件到docker守护进程,以进行编码编译
  • RUN:执行命令,可以理解为bash
  • EXPOSE:端口映射,Docker容器和虚拟机一样,默认情况下宿主机和虚拟机网络是不通的。通过这个命令,我们就可以通过宿主机访问这个端口了。
  • CMD:运行Docker容器时执行的命令。Dockerfile有2个阶段,编译和运行。这个命令只在运行容器时执行,这一点初学者要注意,否则会比较懵。

然后,我们通过如下命令把它编译成docker镜像(注意,这里的镜像并不是类似.iso等一个大文件,在docker中是只一系列文件层的集合,当然可以导出为一个类似iso包含所有层的镜像文件):

# 编译镜像
$ docker build --tag docker-gs-ping .
# 列出本地镜像
$ docker image ls
REPOSITORY     TAG    IMAGE ID     CREATED SIZE 
docker-gs-ping latest 336a3f164d0f 43 minutes ago 540MB
# 启动
$ docker run -p 8080:8080 docker-gs-ping

多阶段构建

我们看到构建后的镜像有540MB,其实程序就一个文件,大多数都是编译环境之类的占了空间。这个时候我们可以分开编译和运行。

创建一个Dockerfile.multistage:

# syntax=docker/dockerfile:1

##
## Build
##
FROM golang:1.16-buster AS build

WORKDIR /app

COPY go.mod ./
COPY go.sum ./
RUN go mod download

COPY *.go ./

RUN go build -o /docker-gs-ping

##
## Deploy
##
FROM gcr.io/distroless/base-debian10

WORKDIR /
COPY --from=build /docker-gs-ping /docker-gs-ping

EXPOSE 8080

USER nonroot:nonroot

ENTRYPOINT ["/docker-gs-ping"]

然后再编译:

# -f:指定dockerfile
# -t:即-tag。如果省略:后面的版本号,就是latest
$ docker build -t docker-gs-ping:multistage -f Dockerfile.multistage .
$ docker image ls
REPOSITORY     TAG        IMAGE ID     CREATED SIZE
docker-gs-ping multistage e3fdde09f172 About a minute ago 27.1MB
docker-gs-ping latest     336a3f164d0f About an hour ago 540MB

我们看到,此时的镜像只有27MB。这个时候,可能你还会很疑惑,为什么还有这么大?能不能再小一点?

我们只需要更改Deploy阶段基础的镜像即可,选择一个更小的,比如alpine,以下是一个常用基础镜像的对比:
在这里插入图片描述

项目实战

Go Project layout介绍

根据官方的仓库:Github 翻译 ,我们实际的Go项目可能会有以下结构:
在这里插入图片描述

  • cmd:这个目录下,放程序的入口,即编译为可执行文件。
  • internal:该目录下放程序的逻辑,这下面的都是内部包,不能被其他包访问。

那么,以上图为例,我们要写Dockerfile的时候,应该怎么写呢

这就是我实战的时候遇到的问题,所以,我们先看看我的项目结构。

CoffeeChat项目结构简介

参考官方的layout后,我的项目结构如下:

├── README.md
├── api
├── app
│   ├── daemon
│   ├── demo
│   ├── im_filegw    # 文件网关
│   ├── im_gate      # tcp官网
│   ├── im_http      # http api
│   └── im_logic     # 程序逻辑,提供gRPC给im_gate和im_http调用 
├── go.mod
├── go.sum
├── internal
│   ├── filegw
│   ├── gate
│   ├── httpd
│   └── logic
├── pkg
│   ├── db
│   ├── def
│   ├── helper
│   ├── logger
│   └── mq

一开始,我打算把Dockerfile放在根目录下,里面启动所有的服务,但实际实现的时候,我发现这个Dockerfile很难写。后面通过一些文章以及官方的 构建镜像最佳实践 ,我改正了该错误,确定了一个原则:一个Dockerfile一个程序

实践技巧

一个Dockerfile一个程序

所以,针对类似上面结构的项目,个人建议把Dockerfile放在程序下面,根目录放docker compose做容器编排。如下:

server
├── api
├── app
│   ├── im_gate
│   │   ├── Dockerfile           # 这里放Dockerfile,配置文件也放在这个目录,方便和compose结合
│   │   ├── gate-example.toml
│   │   └── gate.go
│   ├── im_http
│   │   ├── Dockerfile           # 每个程序一个
│   │   ├── http-example.toml
│   │   └── http.go
│   └── im_logic
│       ├── Dockerfile           # 每个程序一个
│       ├── logic-example.toml
│       └── logic.go
├── docker-compose.debug.yml
├── docker-compose.yml           # 项目根目录,放compose,作编排
├── go.mod
├── go.sum
├── internal
│   ├── filegw
│   ├── gate
│   ├── httpd
│   └── logic
├── pkg
└── setup
    └── mysql
        └── init

那么,这个时候,我们就要注意路径的问题了。以im_http举例,Dockerfile内容如下:

FROM golang:1.16-alpine as build
LABEL maintainer="xmcy0011<xmcy0011@sina.com>"
WORKDIR /go/src/coffeechat

# 把当前所有文件 拷贝到上面的工作目录下(包括配置文件)
COPY . .

# 设置go代理,加快拉包速度
RUN go env -w GOPROXY=https://goproxy.io && \
    cd app/im_http && \
    # 拉项目依赖
    go mod tidy && \
    # 编译程序
    go build

##
## deploy 
##

FROM alpine
# 指定日志存储卷,当前工作目录下的Log文件
VOLUME [ "log” ]
# 第一行的as build,build是一个名称,这里使用
COPY --from=build /go/src/coffeechat/app/im_http .
CMD ["./im_http"]

Dockerfile中我们copy的是当前目录,但是源码在Dockerfile的…/…/目录中。故编译的时候,需要指定上下文:

$ cd server # 代码根目录
# 通过-f指定dockerfile。.:指定编译上下文为当前目录
$ docker build -t im_http:v0.1 -f app/im_http/Dockerfile .

接下来,我们介绍一些其他的实践技巧。

.dockerignore忽略不必要的文件和目录

默认情况下我们是把整个工程的文件都发送过去。但有一些源码的文件,比如.idea、脚本等等是不需要发送的。

此时我们可以在根目录下创建一个.dockerignore文件,忽略这些除源码以外的文件或文件夹。规则和.gitignore一样。

.dockerignore内容如下:

.idea    # 一行代表一个规则。比如这个就是忽略当前目录下的.idea目录
shell    # 忽略shell目录
build.sh # 忽略build.sh文件
合并指令,减少层数,使镜像体积更小

dockerfile中的一行,就代表了镜像的一层,如果这一层对应的文件内容发送改变,那就会重新构建这一层,而其他层使用缓存层越多,镜像体积越大,所以对指令进行合并是很有需要的。

层和层之间,都是相对的WORKDIR路径。上一层执行RUN cd,下一行RUN还是以WorkDIR为准,不会进入到上一个命令cd到的目录

PS:根据实测,很尴尬的是,下面2个方式,生成的镜像大小一样。但还是建议合并指令,更不容易出错。

没有合并之前:

RUN go env -w GOPROXY=https://goproxy.io
RUN cd app/im_http && go mod tidy
RUN cd app/im_http && go build

合并之后(更简洁,换行使用 “空格" 加 “" ):

RUN go env -w GOPROXY=https://goproxy.io && \
    cd app/im_http && \
    go mod tidy && \
    go build
选择合适的基础镜像

这个是影响镜像大小一个很重要的点,即使是golang,不同的版本也有不同的tag,我们可以选择小的。

拿docker hub中的golang1.16镜像为例:
在这里插入图片描述
所以:

  • 编译镜像,go推荐使用基础镜像:golang:1.16-alpine
  • 运行go程序,测试环境推荐使用镜像:alpine

以下是一个基础镜像的对比:
在这里插入图片描述

多阶段构建

多阶段构建的目标,就是为了分离编译和部署为2个环境,从而减少最终镜像的体积。因为运行的时候,针对静态语言,是不需要源码和编译环境的。

这个官方的教程中已经更出了明确的示例,这里就不在阐述。只需要记住2点即可:

  • 至少2个FROM,分别指定编译的基础镜像和运行的基础镜像
  • CMD指令只会在容器启动的时候执行,千万别搞混了
卷容器比mount目录更方便,但是mac查看偏麻烦

持久化容器数据的时候,主要有2种方式:一是直接使用宿主机的目录,二是使用卷容器。

第一种方式比较简单,启动容器的时候,直接通过增加-v参数,然后设定宿主机路径和容器路径即可完成映射。

但是在开发阶段或者学习docker阶段,针对Go语言,我感觉卷容器更好

  • docker compose文件有时候需要调整,特别是在集成mysql镜像时,直接删除整个卷容器,然后执行初始化逻辑感觉很省事。
  • 更好管理。直接使用docker volume ls,就可以查看所有的卷容器。

但是,在mac下,即使通过 inspect 找到了卷容器的路径,然后要进去查看也是比较麻烦:

$ docker inspect server_cim_mysql_data
[
    {
        "CreatedAt": "2021-11-01T01:19:42Z",
        "Driver": "local",
        "Labels": {
            "com.docker.compose.project": "server",
            "com.docker.compose.version": "1.29.2",
            "com.docker.compose.volume": "cim_mysql_data"
        },
        "Mountpoint": "/var/lib/docker/volumes/server_cim_mysql_data/_data",
        "Name": "server_cim_mysql_data",
        "Options": null,
        "Scope": "local"
    }
]

Mountpoint 指定了实际的位置,但是macOS并没有这个目录。它是经过转换之后的一个路径。

网上找到的解释是:

Docker for Mac在Linux VM中运行docker引擎,而不是Mac OS,因此您无法在Mac OS文件系统中找到卷的挂载点。卷文件应该存在于该Linux VM的文件系统中。
但是,您可以通过屏幕登录Docker for Mac的VM:

$ screen ~/Library/Containers/com.docker.docker/Data/com.docker.driver.amd64-linux/tty  
#user: root
#password: xxxxxx
$ ls -ltrh /var/lib/docker/volumes
total 148
drwxr-xr-x    3 root     root          4096 May 16 13:20 04576d248c19b1210d47e94c8211493428cd3c3aa71dfe3fa0f4214589a6f875
drwxr-xr-x    3 root     root          4096 May 16 13:20 31af0f01492d8f7b832dad75e731b754302e84fbecfa7c654d7de10465bec204
一个好的编辑器+Docker插件,能让我们事倍功半

一开始,按照官方的教程,我使用VS Code+Docker插件编写Dockerfile,它可以直接编译镜像而不需要输入命令,然后在底部会显示构建结果。

好处是能降低记忆负担,让我们快速上手

坏处是我们不能手敲命令,总是感觉没有入门

所以在学官方教程的时候,我还是使用iTerm来键入命令操作docker。
在这里插入图片描述
在这里插入图片描述

另外一个,使用VS Code最主要的原因是:针对Dockerfile的智能补全功能。但是在后面学习docker compose的时候,它失效了(至少在我的Mac上),没有任何提示,可能是因为环境问题或者配置异常导致。所以,我就转而在Goland中安装docker插件尝试编写dockerfile和docker compose。我发现,虽然它更笨重,但是有时候是真的强大。

比如,我们可以点击”mysql:5.7”快速跳转镜像对应的docker hub地址,查看详情。也可以点击左侧打绿色箭头,决定是启动整个服务,还是单个服务。
在这里插入图片描述

然后,在底部的Services,可以看到容器输出的日志。以及对容器进行暂停重启等,感觉非常方便。
在这里插入图片描述

所以,有时候,一个好的IDE,能让我们更快更方便的学习或者掌握某个技术,点赞👍。

PS1:上图是Goland2021.2.4
PS2:vs code写dockerfile相比goland更方便,主要是在于镜像编译、管理。

问题

运行的容器列表中为什么没有我的容器?

docker ps可以列出所有正在运行中的容器,但是可能因为各种原因,你的容器在启动后就退出了,或者启动失败。这个时候,我们可以增加-a参数,列出所有的容器。

$ docker ps -a

如果要排查,启动失败的原因或者退出的原因,可以通过logs命令查看启动日志,看看是否有错误输出。

$ docker logs <container_id>

如何进入docker容器以查看实际目录结构?

有2种情况:

  • 容器已启动,想进入查看
  • 容器启动失败,想进入确定目录结果,调试dockerfile

第一种情况 可以通过exec命令交互式进入

$ docker exec -it <continaer_id> /bin/sh   # 注意,不能bash,有些基础镜像中没有该命令

效果就像你ssh到一个linux主机一样,退出同样也是输出exit即可。

第二种情况 建议把CMD直接改成/bin/sh,然后启动容器的时候,不要加-d(后台启动)参数。

$ vim dockerfile.yml
...
CMD [ “/bin/sh" ]

$ docker run -it <continaer_id> # 不要加-d,直接前台启动docker容器,容器启动后就进入了shell,此时就可以查看dockerfile是哪里出了问题

docker compose如何与dockerfile结合?

docker compose适用于在单机环境下的服务编排,通常是基于已有的镜像(上传到了Docker Hub),那么如何基于项目源码编译镜像,然后编排部署呢?这样的好处在于:可以使自己的开源项目很容易被部署,且任意下载源码的人,都能通过修改源码即时看到效果。

docker compose中,可以通过build配置来直接从Dockerfile编译。

services:
  im_http: # http 服务
    container_name: im_http
    build: # 指定从dockerfile编译
      context: .
      dockerfile: app/im_http/Dockerfile
    volumes: # 数据卷绑定
      - ./log:/log

none:none是什么?如何清除

通过docker image ls 查看镜像列表时,有些id是none的镜像,这种镜像叫做空悬镜像,通常是由于构建过程异常导致残存的image,占用空间,可以使用命令清理:

$ docker image prune

如果无法删除,根据提示检查停止的容器删除后,再手动强制删除(加-f选项)镜像即可。

关于作者

觉得还不错,关注一下作者的公号吧~
请添加图片描述

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值