使用 Docker Compose 远端部署项目 | 青训营笔记

673 阅读5分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 15 天。

本文以 CC-BY-SA 4.0 发布。

微服务部署

微服务弄起来还是挺开心的, 刷刷刷几下就把项目分成了好几种微服务。 但想起来简单,实际部署时, 几个微服务、注册中心还有数据库等基础设施可能也得费一大番功夫才能整理妥当。

当然,作为青训营的项目,我们也并不需要去弄复数的服务器来把各个微服务分别部署 ——直接全部部署到同一台机器上也就足以演示了。 下面我将以实例说明如何用 Docker Compose 来 将一个分成五块微服务 + 各种数据库的项目部署到远端。

Docker Context

Docker 自带了远端操作的能力。 虽然实际上用起来似乎配置起来还有些问题, 但只要有一个 ssh 连接,我们便可以对连接的主机远程进行各种 docker 操作。

$ docker context create remote --docker "host=ssh://<user>@<ip-addr>"
$ docker --context remote images # 远端执行命令

要注意的有几点:

  • 远端的机器需要安装好 docker,最好本地和远端的 docker 统一版本。
  • 一般来说建议 ssh 使用密钥验证,但是 docker 命令行并没有给输入密钥密码的机会。 所以,如果在生成密钥时使用了密码(passphrase),最后远端执行的时候大概率会验证失败。
  • 远端的对应用户需要有 docker 的相关权限,要不就是远端使用 root 用户(不安全), 要不就是把用户加到 docker 组里去(可能也不安全)。

Dockerfile

Dockerfile 的目的是将我们的项目(五个的 Go 编译的可执行文件)封装为 docker 镜像。 可能实际应用里还会把镜像给上传到私有的 repo 里再进行部署,我们这里就不这样折腾了。

多阶段构建

从普通的 make 的使用方法来说,最常见的 make 命令是:

  1. make: 编译为可执行文件
  2. make install: 安装(部署)可执行文件

同样,docker 也可以这样做,我们可以把编译的环境和最后可执行文件构筑的镜像分离开来, 毕竟最后部署时我们是不需要编译环境的 git, Go 等工具的。

多阶段构建简单来说就是多个 FROM 语句。每个 FROM 语句标志着一个新镜像的开始。 当后面的镜像使用到了前面镜像的输出时(如前面编译好的文件), docker 就会识别出其中的依赖关系并分阶段构建不同的镜像。

# 下面是编译环境,注意 alpine 与最终镜像一致
FROM golang:alpine AS builder
WORKDIR /src
COPY . .
RUN go mod download && go build -o MyProgram

# 下面是最终的可执行文件的镜像
FROM alpine AS my-program
# 不使用 root 用户
RUN adduser -D -u 1000 my-user
USER my-user
# 使用上面 builder 输出的可执行文件
COPY --from=builder /src/MyProgram /
CMD ["/MyProgram"]

不使用 root 特权用户

上面有两行代码:

RUN adduser -D -u 1000 my-user
USER my-user

因为镜像里使用 root 用户可能会有潜在的安全风险,所以可以的话还是使用普通用户比较好。 上面制定了 1000 作为用户 ID,因为一般的机器上都会有一个 UID 1000 的用户, 这样在 volume 创建的文件至少可以对应上外部用户。 另外,如果有用到 volume 的话,挂载的目录也需要设置相关权限,如:

RUN mkdir -p /volume-folder && chown my-user /volume-folder

分离镜像的构建和运行

很遗憾,我可怜的 2 GB 内存的云服务器完全跑不动项目的编译。 既然上面有了分阶段构建,那么我们为什么不能分阶段部署呢?

  1. 在我的个人用电脑上构建好镜像
  2. (正式的项目大概会把镜像先上传到一个私有 repo)
  3. 再去云服务器上部署镜像,无需再编译

虽然我们没有私有 repo,各种地方有点麻烦,但我们仍然可以分阶段部署。 下面假设你已经写好了 docker-compose.yml。

  1. 本地构建:
    $ docker compose build
    
  2. 因为我们没有私有 repo 作为传输媒介,所以我们只能手动把镜像发送到远端。 以上面构建好的 my-program 镜像为例:
    $ docker save my-program:latest | gzip | docker --context remote load
    $ ## 上面把 my-program 镜像导出 -> 压缩 -> 再使用 docker 在远端加载镜像
    
    如果构建了多个镜像的话(例如我们项目的五个微服务),那么我们需要分别手动发送:
    docker save mdouyin-counter:latest  | gzip | docker --context remote load
    docker save mdouyin-feeder:latest   | gzip | docker --context remote load
    docker save mdouyin-gateway:latest  | gzip | docker --context remote load
    docker save mdouyin-message:latest  | gzip | docker --context remote load
    docker save mdouyin-reaction:latest | gzip | docker --context remote load
    
  3. 在远端进行部署:
    $ docker --context remote compose up --no-build
    

Troubleshooting

Docker 网络与注册中心

不同的注册中心、利用注册中心的不同库都可能有不太一样的服务注册与发现的方法。 举个例子,Kitex 社区利用 etcd 作注册中心的扩展, 其在 etcd 里登记的 IP 有可能会是 0.0.0.0 抑或是 IPv6 的 ::1, 这在 docker 的网络中是完全行不通的: 每一个容器都是不同的网络单元,对应的 IP 也不同, 一台容器的 0.0.0.0 肯定无法访问到其它机器,因此这方面需要我们在调用库时注意。

Healthcheck 与依赖关系

依赖关系说起来很简单,不就是“服务 A 需要用到 Redis”“服务 B 用到 Cassandra”这种东西嘛。 且不说理论上微服务里不同种的服务使用的数据库应该分离, 所有的程序都会需要初始化的时间: Cassandra 启动了,但是在 Cassandra 初始化完毕之前, 服务 B 的依赖关系都没有得到满足——这时启动服务 B 它可能会报错。

那么我们要如何表示出这种需要初始化完毕的依赖关系呢? Docker Compose 自带了一个 healthcheck 机制,我们可以指定一个“初始化完毕”的判据, 并在依赖关系里加上一个 service_healthy 条件。

services: # 省略了其它部分
  etcd:
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
  my-program:
    depends_on:
      etcd:
        condition: service_healthy

ENTRYPOINT 覆盖

我们项目里使用了 ffmpeg 来进行简单的视频校验和封面生成操作, 因此使用了 jrottenberg/ffmpeg 的镜像来提供 ffmpeg。

但是 jrottenberg/ffmpeg 自带一个 ENTRYPOINT: 这个时候我们不能直接设置 CMD,否则它只会自动运行 ffmpeg, 把我们后面加入的程序 CMD 当作给 ffmpeg 的参数。

直接设置 ENTRYPOINT ["/MyProgram"] 似乎并不能覆盖原有的。 从结果来看,想要完全覆盖 ENTRYPOINT,我们需要:

FROM jrottenberg/ffmpeg:5-alpine
ENTRYPOINT []
CMD ["/MyProgram"]