7天docker入门:第3天Dockerfile实战
引言这是docker入门教程系列的第3篇,如果完成了前面2篇,我想你应该是初步学会使用Docker了:7天docker入门:第1天 getting-started7天docker入门:第2天 特定语言指南(Go)如果没看,我建议你去看看,官方的教程,真的很好不枯燥。那么接下来,你可能会考虑如何在项目中应用Docker,所以,我们今天主要是讲解如何编写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
:执行命令,可以理解为bashEXPOSE
:端口映射,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选项)镜像即可。
关于作者
觉得还不错,关注一下作者的公号吧~
更多推荐
所有评论(0)