原文: How to Dockerize a Go Application – Full Step-by-Step Walkthrough

假设你想把源代码分享给一个电脑上没有安装 Go 的人。遗憾的是,这个人将无法运行你的应用。即使对方安装了 Go,由于你们的本地开发环境不同,应用的行为也可能有所差异。

那么,怎样才能把应用及其所需内容打包起来,让它在任何本地环境中都以相同的方式运行呢?这正是 Docker 的用武之地。

对于初学者来说,Docker 并不总是一个容易理解的概念。但一旦理解了它,我敢保证,你会觉得它非常有趣,甚至想把能接触到的每个应用都进行 Docker 容器化。

本文将以一个 Go 应用为例。这里讲解的容器化基本概念可以迁移到其他场景,因此不必过于担心使用其他语言开发的应用该如何进行 Docker 容器化。

我们将介绍如何仅使用 Docker、镜像和容器对 Go 应用进行容器化,如何使用 Docker Compose 在一个应用中设置多个容器,以及 Docker Compose 文件由哪些部分组成。

读完本文后,你将对 Docker、镜像和容器有基本的了解,并知道如何使用 Docker Compose 编排多个相互依赖的容器。

本文内容

  1. 前置条件
  2. 什么是 Docker
  3. 如何安装 Docker
  4. 什么是 Dockerfile
  5. 什么是 Docker Compose
  6. app 容器
  7. database 容器
  8. phpMyAdmin 容器
  9. 一起运行所有服务
  10. 总结

前置条件

阅读本教程不需要具备任何 Docker 基础知识。本文从初学者的角度编写,所以即使你从未接触过这个概念也没有关系。

如果你具备一些 Go 基础知识,就能更好地理解并跟随本文中的 Go 代码示例。如果你已经知道如何在本地电脑上搭建 Go 应用环境,就可以继续阅读。如果还不知道,可以查看这篇关于如何开始使用 Go 编程的文章。

什么是 Docker

想象一下,你有一个盒子。你把代码以及代码运行所需的一切都放进这个盒子,包括代码使用的编程语言,以及需要安装的所有其他外部软件包。

如果有人需要你的应用,你只需把这个盒子交给对方。你也可以把它交给任意数量的人。他们不需要在自己的电脑上安装相应的编程语言或其他任何东西,因为所需的一切都已经装在盒子里。因此,他们运行应用时,实际上是在运行这个盒子的一个实例。

应用在盒子这个标准化环境中运行。这意味着,每个拿到并“打开”盒子的人,都会以完全相同的方式运行该应用。

借助 Docker,应用可以在不同系统上的一致环境中运行,从而避免“在我的电脑上明明能运行”的问题。

一个装有依赖项、运行时和源代码的盒子,通过箭头连接到多位开发者

用 Docker 的专业术语来说,这个盒子叫作镜像,镜像的运行实例叫作容器。

镜像是一种轻量级、独立且可执行的软件包,其中包含运行某个软件所需的一切,包括代码、运行时、库、系统工具,甚至操作系统环境。

容器就是镜像的一个可运行实例。它代表某个特定应用的执行环境。

如果这些内容听起来仍然很抽象,也不用担心。我们很快就会开始实际操作。

如何安装 Docker

为了安装 Docker,我们将安装自带 Docker Engine 的 Docker Desktop。Docker Desktop 是一个用于管理容器的图形用户界面,你会在后面的章节中看到它有多实用。

在撰写本文时,我使用的是 WSL(Windows Subsystem for Linux,适用于 Linux 的 Windows 子系统)。如果你也使用 WSL,安装前需要考虑这一点,因为不同操作系统安装 Docker 的前置条件和步骤并不相同。

要在 WSL 环境中安装 Docker Desktop,请执行以下步骤:

  1. 下载并安装 Windows 版 .exe 文件。
  2. 从“开始”菜单启动 Docker Desktop,然后进入设置。
  3. 在 General 选项卡中选择 Use WSL 2 based engine。
  4. 单击 Apply。

这样就完成了 WSL 环境中的安装。如果你使用其他操作系统,可以在官方文档中查看相应的安装方式。

什么是 Dockerfile

为了创建前面提到的盒子,Docker 首先需要按照一系列明确的步骤操作。它需要知道应用的依赖项和运行时,还需要获得源代码。我们会把所有这些步骤都写在 Dockerfile 中。

在正式开始之前,先创建一个工作目录并进入该目录:

mkdir go_book_api && cd go_book_api

运行以下命令,在应用中初始化 Go 模块:

go mod init go_book_api

执行这条命令需要本地已经安装 Go;但在项目文件准备完成后,其他人只需安装 Docker 就能构建和运行该项目。这条命令会创建一个 go.mod 文件,用来记录项目依赖项。在项目根目录下创建一个 cmd 目录,并在其中创建 main.go 文件。这个文件将作为应用的入口点。你可以在 main.go 文件中加入一条简单的输出语句:

// cmd/main.go
package main

import "fmt"

func main() {
	fmt.Println("Look at me gooo!")
}

接下来,在项目根目录下创建一个名为 Dockerfile 的文件。这个文件没有扩展名;默认情况下,docker build 会在构建上下文的根目录中查找这个名称的构建文件。

把以下内容粘贴到该文件中,然后我们逐项进行说明:

# base image
FROM golang:1.24

# define the working directory
WORKDIR /app

# copy go.mod so that the required modules are known in the container
# ./ here is the WORKDIR, /app
COPY go.mod ./

# command to download modules
RUN go mod download

# copy source code into working dir
COPY . .

# build
RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping ./cmd/main.go

# run the compiled binary when the container starts
CMD ["/docker-gs-ping"]

大多数 Dockerfile 都以基础镜像开头,并使用 FROM 关键字指定基础镜像。基础镜像是一个底层模板,它提供在容器中构建和运行应用所需的精简操作系统环境、库或依赖项。

在本例中,基础镜像是 golang:1.24。基础镜像也可以提供 Linux 之类的操作系统环境。在这种情况下,即使你把代码交给没有使用 Linux 的人,对方也不必担心,因为应用将在已经包含精简 Linux 系统的容器环境中运行。同理,即使对方的本地电脑没有安装 Go,也能运行你的应用。

在编写 Dockerfile 时,如果不确定该使用哪种基础镜像,可以随时在 Docker Hub 中浏览已经发布的镜像。本例使用的是 Docker 官方 Go 镜像。

下一步是定义工作目录。容器内有一个类似 Linux 的文件系统,其中包含 /app、/bin、/usr 和 /var 等目录。本例通过 WORKDIR 指令把工作目录定义为 /app。

设置工作目录后,需要把 go.mod 文件复制到其中,以便 Docker 知道应用需要哪些模块。如果项目中已有 go.sum,通常也应将它一并复制,例如将相应指令写成 COPY go.mod go.sum ./。

Docker 的 COPY 指令至少接收两个参数:一个或多个源路径,以及一个目标路径。本例中的 COPY go.mod ./ 会把 go.mod 复制到容器的工作目录 /app 中。

接下来,在容器环境中运行一条命令,把 go.mod 中定义的模块下载到模块缓存。要在构建镜像时运行命令,可以使用 RUN,后面接要执行的命令。本例执行的是 go mod download。

下一步是把所有源代码复制到工作目录中。

现在,依赖项和源代码都已经准备好了。最后,需要把 Go 应用构建为一个可在该环境,也就是容器内部运行的可执行文件。

容器中会生成一个位于 /docker-gs-ping 的已编译二进制文件,它由 main.go 中的代码编译而来。最后的 CMD 指令会告诉 Docker 在容器启动时运行这个可执行二进制文件。换句话说,就是“容器开始运行后,执行这个二进制文件”。

完成这些步骤后,Docker 会构建一个可以运行的镜像,也就是我们在类比中所说的盒子。要构建镜像,可以在终端中运行以下命令:

docker build -t go_book_api .

docker build 命令让 Docker 按照 Dockerfile 中的步骤构建镜像。-t 用于设置镜像标识,其格式可以包含镜像名称和标签。

本例将镜像命名为 go_book_api;由于没有显式指定标签,Docker 会使用默认标签 latest。命令末尾的 . 表示当前目录是构建上下文,Docker 会在其中查找 Dockerfile 以及需要复制到镜像中的文件。

下面是我在集成开发环境中构建镜像时的画面:

集成开发环境的终端正在构建 Docker 镜像

如果查看 Docker Desktop 的 Images 选项卡,就会看到镜像已经构建完成:

Docker Desktop 中已经构建好的容器镜像

你可以把这个镜像托管在 Docker Hub 之类的公共镜像仓库平台上,并与朋友分享。他们可以拉取镜像,完成设置并运行你的应用,即使本地没有安装 Go 也没关系。他们只需要把容器运行起来。

单击最右侧的小型播放按钮,就可以从该镜像启动一个实例,也就是一个容器。

用于运行新容器的 Docker Desktop 对话框

你可以为容器指定一个描述性名称。如果不指定,Docker 会随机生成一个名称。然后单击 Run 按钮。容器开始运行后,页面会跳转到它的日志页面。

现在容器已经启动并执行了应用。由于当前程序只输出一行文字后就会退出,容器也会很快停止;你仍然可以在日志中看到程序的输出。

Docker Desktop 中正在运行的 Docker 容器

什么是 Docker Compose

如果你构建的是一个不需要任何外部依赖项的简单 Go 应用,那么上面的设置已经完全足够。

但在本例中,这个应用是一个图书 API,因此通常还需要数据库之类的服务,以及 phpMyAdmin 这样的数据库管理客户端,以便查看数据库表。

如果只使用一个 Dockerfile,把这些服务全部放进同一个容器会比较复杂。Dockerfile 虽然可以通过多阶段构建使用多个 FROM 指令,但 Go 应用、数据库和数据库管理工具是不同的长期运行服务,通常不应打包到同一个容器中。

你可以使用一个精简操作系统的基础镜像,再通过命令手动安装其他服务,但这会把多个组件紧密耦合在一起,使应用更难维护和扩展。某个组件出现故障时,也可能影响整个应用。

为了解决这个问题,Docker Compose 允许你为一个应用定义多个相互连接的服务。它可以协调容器的启动顺序和网络连接,并通过卷持久化或共享数据。

仍然沿用前面的盒子类比,只不过使用 Docker Compose 时,我们不再只有一个盒子:

一个装有多个容器的盒子,通过箭头连接到不同的开发者

Docker Compose 的作用是帮助你编排运行应用所需的多项服务及其容器。可以把它理解为把多个盒子连接在一起。

按照前面的说明,Go 应用将在 app 服务的 go_book_api 容器中运行。把它扩展为实际的图书 API 后,可以由 database 服务提供 MySQL,并把图书数据保存在 mysql-go 命名卷中;你还可以通过 phpmyadmin 服务查看数据库。本文中的最小 Go 示例尚未实现数据库连接。

为了具体了解这种配置,请在项目根目录下创建一个 docker-compose.yml 文件。Docker Compose 默认查找 compose.yaml(首选)、compose.yml、docker-compose.yaml 或 docker-compose.yml。扩展名表明其中的配置使用 YAML 编写。YAML 是一种主要用于配置文件的数据序列化语言。

services:
  app:
    depends_on:
      - database
    build:
      context: .
    container_name: go_book_api
    networks:
      - go_book_api_net
    ports:
      - 8080:8080

  database:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: ${DB_NAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_USER: ${DB_USER}
    volumes:
      - mysql-go:/var/lib/mysql
    ports:
      - 3356:3306
    networks:
      - go_book_api_net

  phpmyadmin:
    image: phpmyadmin
    restart: always
    ports:
      - 9000:80
    environment:
      PMA_HOST: database
      PMA_ARBITRARY: 1
    depends_on:
      - database
    networks:
      - go_book_api_net

volumes:
  mysql-go:

networks:
  go_book_api_net:
    driver: bridge

Docker Compose 文件的顶层有一个 services 字段,其中定义了应用运行所需的所有服务。服务是抽象定义,每项服务可以由一个或多个容器实例实现。

app 容器

app:
  depends_on:
    - database
  build:
    context: .
  container_name: go_book_api
  networks:
    - go_book_api_net
  ports:
    - 8080:8080

第一项服务是 app,也就是你的 Go 应用。你还需要在 app 服务下定义它运行所需的一些参数。

depends_on 属性控制服务的启动和停止顺序。这样一来,如果服务 A 依赖服务 B,系统就会先启动服务 B,再启动服务 A。在本例中,必须先启动 database 服务,再启动 app 服务。请注意,这并不意味着 app 一定会等到 database 已经可以接受请求后才启动。

下一个属性 build 告诉 Docker Compose 使用本地项目构建 Docker 镜像。由于应用的 Dockerfile 位于项目根目录,因此使用 context 属性并将根路径指定为 .。

要为容器指定名称,可以使用 container_name。在 Compose 网络中,服务之间通常使用服务名或网络别名进行通信;本例中,其他服务可以使用 app 引用这项服务。

Docker Compose 的目的在于让多个容器协同工作,而容器借助网络进行通信。如果没有显式声明网络,Compose 会为项目创建默认网络。本例显式定义了一个名为 go_book_api_net 的网络,并让需要相互通信的服务都加入这个网络。

下一个属性是 ports。后面我们会让这个 Go 应用启动一个 HTTP 服务器。要从本地访问它,需要把主机端口映射到容器端口。这里把电脑上的 8080 端口映射到容器中的 8080 端口。

Docker Compose 会读取项目目录中的 .env 文件,用其中的值替换 Compose 文件里的 ${DB_NAME} 等表达式。

为了让本例中的数据库服务能够启动,.env 至少需要提供以下变量。下面的值仅用于演示,请换成适合本地开发环境的值:

DB_ROOT_PASSWORD=change-this-root-password
DB_NAME=go_book_api
DB_USER=go_book_user
DB_PASSWORD=change-this-user-password

由于 Dockerfile 使用了 COPY . .,还应在项目根目录下创建 .dockerignore,防止 .env 被复制进镜像:

.env
.git

同时应把 .env 加入 .gitignore,不要把真实凭据提交到版本库。

database 容器

database:
  image: mysql:8.0
  environment:
    MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    MYSQL_DATABASE: ${DB_NAME}
    MYSQL_PASSWORD: ${DB_PASSWORD}
    MYSQL_USER: ${DB_USER}
  volumes:
    - mysql-go:/var/lib/mysql
  ports:
    - 3356:3306
  networks:
    - go_book_api_net

第二项服务是 database。你可以自行命名列出的服务,不过通常应使用能够说明用途的服务名。

为了演示多容器配置,本例添加了 MySQL 数据库服务。当前的最小 Go 代码还没有连接数据库;实际扩展图书 API 时,可以通过这项服务读写数据。

请记住,创建容器需要镜像。在本例中,image 属性指定了现成的官方镜像 mysql:8.0。启动这项服务时,Docker Compose 会使用该镜像创建数据库容器。

如果你曾在本地配置过数据库,就会知道配置是一个无法省略的步骤。创建数据库需要设置用户、密码和数据库名称。可以在 environment 属性中引用这些变量,并在项目的 .env 文件或 shell 环境中提供实际值。不要把真实凭据直接写入 Compose 文件,也不要把包含真实凭据的 .env 文件提交到版本库。

无论数据库运行在本地还是远程,数据库服务器通常都会监听特定端口,以接收传入的连接。和 app 容器一样,可以为数据库设置一个本地端口,并把它映射到容器中的对应端口。如果要从本地访问数据库,应使用 3356 端口,连接流量会被转发到数据库容器中的 3306 端口;同一 Compose 网络中的其他容器则应通过 database:3306 连接数据库。

容器正常工作、应用开始在数据库中创建和存储数据后,还需要考虑数据持久化。如果数据只保存在容器的可写层中,那么容器被删除或重新创建时,这些数据就会丢失;单纯停止并重新启动同一个容器并不会删除数据。

为了避免数据随容器被删除,需要把数据存储在生命周期独立于容器的卷中。

卷就是为此而设计的。本例中的 mysql-go:/var/lib/mysql 表示把名为 mysql-go 的命名卷挂载到数据库容器内的 /var/lib/mysql 路径。

与前面在 app 容器中把网络设为 go_book_api_net 一样,这个数据库容器也要指定同一个网络。由于需要让容器相互通信,把它们放在同一个网络中是合理的。

phpMyAdmin 容器

本例还可以选择配置 phpmyadmin 服务。我发现使用数据库客户端会更加方便,因为它能让我轻松查看数据库的结构和内容。

phpmyadmin:
  image: phpmyadmin
  restart: always
  ports:
    - 9000:80
  environment:
    PMA_HOST: database
    PMA_ARBITRARY: 1
  depends_on:
    - database
  networks:
    - go_book_api_net

这个过程与配置前面几个容器时几乎相同。Docker 会拉取现成的官方 phpmyadmin 镜像,并使用它创建容器。

这里把 restart 设置为 always,表示容器退出后,Docker 通常会自动重启该容器;如果手动停止容器,则不会立即自动重启。

在主机,也就是本地环境上,可以通过 9000 端口访问这项服务。该端口会映射到容器内的 80 端口。

在 environment 配置中,PMA_HOST 告诉 phpMyAdmin 连接到名为 database 的主机,也就是数据库服务。之所以可以这样做,是因为两个服务位于同一个网络中,这一点可以从 networks 属性看出。PMA_ARBITRARY: 1 允许用户在 phpMyAdmin 登录页面手动填写数据库服务器的主机名。例如,如果以后配置了另一个数据库,但仍想通过 phpMyAdmin 连接它,就可以在登录页面中指定该主机。

数据库客户端依赖 database 服务,因此还需要在 depends_on 中指定这一点。

volumes:
  mysql-go:

networks:
  go_book_api_net:
    driver: bridge

Docker Compose 文件的最后一部分定义了前面配置服务时所用的命名卷和自定义网络。

在顶层 volumes 下定义名为 mysql-go 的命名卷,然后在需要使用该卷的服务中指定容器内的挂载路径。可以在数据库服务中看到它的用法:

volumes:
  - mysql-go:/var/lib/mysql

网络也采用相同的思路。这里定义了一个名为 go_book_api_net 的网络,加入该网络的容器可以相互通信。driver 选项用于指定网络驱动;bridge 会创建用户定义的桥接网络,为不同网络中的容器提供隔离,但默认仍允许容器访问外部网络。

一起运行所有服务

在使用 Docker Compose 之前,你只有一个 Dockerfile,用来为 Go 应用构建镜像。使用 Docker Compose 后,你可以定义应用、数据库和 phpMyAdmin 三项服务,并由 Compose 创建相应的容器,让它们作为一个完整应用协同工作。

你可以把整套项目配置推送到 GitHub 等平台。其他人可以克隆、启动并运行该应用,而不必在本地电脑上安装 Go、MySQL 或 phpMyAdmin。不过,他们仍然需要安装 Docker。提交代码前,请确保 .env 等包含真实凭据的文件已经被排除。

要构建 Compose 项目中需要从本地源代码构建的服务镜像,可以使用 docker compose build 命令。本例只有 app 服务包含 build:,因此该命令只会构建应用镜像:

集成开发环境的终端正在构建镜像

再次查看 Docker Desktop 界面,可以看到已经构建了一个与 app 服务对应的新镜像。

Docker Desktop 中已经构建好的镜像

要启动这些容器,可以使用 docker compose up 命令:

集成开发环境的终端中正在运行的容器

进入 Docker Desktop 的 Containers 选项卡,可以查看各个容器的状态:

Docker Desktop 中正在运行的容器

app 服务的 go_book_api 容器并未保持运行,因为容器启动后,当前二进制文件只输出一行文字,执行完毕后几乎会立即退出。

修改 main.go 中的代码,创建一个监听 8080 端口的最小 HTTP 服务器:

// cmd/main.go
package main

import (
	"log"
	"net/http"
)

func main() {
	http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	log.Println("listening on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

如果你刚开始学习 Go,也不必太担心上面的代码。它为 /health 端点注册了一个处理函数,并让 HTTP 服务器监听 8080 端口;访问该端点时,服务器会返回“ok”。

确认 Dockerfile 中包含以下指令,让容器启动时执行已编译的二进制文件:

# run the compiled binary when the container starts
CMD ["/docker-gs-ping"]

修改 main.go 后,需要重新构建并再次启动这些容器。现在可以看到,所有容器都正在运行:

Docker Desktop 中正在运行的所有容器

单击 go_book_api 容器,可以看到服务器正按配置在 8080 端口上运行:

Docker Desktop 中正在运行的容器

由于应用运行在 8080 端口,并且已经设置了 /health 端点,因此可以在浏览器中访问该端点并查看输出“ok”。

浏览器中的健康检查端点显示 ok 响应

此外,单击已映射的 phpmyadmin 端口,就可以通过本地的 9000 端口访问数据库客户端,并使用 .env 文件中设置的数据库凭据登录。

浏览器中的 phpMyAdmin 登录表单

Docker Desktop 中另一个值得查看的功能是卷。你可以在 Volumes 选项卡中看到已经配置好的 mysql-go 卷。

Docker Desktop 中的 Volumes 选项卡

你可以随时在 Docker 图形界面中打开这些卷或容器,查看其中的文件和日志,也可以尝试停止某个容器,观察其他容器会如何响应,等等。

完成这一整套设置后,你发现了什么?项目文件准备完成后,运行项目的人不需要在本地安装 Go、MySQL 或 phpMyAdmin,只需要安装 Docker,就能使用官方发布的镜像编排出一个完整的应用。这就是 Docker 的魅力。

总结

Docker 一开始可能显得非常抽象,但只要理解它的根本用途,一切就会清晰得多。

本文介绍了 Docker 是什么、如何对一个基本的 Go 应用进行容器化,以及如何使用 Docker Compose 管理多个容器。

如果你很难理解 Dockerfile 为什么这样设置,或者为什么要按这样的顺序编写,不必一开始就执着于独自弄清每个细节。刚开始学习 Docker 时,我发现把它想象成编写一份食谱会更容易理解。如果构建镜像失败,就说明你可能遗漏了其中某个步骤。

如果你希望在本教程之外进一步了解 Docker,可以查看 Docker 官方文档。其中有许多非常出色的资源。我也建议你继续深入学习,因为本文只是初步介绍了容器化可以实现的众多功能。