使用容器進行 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 網路的更多資訊。

為卷和網路選擇合適的名稱

俗話說,計算機科學中只有兩件難事:快取失效和命名事物。還有差一錯誤。

為網路或託管卷選擇名稱時,最好選擇一個能表明其預期目的的名稱。本指南旨在簡潔,因此使用了簡短、通用的名稱。

啟動資料庫引擎

現在雜務已處理完畢,您可以在容器中執行 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 的最新修補版本,同時不過分偏離已知的可用版本。

配置資料庫引擎

現在資料庫引擎已上線,在您的應用程式開始使用它之前還需要進行一些配置。幸運的是,這並不多。您必須

  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 傳送包含 { "value" : string } JSON 的 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 映象來構建應用程式,然後透過將編譯後的二進位制檔案放入更精簡、無發行版的映象中來構建最終映象。

無論您是更新了舊的示例應用程式,還是簽出了新的應用程式,都必須構建這個新的 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:///
    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:///send \
  --header 'content-type: application/json' \
  --data '{"value": "Hello, Docker!"}'

應用程式響應訊息內容,這意味著它已儲存到資料庫中

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

傳送另一條訊息

$ curl --request POST \
  --url https:///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

在您的應用程式目錄中,建立一個名為 compose.yaml 的新文字檔案,內容如下。

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 檔案

如果 .env 檔案可用,Docker Compose 將自動從其中讀取環境變數。由於您的 Compose 檔案需要設定 PGPASSWORD,因此請將以下內容新增到 .env 檔案中

PGPASSWORD=whatever

對於本示例來說,確切的值並不重要,因為您在不安全模式下執行 CockroachDB。請務必將變數設定為某個值,以避免出現錯誤。

合併 Compose 檔案

檔名 compose.yamldocker 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 會讀取檔案 compose.yaml,將其解析為記憶體中的資料結構,儘可能進行驗證,然後從其內部表示中打印出該配置檔案的重建結果。如果由於錯誤而無法實現,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:///

您應該收到以下響應

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 中執行功能測試的一種可能方法。