Dockerfile 应用容器化及 Compose 部署应用

将应用整合到容器中并且运行起来的这个过程,或者把应用打包成为一个镜像的过程,称为容器化,有时也叫作“Docker 化”。容器化核心就是创建镜像,创建镜像有两种方式,一种是 commit 容器,还有一种是使用 Dockerfile 快速创建自定义镜像。

docker compose 能够在 Docker 节点上,以**单引擎模式(Single-Engine Mode)**进行多容器应用的部署和管理。它区别于 Swarm 和 Kubernetes 可以进行多引擎多容器应用部署(在 docker 中叫 swarm mode,Compose does not use swarm mode to deploy services to multiple nodes in a swarm)。compose 和 Kubernetes 其适用范围不同,所以不适合作对比。

1   shell 基础

  1. 熟悉 shell 语法,比如$的应用规则:$?(上一个命令的返回值)、$0 $1 $2(表示指令,参数1、参数2)、$() = ` `、$NAME(引用变量)等,脚本经常会先执行 set -xe(e 表示单个命令执行返回非零时立即退出,包括函数返回非零,x 执行指令前会先显示该完整的命令)。

  2. 理解程序运行的原理,shell 是一个等待输入的程序,输入的命令有外部命令和内部命令之分;外部命令是通过系统调用或独立的程序实现的,如 sed、awk 等。内部命令是由特殊的文件格式(.def)所实现,如 cd、history、exec、source 等。其接收到指令后有三种方式运行: (1)在当前的 shell 上运行 (2)fork 新的 shell 运行,环境变量会从父进程传递给子进程 (3)系统调用 exec 函数簇执行,一般是 fork 父进程,父子进程拥有共同的地址空间,只有当子进程需要写入数据时(如向缓冲区写入数据),这时候会复制地址空间,复制缓冲区到子进程中去。 同理,运行一个脚本也有三种方式一一对应上面三种,当然前提是有一个已经在运行的 shell。 (1)source:也就是 . 命令,在当前上下文中执行脚本,不会生成新的进程。脚本执行完毕,shell 继续等待输入。影响上下文; (2)./script.sh(以 #!/bin/sh 开头) 与 sh script.sh(无需 #!/bin/sh 开头) 等效,当前shell是父进程,fork 子 shell 进程,在子 shell 进程中执行脚本。脚本执行完毕,退出子shell,回到当前shell。不影响上下文。 (3)执行完不返回 shell,直接退出 shell,关闭上下文。 如以下脚本,通过 . jump.sh 后返回到原来的 shell 其当前目录也变了成 /,而 ./jump.sh 或 sh jump.sh 则不会影响上下文。

cat <<EOF > jump.sh
#!/bin/sh
cd /
pwd
echo $HOME
EOF

2   Dockerfile

Dockerfile 具有众多的指令。一般分为四部分:基础镜像信息、维护者信息、镜像操作指令和容器启动时执行指令。

2.1   命令

镜像常用 Dockerfile 指令文件创建,创建命令:docker image build -t NAME[:TAG] Dockerfile-Path。它几乎无需指定参数,但还是简单说明几个:

  • –rm 成功 build 后删除中间运行的容器,默认为true。还有一个 –force-rm,无论成不成功都删除,不建议使用,因为失败了还可以通过容器调试。 注意这里要区别于中间镜像,在 build 中 ---> cdf98d1859c1 表示依赖的镜像或者中间镜像,---> Running in 1d2485ce71e9 表示中间容器。
  • –no-cache 不走缓存。构建的时候会搜索开始到当前的指令是否有缓存,有则直接拿来用提升速度,但有注意 COPY、ADD 指定即使没变也会检查复制的文件有没有改动过。既然缓存会自动判断,那为什么要设置不走缓存,那是因为像 RUN,即使命令都没变,但可能因为时间、远程版本变化导致运行结果也有变化,这时候就可以指定不走缓存。
  • –squash,压缩层,即把所有的层压缩成一个层,这对本地使用还好,对需要 pull、push 的增加了网络负担。所以尽量不用,而是在 Dockerfile 里选择性的合并指令达到压缩层的目的。像 git 也有该参数,同样各有利弊。

2.2   指令

有些指令会新建镜像层,有些只会增加元数据,关于如何区分命令是否会新建镜像层,一个基本的原则是,**如果指令的作用是向镜像中增添新的文件或者程序,那么这条指令就会新建镜像层;**如果只是告诉 Docker 如何完成构建或者如何运行应用程序,那么就只会增加镜像的元数据。所以并非所有的 RUN 都会有创建新层,比如 RUN echo "no data" 就不会创建新的层。

需要注意的一点是镜像没有任何运行时的宿主机信息,比如不可能有端口映射,端口映射一定是在启动容器的时候才会指定,否则宿主机的端口未知是否可用,则容器也未知是否可用。

下面列举一些常用的指令:

  • FROM:指定基础镜像,推荐 Alpine,只有 5M 左右;
  • RUN:有 shell 和 exec 两种执行方式:
RUN <command>  //shell
RUN ["executable", "param1", "param2"]  //exec
  • COPY、ADD:COPY 只能复制宿主机文件,ADD 支持远端复制,并且会自动解压压缩文件,不过不会删除压缩文件。
  • EXPOSE、-p、-P:设置镜像暴露端口,容器启动时就会监听的端口,但是不导出(publish)端口到主机,不过容器之间 link 可以使用暴露的端口通信。docker run 命令的 -p 和 -P 表示是否设置容器的端口到宿主机的映射; 其中 -P 表示将 EXPOSE 暴露的端口映射到本地主机的随机端口;-p 设置容器新暴露端口并映射到宿主机的指定端口。
EXPOSE 80   //Dockerfile
------------------------
PORTS
80/tcp, 0.0.0.0:91->8080/tcp  //80端口只是暴露没有导出,只能用于容器之间的 link;-p 91:8080
0.0.0.0:32768->80/tcp, 0.0.0.0:90->8080/tcp // 80端口导出到宿主机随机端口;-P -p 90:8080
  • ENTRYPOINT、CMD、docker run 的命令:
  • Dockerfile 中应至少一条 CMD 或 ENTRYPOINT 指令,如果有多条,他们都是最后一条生效;而且逻辑是 CMD 在后面,如果不写在后面也不会报错,不过还是会追回在 ENTRYPOINT 参数后面;
  • CMD 和 docker run 本质上是一样的,只不过前者是默认,后者会覆盖前者;如果有 ENTRYPOINT 指令,则他们只能是 ENTRYPOINT 指令的追加参数;
  • docker run 中加入 –entrypoint,会覆盖镜像中的 ENTRYPOINT;
  • 当使用容器作为一个程序容器时,应使用 ENTRYPOINT 定义入口程序。
CMD ["executable", "param1", "param2"]  //exec, json数组格式,所有参数都必须有双引号
CMD ["param1", "param2"]  // 结合 ENTRYPOINT 指令追加参数
CMD command param1 param2  //shell

ENTRYPOINT ["executable", "param1", "param2"]  //exec, json数组格式,所有参数都必须有双引号
ENTRYPOINT command param1 param2  //shell
  • WORKDIR:需要注意如果是相对路径,则会以上一条绝对路径为前缀,像 cd 改变目录的功能。

  • USER:如果容器中的应用程序运行时不需要特殊权限,则可以通过 USER 指令把应用程序的所有者设置为非 root 用户。

RUN groupadd -r postgres && useradd -r -g postgres postgres
USER postgres
  • ENV:有两种方式,但推荐第二种减少中间镜像数量

  • ENV <key> <value>

  • ENV <key1>=<value1> <key2>=<value2>,这种情况字符串有空格一定要用双引号括起来

  • VOLUME、-v:挂载卷,启动容器的时候会把容器中的目录挂载到宿主机中。docker run 的 -v 是可以指定宿主机的目录名的。

2.3   Dockerfile 最佳实践

  1. 让层尽量的少,加快编译时间;但是保留共用层,避免 push 或 pull 重复的数据
  • RUN 时一般使用 \ 把长的指令分成多行,把多个 RUN 指令合并成一个 RUN 指令,达到压缩镜像层的目的;
  • ENV ENV <key1>=<value1> <key2>=<value2> 减少中间镜像层
  1. 让镜像的大小尽量的小,只留必要的文件,其它的如构建工具、依赖、代码等如对服务没有帮助则应该删除
  • 运行结束后应该清理缓存和中间工具使得每一层的 SIZE 最小,这主要有两种方式:
    • 编写命令清理不需要的数据,php 镜像就是这么干的,apk add –no-cache –virtual .build-deps 和 apk del .build-deps;phpize 和 docker-php-source delete等;
    • 建造者模式:把有用的数据移到最小版本,需要多个 Dockerfile;
    • 多阶段构建方式:利用 COPY –from 参数指定要复制指定的数据,只需要一个 Dockerfile。

3   Compose

3.1   安装

三大版本的关系:docker compose 版本、Compose file format 版本和 Docker Engine 版本,可以参见 github 库 docker/compose。比如 docker compose v1.21.0 只能支持 Compose file format v3.6 基于 Docker Engine v18.02.0+,docker compose v1.22.0 才增加了 Compose file format v3.7,而且 docker-compose.yml specification v3.7 版本要求 Docker Engine 在 v18.06.0 以上;目前最新的 docker compose v1.24.0 只能支持 Compose file format v3.7 基于 Docker Engine v18.06.0+。 版本号如果写成 version: ‘3’,则表示为 3.0 版本。关于 docker compose file format 的差异可以看 Compose file versions and upgrading

docker compose 是收购 fig,它是一个 python 工具,按官方下载就可以了;升级也很简单,重新下载一次就可以了。

3.2   命令

  • up:启动,-d 表示后台运行;
  • down:关闭,会把容器和网络删除,但不会删除卷;
  • logs:如果加了 -d 参数,可以通过该命令查看日志,但日志的输出是依赖于服务内部的设计的;
  • build:重建镜像用 docker-compose build or docker-compose up --build

3.3   指令

需要注意的是可以在 Compose 文件中用$直接引用宿主机的变量,而 Dockerfile 文件是不行的,$只是引用 ENV 定义的变量。下面以 3.7 版本格式列举一些常见的指令:

一级指令:

  • version:版本号,规定版本的格式
  • services:服务
  • build:本地找,找不到就构建,如果指定 image 则用其值,如没有就用 “服务名:latest“
  • image:如指定 build 则其规则看 build;如未指定 build,则本地找,本地没有上 hub 拉取
  • environment:在 docker-compose 运行时导入容器,这极大的方便了引用宿主机环境变量
  • networks:网络
  • volumes:卷

4   实战部署

本节以一个计数器进行实战部署,目录结构:

> tree gomicro
gomicro
├── config.yaml
├── docker-compose.yml
├── Dockerfile
├── main.go
└── service
    ├── config.go
    └── redis.go

除了 Dockerfile 和 docker-compose.yml,其余的都是业务代码。

4.1   业务代码

  1. 使用 go 作为 web 服务器,开发路径为 $GOPATH/github.com/wpxun/gomicro,开发环境和生产环境保持一致,需要发布 80 端口;
  2. 使用 redis 存储计数,并作持久存储,该服务只供 go web 服务请求,所以暴露的端口(6379)不需要发布;
cat <<EOF > $GOPATH/github.com/wpxun/gomicro/main.go
package main

import (
    "fmt"
    "github.com/wpxun/gomicro/service"
    "net/http"
    "os"
    "strconv"
)

func IndexHandler(w http.ResponseWriter, r *http.Request) {
    redis := service.GetRedis()
    val, err := redis.Incr("count").Result()
    if err != nil {
        panic(err)
    }
    host := os.Getenv("FROMHOSTNAME") //读取 docker-compose.yml 中引入到容器的环境变量
    fmt.Fprintln(w, "hello world "+ host +", visitors = " + strconv.FormatInt(val, 10) )
}

func main()  {
    http.Handle("/pattern", http.HandlerFunc(IndexHandler))
    http.ListenAndServe(":80", nil)
}
EOF
cat <<EOF > $GOPATH/github.com/wpxun/gomicro/config.yaml
Redis:
  DialTimeout: 2000000000 #连接超时设定(s),默认200ms
  Network: tcp #网络连接协议
  Address: redis:6379 #连接地址(带端口)
  Password:  #密码
  Database: 0 #数据库,默认0
EOF
cat <<EOF > $GOPATH/github.com/wpxun/gomicro/service/redis.go
package service

import (
    "github.com/go-redis/redis"
)

func GetRedis() *redis.Client {
    return redis.NewClient(&redis.Options {
        Addr:         Conf.Redis.Address,
        Password:     Conf.Redis.Password,
        DB:           Conf.Redis.Database,
        Network:      Conf.Redis.Network,
        DialTimeout:  Conf.Redis.DialTimeout,
    })
}
EOF
cat <<EOF > $GOPATH/github.com/wpxun/gomicro/service/config.go
package service

import (
    "fmt"
    "gopkg.in/yaml.v2"
    "io/ioutil"
    "time"
)

type confstruct struct {
    Redis struct {
        Address     string          `yaml:"Address"`
        Database    int             `yaml:"Database"`
        DialTimeout time.Duration   `yaml:"DialTimeout"`
        Network     string          `yaml:"Network"`
        Password    string          `yaml:"Password"`
    } `yaml:"Redis"`
}

var Conf confstruct

func init() {
    GetYaml("config", &Conf)
}

func GetYaml(filename string, out interface{}) {
    yamlFile, err := ioutil.ReadFile(fmt.Sprintf("%s.yaml", filename))
    if err != nil {
        fmt.Println("Read config file error:", err.Error())
    }

    err = yaml.Unmarshal(yamlFile, out)

    if (err != nil) {
        fmt.Println("Unmarshal config file error:", err.Error())
    }
}

EOF

4.2   容器化

这一步我们只需要把 go web 服务器容器化,而 redis 我们直接用官方的容器。

cat <<EOF > $GOPATH/github.com/wpxun/gomicro/Dockerfile
# 多阶段构建
# 第一阶段,391MB,编译前准备:go 和 git 工具、代码依赖库
FROM golang:1.12.4-alpine3.9  AS front
RUN set -xe && \
    apk add git && \
    go get -v github.com/go-redis/redis && \
    go get -v gopkg.in/yaml.v2

# 分成两次 RUN 目的是可复用上面的缓存,编译 go 代码
COPY . /go/src/github.com/wpxun/gomicro
RUN set -xe && \
    go install github.com/wpxun/gomicro


# 第二阶段,14.6MB;仅仅复制了可执行程序和程序的配置文件
FROM alpine:3.9
ENV GOM_VERSION   1904.1
COPY --from=front /go/bin /go/src/github.com/wpxun/gomicro/config.yaml /go/bin/
EXPOSE 80
WORKDIR /go/bin
CMD ["/go/bin/gomicro"]
EOF

我们采用多阶段构建,最终只需要 go web 服务器的可执行程序和启动时需要读取的配置文件,这里我把他们放在 /go/bin 目录下,因为 go 程序中基于当前目录读取的 config.yaml,所以需要设置工作目录为配置文件所在的目录 WORKDIR /go/bin。

4.3   单引擎部署

这一步我们需要把 go web 服务和 redis 服务进行编排管理,两者的通信需要配置同一个 networks。而且还在运行容器的时候添加 FROMHOSTNAME 环境变量等于宿主机的 HOSTNAME 环境变量,这里要注意,运行时的 compose 里的环境变量是宿主机的环境变量,而构建时的 Dockerfile 不能包含宿主机的信息(build 的时候可以通过 –build-arg 传变量)。

cat <<EOF > $GOPATH/github.com/wpxun/gomicro/docker-compose.yml
version: "3.7"
services:
  gomicro:
    build: .
    image: wpxun/gomicro:v1
    environment:
      FROMHOSTNAME: $HOSTNAME
    ports:
      - target: 80
        published: 80
    networks:
      - counter-net

  redis:
    image: "redis:5.0.4-alpine3.9"
    networks:
      - counter-net

networks:
  counter-net:

volumes:
  counter-vol:
EOF

4.4   浏览器看结果

docker-compose up -d 运行

> docker ps -a
IMAGE                   COMMAND                  CREATED             STATUS                     PORTS                                      NAMES
gomicro_gomicro         "/go/bin/gomicro"        3 hours ago         Up 2 hours                 0.0.0.0:80->80/tcp                         gomicro_gomicro_1
edis:5.0.4-alpine3.9    "docker-entrypoint.s…"   3 hours ago         Up 2 hours                 6379/tcp                                   gomicro_redis_1

访问 http://<ip>/pattern 即可以看到打印的次数加 1。如果容器被 stop,计数次数保留,如果容器被 down 掉(也就是容器被删除),则计数丢失。当然可以把 redis 的数据保存在卷中,这样即使容器被删除,redis 持久化数据还在卷中,下次重启可以挂载。

参考文献 [1] Nigel Poulton. 深入浅出 Dokcer. 版次:2019年4月第1版 [2] 廖煜 晏东. Docker 容器实战. 版次:2016年11月第1版 [3] Dockerfile 最佳实践. https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/