使用容器进行 Go 开发

先决条件

按照将您的镜像作为容器运行模块中的步骤进行操作,以了解如何管理容器的生命周期。

简介

在本模块中,您将了解如何在容器中运行数据库引擎并将其连接到示例应用程序的扩展版本。您将看到一些用于保存持久数据和将容器连接在一起的选项。最后,您将学习如何使用 Docker Compose 有效地管理此类多容器本地开发环境。

本地数据库和容器

您将使用的数据库引擎称为CockroachDB。它是一个现代的云原生分布式 SQL 数据库。

您将使用CockroachDB 的 Docker 镜像在容器中运行,而不是从源代码编译 CockroachDB 或使用操作系统的本地包管理器安装 CockroachDB。

CockroachDB 在很大程度上与 PostgreSQL 兼容,并与后者共享许多约定,特别是环境变量的默认名称。因此,如果您熟悉 Postgres,请不要对看到一些熟悉的环境变量名称感到惊讶。与 Postgres 一起使用的 Go 模块(例如 pgxpqGORMupper/db也可以与 CockroachDB 一起使用。

有关 Go 和 CockroachDB 之间关系的更多信息,请参阅CockroachDB 文档,尽管这对于继续本指南不是必需的。

存储

数据库的目的是拥有持久的数据存储。是持久化 Docker 容器生成和使用的数据的首选机制。因此,在您启动 CockroachDB 之前,请为其创建卷。

要创建托管卷,请运行

$ docker volume create roach
roach

您可以使用以下命令查看 Docker 实例中所有托管卷的列表

$ docker volume list
DRIVER    VOLUME NAME
local     roach

网络

示例应用程序和数据库引擎将通过网络相互通信。有不同类型的网络配置,您将使用所谓的用户定义的桥接网络。它将为您提供 DNS 查找服务,以便您可以通过其主机名引用数据库引擎容器。

以下命令创建一个名为 mynet 的新桥接网络

$ docker network create -d bridge mynet
51344edd6430b5acd121822cacc99f8bc39be63dd125a3b3cd517b6485ab7709

与托管卷一样,有一个命令可以列出 Docker 实例中设置的所有网络

$ docker network list
NETWORK ID     NAME          DRIVER    SCOPE
0ac2b1819fa4   bridge        bridge    local
51344edd6430   mynet         bridge    local
daed20bbecce   host          host      local
6aee44f40a39   none          null      local

您的桥接网络 mynet 已成功创建。另外三个名为 bridgehostnone 的网络是默认网络,由 Docker 本身创建。虽然这与本指南无关,但您可以在网络概述部分中了解有关 Docker 网络的更多信息。

为卷和网络选择好的名称

正如俗话说,计算机科学中只有两件难事:缓存失效和命名。以及 off-by-one 错误。

在为网络或托管卷选择名称时,最好选择一个反映预期用途的名称。本指南旨在简洁,因此使用简短的通用名称。

启动数据库引擎

现在已经完成了家务事,您可以将 CockroachDB 运行在容器中,并将其附加到您刚刚创建的卷和网络。当您运行以下命令时,Docker 将从 Docker Hub 拉取镜像并在本地为您运行它

$ docker run -d \
  --name roach \
  --hostname db \
  --network mynet \
  -p 26257:26257 \
  -p 8080:8080 \
  -v roach:/cockroach/cockroach-data \
  cockroachdb/cockroach:latest-v20.1 start-single-node \
  --insecure

# ... output omitted ...

注意巧妙地使用标签 latest-v20.1 来确保您拉取 20.1 的最新修补版本。可用标签的多样性取决于镜像维护者。在这里,您的目标是拥有 CockroachDB 的最新修补版本,但随着时间的推移不要偏离已知的正常工作版本太远。要查看 CockroachDB 镜像的可用标签,您可以访问 Docker Hub 上的 CockroachDB 页面

配置数据库引擎

现在数据库引擎已启动,在您的应用程序可以使用它之前需要进行一些配置。幸运的是,并不多。您必须

  1. 创建一个空数据库。
  2. 注册一个新的用户帐户到数据库引擎。
  3. 授予该新用户访问数据库的权限。

您可以使用 CockroachDB 内置的 SQL shell 完成此操作。要在运行数据库引擎的同一容器中启动 SQL shell,请键入

$ docker exec -it roach ./cockroach sql --insecure
  1. 在 SQL shell 中,创建示例应用程序将使用的数据库。

    CREATE DATABASE mydb;
  2. 注册一个新的 SQL 用户帐户到数据库引擎。使用用户名 totoro

    CREATE USER totoro;
  3. 为新用户授予必要的权限。

    GRANT ALL ON DATABASE mydb TO totoro;
  4. 键入 quit 退出 shell。

以下是一个与 SQL shell 交互的示例。

$ sudo docker exec -it roach ./cockroach sql --insecure
#
# Welcome to the CockroachDB SQL shell.
# All statements must be terminated by a semicolon.
# To exit, type: \q.
#
# Server version: CockroachDB CCL v20.1.15 (x86_64-unknown-linux-gnu, built 2021/04/26 16:11:58, go1.13.9) (same version as client)
# Cluster ID: 7f43a490-ccd6-4c2a-9534-21f393ca80ce
#
# Enter \? for a brief introduction.
#
root@:26257/defaultdb> CREATE DATABASE mydb;
CREATE DATABASE

Time: 22.985478ms

root@:26257/defaultdb> CREATE USER totoro;
CREATE ROLE

Time: 13.921659ms

root@:26257/defaultdb> GRANT ALL ON DATABASE mydb TO totoro;
GRANT

Time: 14.217559ms

root@:26257/defaultdb> quit
oliver@hki:~$

了解示例应用程序

现在您已经启动并配置了数据库引擎,您可以将注意力转向应用程序。

本模块的示例应用程序是您在之前模块中使用过的 docker-gs-ping 应用程序的扩展版本。您有两个选择

  • 您可以更新您本地版本的 docker-gs-ping 以匹配本章中介绍的新扩展版本;或者
  • 您可以克隆 docker/docker-gs-ping-dev 存储库。建议使用后一种方法。

要签出示例应用程序,请运行

$ git clone https://github.com/docker/docker-gs-ping-dev.git
# ... output omitted ...

应用程序的 main.go 现在包含数据库初始化代码,以及实现新业务需求的代码。

  • 发送到 /send 的 HTTP POST 请求包含 { "value" : string } JSON 必须将该值保存到数据库。

您还对另一个业务需求进行了更新。需求是

  • 应用程序在对 / 的请求中使用包含心形符号(“<3”)的文本消息进行响应。

现在将变成

  • 应用程序将以包含存储在数据库中的消息计数的字符串(括号内)进行响应。

    示例输出:Hello, Docker! (7)

以下是 main.go 的完整源代码清单。

package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/cenkalti/backoff/v4"
	"github.com/cockroachdb/cockroach-go/v2/crdb"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {

	e := echo.New()

	e.Use(middleware.Logger())
	e.Use(middleware.Recover())

	db, err := initStore()
	if err != nil {
		log.Fatalf("failed to initialise the store: %s", err)
	}
	defer db.Close()

	e.GET("/", func(c echo.Context) error {
		return rootHandler(db, c)
	})

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

	e.POST("/send", func(c echo.Context) error {
		return sendHandler(db, c)
	})

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

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

type Message struct {
	Value string `json:"value"`
}

func initStore() (*sql.DB, error) {

	pgConnString := fmt.Sprintf("host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
		os.Getenv("PGHOST"),
		os.Getenv("PGPORT"),
		os.Getenv("PGDATABASE"),
		os.Getenv("PGUSER"),
		os.Getenv("PGPASSWORD"),
	)

	var (
		db  *sql.DB
		err error
	)
	openDB := func() error {
		db, err = sql.Open("postgres", pgConnString)
		return err
	}

	err = backoff.Retry(openDB, backoff.NewExponentialBackOff())
	if err != nil {
		return nil, err
	}

	if _, err := db.Exec(
		"CREATE TABLE IF NOT EXISTS message (value TEXT PRIMARY KEY)"); err != nil {
		return nil, err
	}

	return db, nil
}

func rootHandler(db *sql.DB, c echo.Context) error {
	r, err := countRecords(db)
	if err != nil {
		return c.HTML(http.StatusInternalServerError, err.Error())
	}
	return c.HTML(http.StatusOK, fmt.Sprintf("Hello, Docker! (%d)\n", r))
}

func sendHandler(db *sql.DB, c echo.Context) error {

	m := &Message{}

	if err := c.Bind(m); err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}

	err := crdb.ExecuteTx(context.Background(), db, nil,
		func(tx *sql.Tx) error {
			_, err := tx.Exec(
				"INSERT INTO message (value) VALUES ($1) ON CONFLICT (value) DO UPDATE SET value = excluded.value",
				m.Value,
			)
			if err != nil {
				return c.JSON(http.StatusInternalServerError, err)
			}
			return nil
		})

	if err != nil {
		return c.JSON(http.StatusInternalServerError, err)
	}

	return c.JSON(http.StatusOK, m)
}

func countRecords(db *sql.DB) (int, error) {

	rows, err := db.Query("SELECT COUNT(*) FROM message")
	if err != nil {
		return 0, err
	}
	defer rows.Close()

	count := 0
	for rows.Next() {
		if err := rows.Scan(&count); err != nil {
			return 0, err
		}
		rows.Close()
	}

	return count, nil
}

存储库还包括 Dockerfile,它与之前模块中介绍的多阶段 Dockerfile 几乎完全相同。它使用官方 Docker Go 镜像来构建应用程序,然后通过将编译后的二进制文件放入更精简的 distroless 镜像中来构建最终镜像。

无论您是更新了旧的示例应用程序还是签出了新的示例应用程序,都需要构建这个新的 Docker 镜像来反映应用程序源代码的更改。

构建应用程序

您可以使用熟悉的 build 命令构建镜像

$ docker build --tag docker-gs-ping-roach .

运行应用程序

现在,运行您的容器。这次您需要设置一些环境变量,以便您的应用程序知道如何访问数据库。现在,您将在 docker run 命令中直接执行此操作。稍后您将看到使用 Docker Compose 的更便捷方法。

注意

由于您在不安全模式下运行 CockroachDB 集群,因此密码的值可以是任何内容。

在生产环境中,请勿在不安全模式下运行。

$ docker run -it --rm -d \
  --network mynet \
  --name rest-server \
  -p 80:8080 \
  -e PGUSER=totoro \
  -e PGPASSWORD=myfriend \
  -e PGHOST=db \
  -e PGPORT=26257 \
  -e PGDATABASE=mydb \
  docker-gs-ping-roach

关于此命令,需要注意几点。

  • 这次您将容器端口 8080 映射到主机端口 80。因此,对于 GET 请求,您可以直接使用 curl localhost

    $ curl localhost
    Hello, Docker! (0)
    

    或者,如果您愿意,一个完整的 URL 也同样有效

    $ curl https://127.0.0.1/
    Hello, Docker! (0)
    
  • 目前存储的消息总数为 0。这是正常的,因为您尚未向您的应用程序发布任何内容。

  • 您通过其主机名(即 db)引用数据库容器。这就是您在启动数据库容器时使用 --hostname db 的原因。

  • 实际密码并不重要,但必须设置为某个值,以避免混淆示例应用程序。

  • 您刚刚运行的容器名为 rest-server。这些名称对于管理容器生命周期很有用。

    # Don't do this just yet, it's only an example:
    $ docker container rm --force rest-server
    

测试应用程序

在上一节中,您已经使用 GET 测试了查询应用程序,它返回了存储的消息计数器为零。现在,向它发布一些消息。

$ curl --request POST \
  --url https://127.0.0.1/send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Docker!"}'

应用程序使用消息内容进行响应,这意味着它已保存到数据库中。

{"value":"Hello, Docker!"}

发送另一条消息。

$ curl --request POST \
  --url https://127.0.0.1/send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Oliver!"}'

同样,您会收到消息的值。

{"value":"Hello, Oliver!"}

运行 curl 并查看消息计数器显示什么。

$ curl localhost
Hello, Docker! (2)

在本示例中,您发送了两条消息,数据库将它们保存下来。或者它保存了吗?停止并删除所有容器,但不删除卷,然后重试。

首先,停止容器。

$ docker container stop rest-server roach
rest-server
roach

然后,删除它们。

$ docker container rm rest-server roach
rest-server
roach

验证它们是否已消失。

$ docker container list --all
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

然后再次启动它们,先启动数据库。

$ docker run -d \
  --name roach \
  --hostname db \
  --network mynet \
  -p 26257:26257 \
  -p 8080:8080 \
  -v roach:/cockroach/cockroach-data \
  cockroachdb/cockroach:latest-v20.1 start-single-node \
  --insecure

然后启动服务。

$ docker run -it --rm -d \
  --network mynet \
  --name rest-server \
  -p 80:8080 \
  -e PGUSER=totoro \
  -e PGPASSWORD=myfriend \
  -e PGHOST=db \
  -e PGPORT=26257 \
  -e PGDATABASE=mydb \
  docker-gs-ping-roach

最后,查询您的服务。

$ curl localhost
Hello, Docker! (2)

太好了!数据库中的记录计数是正确的,尽管您不仅停止了容器,还在启动新实例之前删除了它们。区别在于 CockroachDB 的托管卷,您可以重复使用它。新的 CockroachDB 容器已从磁盘读取数据库文件,就像它在容器外部运行时一样。

关闭所有内容

请记住,您正在不安全模式下运行 CockroachDB。现在您已经构建并测试了应用程序,是时候在继续之前关闭所有内容了。您可以使用 list 命令列出正在运行的容器。

$ docker container list

现在您知道了容器 ID,您可以使用 docker container stopdocker container rm,如之前模块中所示。

在继续之前,请停止 CockroachDB 和 docker-gs-ping-roach 容器。

使用 Docker Compose 提高生产力

此时,您可能想知道是否有一种方法可以避免处理 docker 命令的冗长参数列表。在本系列中使用的玩具示例需要五个环境变量来定义与数据库的连接。一个真实的应用程序可能需要更多、更多的环境变量。然后还有依赖关系的问题。理想情况下,您希望确保在运行应用程序之前启动数据库。而启动数据库实例可能需要另一个具有许多选项的 Docker 命令。但有一种更好的方法可以协调这些部署以用于本地开发目的。

在本节中,您将创建一个 Docker Compose 文件,使用单个命令启动 docker-gs-ping-roach 应用程序和 CockroachDB 数据库引擎。

配置 Docker Compose

在应用程序的目录中,创建一个名为 docker-compose.yml 的新文本文件,其中包含以下内容。

version: '3.8'

services:
  docker-gs-ping-roach:
    depends_on:
      - roach
    build:
      context: .
    container_name: rest-server
    hostname: rest-server
    networks:
      - mynet
    ports:
      - 80:8080
    environment:
      - PGUSER=${PGUSER:-totoro}
      - PGPASSWORD=${PGPASSWORD:?database password not set}
      - PGHOST=${PGHOST:-db}
      - PGPORT=${PGPORT:-26257}
      - PGDATABASE=${PGDATABASE:-mydb}
    deploy:
      restart_policy:
        condition: on-failure
  roach:
    image: cockroachdb/cockroach:latest-v20.1
    container_name: roach
    hostname: db
    networks:
      - mynet
    ports:
      - 26257:26257
      - 8080:8080
    volumes:
      - roach:/cockroach/cockroach-data
    command: start-single-node --insecure

volumes:
  roach:

networks:
  mynet:
    driver: bridge

此 Docker Compose 配置非常方便,因为您不必键入要传递给 docker run 命令的所有参数。您可以在 Docker Compose 文件中声明性地执行此操作。 Docker Compose 文档页面 内容非常丰富,包括 Docker Compose 文件格式的完整参考。

.env 文件

如果可用,Docker Compose 会自动从 .env 文件中读取环境变量。由于您的 Compose 文件需要设置 PGPASSWORD,因此请将以下内容添加到 .env 文件中。

PGPASSWORD=whatever

对于本示例,确切的值并不重要,因为您在不安全模式下运行 CockroachDB。请确保将变量设置为某个值,以避免出现错误。

合并 Compose 文件

文件名 docker-compose.yml 是默认文件名,如果未提供 -f 标志,docker compose 命令将识别该文件名。这意味着如果您的环境有此类需求,您可以拥有多个 Docker Compose 文件。此外,Docker Compose 文件是……可组合的(双关语),因此可以在命令行中指定多个文件以将配置的不同部分合并在一起。以下列表只是在以下情况下可以使用此功能的一些示例

  • 使用绑定挂载用于本地开发的源代码,但在运行 CI 测试时不使用它;
  • 在使用预构建镜像用于某些 API 应用程序的前端与创建源代码的绑定挂载之间进行切换;
  • 添加用于集成测试的额外服务;
  • 等等……

此处将不会介绍任何这些高级用例。

Docker Compose 中的变量替换

Docker Compose 的一个非常酷的功能是 变量替换。您可以在 Compose 文件的 environment 部分中看到一些示例。举个例子

  • PGUSER=${PGUSER:-totoro} 表示在容器内部,环境变量 PGUSER 将被设置为与运行 Docker Compose 的主机上的值相同。如果主机上没有名为该名称的环境变量,则容器内部的变量将获得默认值 totoro
  • PGPASSWORD=${PGPASSWORD:?database password not set} 表示如果主机上未设置环境变量 PGPASSWORD,则 Docker Compose 将显示错误消息。这是可以的,因为您不想为密码硬编码默认值。您在 .env 文件中设置密码值,该文件是本地机器上的文件。最好将 .env 添加到 .gitignore 中,以防止将机密信息检入版本控制系统。

Docker 文档的 变量替换 部分介绍了处理未定义或空值的其他方法。

验证 Docker Compose 配置

在应用对 Compose 配置文件的更改之前,您有机会使用以下命令验证配置文件的内容。

$ docker compose config

运行此命令时,Docker Compose 会读取 docker-compose.yml 文件,将其解析为内存中的数据结构,并在可能的情况下进行验证,然后打印回从其内部表示形式重建的配置文件。如果由于错误而无法执行此操作,则 Docker 会打印错误消息。

使用 Docker Compose 构建并运行应用程序

启动应用程序并确认它正在运行。

$ docker compose up --build

您传递了 --build 标志,因此 Docker 将编译您的镜像,然后启动它。

注意

Docker Compose 是一个有用的工具,但它也有自己的怪癖。例如,除非提供了 --build 标志,否则在更新源代码时不会触发重建。一个非常常见的陷阱是编辑源代码,然后在运行 docker compose up 时忘记使用 --build 标志。

由于您的设置现在由 Docker Compose 运行,因此它为其分配了一个项目名称,因此您获得了 CockroachDB 实例的新卷。这意味着您的应用程序将无法连接到数据库,因为数据库不存在于这个新卷中。终端显示数据库的身份验证错误。

# ... omitted output ...
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
roach                   | *
roach                   | * INFO: Replication was disabled for this cluster.
roach                   | * When/if adding nodes in the future, update zone configurations to increase the replication factor.
roach                   | *
roach                   | CockroachDB node starting at 2021-05-10 00:54:26.398177 +0000 UTC (took 3.0s)
roach                   | build:               CCL v20.1.15 @ 2021/04/26 16:11:58 (go1.13.9)
roach                   | webui:               http://db:8080
roach                   | sql:                 postgresql://root@db:26257?sslmode=disable
roach                   | RPC client flags:    /cockroach/cockroach <client cmd> --host=db:26257 --insecure
roach                   | logs:                /cockroach/cockroach-data/logs
roach                   | temp dir:            /cockroach/cockroach-data/cockroach-temp349434348
roach                   | external I/O path:   /cockroach/cockroach-data/extern
roach                   | store[0]:            path=/cockroach/cockroach-data
roach                   | storage engine:      rocksdb
roach                   | status:              initialized new cluster
roach                   | clusterID:           b7b1cb93-558f-4058-b77e-8a4ddb329a88
roach                   | nodeID:              1
rest-server exited with code 0
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:26 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:29 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:25 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:26 failed to initialise the store: pq: password authentication failed for user totoro
rest-server             | 2021/05/10 00:54:29 failed to initialise the store: pq: password authentication failed for user totoro
rest-server exited with code 1
# ... omitted output ...

由于您使用 restart_policy 设置了部署方式,因此失败的容器每 20 秒重新启动一次。因此,为了解决问题,您需要登录到数据库引擎并创建用户。您之前已经在 配置数据库引擎 中执行过此操作。

这不是什么大问题。您只需连接到 CockroachDB 实例并运行三个 SQL 命令来创建数据库和用户,如 配置数据库引擎 中所述。

因此,从另一个终端登录到数据库引擎。

$ docker exec -it roach ./cockroach sql --insecure

并运行与之前相同的命令来创建数据库 mydb、用户 totoro 以及向该用户授予必要的权限。完成此操作后(示例应用程序容器会自动重新启动),rest-service 将停止失败并重新启动,控制台将保持静默。

您可以连接之前使用的卷,但出于本例的目的,这样做会比较麻烦,而且也无法展示如何使用restart_policy Compose 文件功能在部署中引入弹性。

测试应用程序

现在,测试您的 API 端点。在新终端中,运行以下命令

$ curl https://127.0.0.1/

您应该收到以下响应

Hello, Docker! (0)

关闭

要停止由 Docker Compose 启动的容器,请在运行docker compose up的终端中按ctrl+c。要删除已停止的容器,请运行docker compose down

分离模式

您可以使用-d标志以分离模式运行由docker compose命令启动的容器,就像使用docker命令一样。

要启动由 Compose 文件定义的堆栈,请在分离模式下运行

$ docker compose up --build -d

然后,您可以使用docker compose stop停止容器,并使用docker compose down删除它们。

进一步探索

您可以运行docker compose来查看其他可用命令。

总结

本章刻意省略了一些与主题相关的有趣内容。对于更具冒险精神的读者,本节提供了一些进一步学习的建议。

持久化存储

托管卷不是为容器提供持久存储的唯一方法。强烈建议您熟悉可用的存储选项及其用例,这些内容在管理 Docker 中的数据中介绍。

CockroachDB 集群

您运行了一个 CockroachDB 实例,这对于本例来说已经足够了。但是,您可以运行一个 CockroachDB 集群,它由多个 CockroachDB 实例组成,每个实例都在自己的容器中运行。由于 CockroachDB 引擎是按设计分布式的,因此您只需进行很少的更改就可以运行具有多个节点的集群。

这种分布式设置提供了一些有趣的可能性,例如应用混沌工程技术来模拟集群部分发生故障并评估您的应用程序应对这种故障的能力。

如果您有兴趣尝试使用 CockroachDB 集群,请查看

其他数据库

由于您没有运行 CockroachDB 实例的集群,您可能想知道是否可以使用非分布式数据库引擎。答案是“可以”,如果您要选择更传统的 SQL 数据库,例如PostgreSQL,本章描述的过程将非常相似。

下一步

在本模块中,您使用应用程序和数据库引擎在不同容器中运行,设置了容器化的开发环境。您还编写了一个 Docker Compose 文件,该文件将这两个容器链接在一起,并提供了一种简单的方法来启动和拆卸开发环境。

在下一个模块中,您将了解在 Docker 中运行功能测试的一种可能方法。