使用容器进行 Go 开发

先决条件

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

简介

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

本地数据库和容器

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

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

CockroachDB 在很大程度上与 PostgreSQL 兼容,并且与后者共享许多约定,特别是环境变量的默认名称。因此,如果您熟悉 Postgres,如果您看到一些熟悉的环境变量名称,请不要感到惊讶。与 Postgres 一起使用的 Go 模块,例如pgx、pq、GORM和upper/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 已成功创建。其他三个网络(名为 bridge、host 和 none)是默认网络,它们是由 Docker 本身创建的。虽然与本指南无关,但您可以在网络概述部分了解有关 Docker 网络的更多信息。

为卷和网络选择好名称

俗话说,计算机科学只有两件难事:缓存失效和命名事物。以及越界错误。

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

启动数据库引擎

现在家务事

$ 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. 使用用户名totoro注册一个新的SQL用户帐户。

    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现在包含数据库初始化代码,以及实现新业务需求的代码

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

您还有一个针对另一个业务需求的更新。该需求是

  • 应用程序对/ 的请求返回包含心形符号(“<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 initialize 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 stop和docker 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是docker compose命令在不提供-f标志时识别的默认文件名。这意味着如果您的环境有这样的要求,您可以拥有多个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 中运行功能测试的一种可能方法。