使用容器进行 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已成功创建。其他三个网络,名为bridgehost、 和none是默认网络,它们是由 Docker 本身创建的。虽然它与本指南无关,但您可以在网络概述部分了解有关 Docker 网络的更多信息。

为卷和网络选择好名称

俗话说,计算机科学中只有两件难事:缓存失效和命名。以及相差一的错误。

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

启动数据库引擎

现在内务工作已经完成,您可以在容器中运行 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现在包括数据库初始化代码以及实现新业务需求的代码:

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

您还获得了另一项业务需求的更新。要求是:

  • <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 http://localhost/
    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 http://localhost/send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Docker!"}'

应用程序响应消息内容,这意味着它已保存在数据库中:

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

发送另一条消息:

$ curl --request POST \
  --url http://localhost/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,如前面的模块中所示。

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

使用 Docker Compose 提高生产力

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

docker-gs-ping-roach在本部分中,您将创建一个 Docker Compose 文件,以使用单个命令启动应用程序和 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 文件。此外,Docker Compose 文件是...可组合的(双关语),因此可以在命令行上指定多个文件以将部分配置合并在一起。以下列表只是此类功能非常有用的场景的一些示例:docker compose-f

  • 对本地开发的源代码使用绑定挂载,但在运行 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提供了标志,否则更新源代码时不会触发重建。编辑源代码并--build在运行时忘记使用标志是一个非常常见的陷阱docker compose up

由于您的设置现在由 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_policyCompose 文件功能将弹性引入您的部署。

测试应用程序

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

$ curl http://localhost/

您应该收到以下回复:

Hello, Docker! (0)

正在关闭

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

分离模式

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

要启动由 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 中运行功能测试的一种可能方法。