假设你想与那些电脑上没有安装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. 应用程序容器

  7. 数据库容器

  8. phpMyAdmin容器

  9. 整体运行流程

  10. 总结

先决条件

阅读本教程之前,你不需要具备任何关于Docker的预先知识。这篇文章是专为初学者编写的,因此即使这些概念对你来说都是新的,也没有关系。

为了能够充分理解这里使用的Go语言编程示例,如果你对Golang有基本的了解会很有帮助。如果你已经知道如何在本地电脑上搭建Go语言应用程序,那么就可以直接开始学习了;如果还不熟悉,可以阅读这篇关于如何开始学习Go语言编程的文章。

什么是Docker?

想象一下,你有一个盒子。把你的代码以及运行它所需的一切都放进去——也就是它所使用的编程语言,以及任何需要安装的外部包。

如果有人需要你的应用程序,你只需把那个盒子交给他们就可以了。你也可以把这个盒子送给任意多人。他们不需要在自己的电脑上安装任何语言环境或其他软件,因为盒子里已经包含了他们所需的一切。所以,当他们运行这个应用程序时,实际上就是在运行这个盒子中的程序。

该应用程序是在这个盒子内部运行的,而这个盒子本身就构成了标准的环境。这意味着,对于所有拿到这个盒子并“打开它”的人来说,这个应用程序的运行方式都是一样的。

借助Docker的技术,应用程序可以在不同的系统上,在相同的条件下运行,这样就避免了“在我的机器上可以运行,但在其他机器上却不行”这样的问题。

一个装有各种依赖项、运行时环境以及源代码的盒子,盒子上还有指向多名开发者的箭头

从技术角度来说,这个盒子被称为镜像,而正在运行的程序实例则被称为容器

镜像是一种轻量级、独立的可执行包,它包含了运行某款软件所需的一切资源,包括代码、运行时环境、库文件、系统工具,甚至操作系统本身。

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

如果这些概念听起来有点抽象,不用担心。稍后我们就会通过实际操作来了解它们。

如何安装Docker

要安装Docker,我们需要先安装Docker Desktop,因为它是与Docker Engine捆绑在一起的。Docker Desktop是一个用于管理容器的图形化界面,在后续的内容中你会看到它非常实用。

在撰写这篇文章的时候,我使用的是WSL(Windows Sub-system for Linux)。如果你也在使用WSL,那么在安装之前需要考虑到这一点,因为不同操作系统对于Docker的安装要求和步骤是不同的。

要在WSL上安装Docker Desktop,请按照以下步骤操作:

  1. 下载并安装windows版本的.exe文件

  2. 从“开始菜单”启动Docker Desktop,然后进入设置界面

  3. 在“常规”选项卡中选择使用基于WSL 2的引擎

  4. 点击“应用”按钮即可完成安装。

以上就是在WSL上安装Docker的步骤。如果你使用的是其他操作系统,那么官方文档中提供了针对不同操作系统的安装指南。

什么是Dockerfile?

要构建应用程序,Docker需要按照一系列特定的步骤进行操作。它需要了解应用程序所依赖的组件、运行时环境,同时也需要源代码文件。所有这些信息都会被记录在Dockerfile中。

在开始具体操作之前,我们先创建一个工作目录并进入该目录。

mkdir go_book_api && cd go_book_api

要初始化你的Go项目,请运行以下命令:

go mod init go_book_api

这个命令会生成一个go.mod文件,用于记录项目的依赖关系。在项目根目录下创建一个cmd目录,并在其中编写main.go文件——这个文件将是你的应用程序的入口点。在main.go文件中,你可以添加一条简单的打印语句:

// cmd/main.go
package main

import "fmt"

func main() {
	fmt.Println("看吧,我来了!")
}

现在,在项目根目录下创建一个名为Dockerfile的文件。这个文件没有扩展名,但你的系统会自动识别它是一个用于Docker命令的文件。

将以下内容粘贴到该文件中,然后我们会逐一解释每条指令的含义:

# 基础镜像
FROM golang:1.24

# 定义工作目录
WORKDIR /app

# 复制go.mod和go.sum文件,以便容器内能够知道需要安装哪些包
COPY go.mod ./

# 安装项目依赖的模块
RUN go mod download

# 将源代码复制到工作目录中
COPY . .

# 构建应用程序
RUN CGO_ENABLED=0 GOOS=linux go build -o /docker-gs-ping ./cmd/main.go

# 容器启动时运行编译后的二进制文件
CMD ["/docker-gs-ping"]

大多数Dockerfile都是以基础镜像开始的,这一镜像是通过FROM关键字指定的。基础镜像是一种基础的模板,它提供了构建和运行容器内应用程序所需的最基本操作系统环境、库以及依赖组件。

在这个例子中,你使用的基础镜像是golang:1.24。当然,这个基础镜像也可以是一个操作系统,比如Linux。这样一来,当你将代码发送给那些没有安装Linux系统的人时,他们也不必担心——因为他们运行的环境已经具备了基本的Linux操作系统的功能,所以你的应用程序依然可以正常运行。同样地,即使某人本地没有安装Go语言,他们也同样可以运行你的应用程序。

在编写Dockerfile时,如果你不确定应该使用哪个基础镜像,可以随时查看Docker Hub官方仓库中发布的各种镜像。对于这个例子,你可以去这里查看Golang官方发布的基礎镜像列表。

下一步是定义一个工作目录。在您的虚拟环境中,所使用的文件系统与Linux系统中的文件系统几乎完全相同。系统中存在诸如/app/bin/usr以及/var之类的文件夹。在这种情况下,您定义的工作目录是/app,而这一操作是通过WORKDIR命令来完成的。

在设置了工作目录之后,你需要将go.modgo.sum文件复制到该目录中,这样Docker才能知道需要为你的应用程序添加哪些依赖项。

Docker中的COPY命令至少需要两个参数:源目录和目标目录。在这种情况下,你需要将go.modgo.sum文件复制到容器的 WORKDIR目录/app中。

在容器内部,你会运行一条命令来下载并安装go.mod文件中定义的所有模块。要在Docker环境中运行命令,可以使用RUN命令后跟上相应的命令,在这里就是go mod download

下一步是将你拥有的所有源代码复制到工作目录中。

此时,你已经拥有了所有的依赖项和源代码。最后一步就是将Go应用程序编译成一个可以在你的环境中运行的可执行文件。

在容器内部,/docker-gs-ping目录下会生成一个经过编译的二进制文件,这个文件是由main.go文件中的代码编译而成的。最后的步骤就是使用RUN命令让Docker在编译完成后运行这个可执行文件。换句话说,就是“等容器启动后,就执行这个二进制文件”。

通过这些步骤,Docker会生成一个你可以运行的镜像。要构建这个镜像,你可以在终端中运行以下命令:

docker build -t go_book_api .

docker build命令告诉Docker根据Dockerfile中的步骤来构建镜像。-t选项用于指定镜像的标签,这样在以后运行容器时就可以通过这个标签来引用该镜像。

除了标签之外,你还可以为镜像起一个名称,在这里就是go_book_api。命令末尾的.也很重要,因为它告诉DockerDockerfile的位置以及需要复制到镜像中的文件。

在我的IDE中,构建镜像的过程如下所示:

IDE终端中显示镜像构建过程的截图

如果你在Docker Compose中查看“Images”选项卡,就会看到镜像已经构建完成了:

Docker Desktop上显示已构建镜像的截图

你可以将这个镜像托管在像Docker Hub这样的公共镜像仓库平台上,然后与朋友们分享。他们可以拉取你的镜像,进行配置,之后即使没有安装Go环境,也能运行你的应用程序。他们只需要让容器启动即可。

如果你点击最右侧的那个播放按钮,就可以启动该图像的一个实例(也就是一个容器)。

Docker Compose中用于运行新容器的模态窗口截图

你可以为这个容器起一个描述性的名称(如果你不指定,Docker会自动生成一个随机名称),然后点击“运行”按钮。一旦容器开始运行,系统就会将你重定向到它的日志页面。

你的容器已经启动并正在运行了!你可以看到,这其实就是你在运行的应用程序实例。

Docker Compose中正在运行的Docker容器的截图

什么是Docker Compose?

如果你正在构建一个不需要任何外部依赖的简单Go应用程序,那么上述设置就已经足够使用了。

在这个例子中,我们所开发的程序是用于一个图书API的,因此你可能会认为我们需要一些服务,比如数据库,以及像phpMyAdmin这样的数据库管理工具来查看表格数据。

如果想仅使用Docker来配置所有这些组件,会显得有些复杂。因为Docker不允许在一个配置文件中同时指定Go应用程序的基础镜像和数据库的基础镜像等等。

你也可以使用一个小型操作系统的基础镜像,然后通过运行命令手动安装其他所需的服务作为依赖项,但这种方法会导致你的应用程序难以维护和扩展。这种做法并不推荐,因为如果其中某个依赖项出现故障,整个应用程序就会立即崩溃。

为了解决这个问题,Docker Compose允许你为你的应用程序创建多个相互连接的容器。Docker Compose会负责按照正确的顺序启动这些容器,同时还能让一个容器使用另一个容器中的文件,或者将数据存储在另一个容器中等等。

我们之前用箱子来打比方的方式仍然适用,只不过在使用Docker Compose时,我们不再一定只使用一个箱子了:

一个箱子里装着多个容器,这些容器之间有箭头指向不同的开发人员

Docker Compose的作用就是帮助你管理运行应用程序所需的多个组件。你可以把它想象成是将几个箱子连接在一起。

根据之前的解释,你的应用程序会运行在Go book api容器中;而我们用这个应用程序生成的书籍数据则会存储在mysql容器里,也就是数据库中;你可以通过位于phpMyadmin容器中的phpMyAdmin工具来查看这些数据库数据。

从技术角度来说,需要在项目根目录下创建一个名为 `docker-compose.yml` 的文件。这个文件的名称非常重要,因为 Docker Compose 只能接受诸如 `compose.yml`、`docker-compose.yml` 或 `docker-compose.yaml` 这样的文件名。文件扩展名表明这些配置命令是用 `yaml` 语言编写的,而 `yaml` 主要被用于配置文件的编写。

services:
  app:
    depends_on:
      - database
    build: 
      context: .
    container_name: go_book_api
    hostname: go_book_api
    networks:
      - go_book_api_net
    ports:
      - 8080:8080
    env_file:
      - .env
    
  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.yml` 文件的根目录下,有一个名为 `services` 的部分。这些其实就是你的应用程序运行所需要的所有容器,在 Docker Compose 的框架中,它们每一个都被视为一项服务。

`app` 容器

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

第一个容器就是 `app` 容器,也就是你的 Go 应用程序所在的核心容器。在 `app` 容器中,你需要定义一些该容器运行所必需的参数。

`depends_on` 属性用于控制容器内各服务之间的启动和关闭顺序。这样就能确保如果容器 A 需要依赖容器 B 才能启动,那么容器 B 必须先被启动,这样才能让容器 A 正常运行。在这个例子中,`database` 容器必须先于 `app` 容器启动。不过需要注意的是,这并不意味着 `app` 容器会一直等待 `database` 容器准备好才开始运行。

下一个属性是 `build`,它告诉 Docker Compose 从本地项目目录中构建 Docker 镜像。由于你的应用程序对应的 Dockerfile 位于项目的根目录下,因此你可以通过 `context` 属性指定路径为 `.`,即当前目录。

如果你想给容器起一个特定的名称,就可以使用 `container_name` 这个属性;而 `hostname` 则是其他容器用来与这个容器进行通信时使用的名称。

请记住,Docker Compose的核心目的就是让多个容器能够相互通信。它们是通过网络来实现这一点的。因此,你需要创建另一个属性 `networks`,并为其指定一个名称,比如 `go_book_api_net`。对于任何你想与这个 `app` 关联的其他容器来说,你也都需要指定同一个网络名称。ports是另一个需要配置的属性。你的应用程序是一个API,因此它是在一个Go后端服务器上运行的。要访问这个API,你需要将本地端口映射到容器中的某个端口。在这种情况下,你是将计算机上的8080端口映射到容器中的8080端口。

env_file属性用于告诉Docker Compose从哪里读取环境变量。你可以 在项目根目录下创建一个.env文件,用来存储容器运行所需的重要环境变量。

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容器。需要注意的是,你可以为这些服务指定任何名称,但给它们起具有描述性的名字通常是一个好的习惯。

对于你的Go应用程序来说,所使用的数据库是MySQL。由于你的应用程序需要MySQL才能正常运行,因此必须将MySQL设置为其中一个服务。

请记住,构建容器时需要一个基础镜像。在这个例子中,基础镜像是mysql:8.0,正如你在上面用image属性指定的那样。当尝试创建这个容器时,Docker Compose会知道应该使用这个现有的官方镜像来构建容器。

如果你之前已经在本地配置过数据库,那么你会知道配置是必不可少的一个步骤。每个数据库都需要用户账户、密码以及数据库名称。这些信息可以设置在environment属性中。为了避免将这些值硬编码在代码中,你可以将它们保存在一个.env文件中,然后像这里一样通过环境变量来引用这些配置。

无论数据库是在本地运行还是远程运行,数据库服务器通常都会在特定的端口上监听来自外部的连接请求。就像你为app容器指定的那样,你也可以为数据库设置一个端口,并将其映射到容器中的相应端口。如果你想在本地访问数据库,就可以使用3356这个端口;所有请求都会被转发到数据库容器中的3306端口。

一旦你的容器开始运行并且应用程序开始在数据库中创建和存储数据,你就会发现每次停止再重新启动容器时,之前存储在数据库中的数据都会丢失。

为了避免这种情况,你需要将数据保存在容器之外。这样,每次停止运行容器时,数据库中的数据就不会丢失了。

这就是卷的作用所在——你可以为数据库容器之外的特定位置分配空间,用来存储这些数据。在您这个例子中,您指定的存储位置是 `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选项的目的是为了确保在你停止并重新启动容器时,phpMyAdmin能够自动重新加载。

在宿主机上,也就是你的本地环境中,你可以通过端口9000访问这个服务,而这个端口在容器中对应的是端口80

至于environment配置项,PMA_HOST告诉phpMyAdmin连接到名为database的宿主机上(也就是你的数据库容器)。由于这两个容器处于同一个网络中,所以这种设置是有效的,这一点可以从networks属性中看出来。PMA_ARBITRARY这个选项的存在是为了方便你在将来需要连接到其他宿主机时,仍然可以通过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来指定网络类型,而bridge通常用于私有内部网络。

整体运行配置

在Docker Compose出现之前,你只需要使用一个Dockerfile来构建用于运行Go应用程序的容器。而使用Docker Compose后,你需要构建三个容器——应用程序容器、数据库容器以及phpMyAdmin容器,并将这些容器协调起来,使它们能够作为一个完整的应用程序一起运行。

你可以将所有这些配置推送到GitHub这样的平台上,这样其他人就可以克隆这些配置,在他们的电脑上无需安装MySQL或phpMyAdmin等服务,就能直接运行这个应用程序。不过,他们确实需要先安装Docker才行。

要一次性构建所有的容器,你可以使用`docker compose build`命令:

如果你再次查看Docker Compose的用户界面,就会发现已经生成了一个新的容器镜像,这个镜像对应于你的应用程序服务。

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

如果进入Docker Compose的容器管理页面,你就会看到你的容器已经成功启动并正在运行中。

需要注意的是,主应用程序服务`go_book_api`并没有真正开始运行,因为当你运行该容器镜像时,对应的二进制文件会立即执行完毕并退出程序。

在`main.go`文件中,我们可以重新编写代码,设置一个简单的HTTP处理函数,让这个函数监听8080端口:

如果你是Go语言的新手,上面的代码可能看起来有点复杂。其实它只是创建了一个名为`health`的API端点,并配置了一个处理函数,这个函数会监听8080端口,并向客户端返回“ok”这个响应。

在Dockerfile中,我们需要添加一条命令,以便在容器启动时自动执行刚刚生成的二进制文件。

# 当容器启动时运行编译后的二进制文件
CMD ["/docker-gs-ping"]

添加这些配置后,你需要重新构建容器并再次启动它们。现在你可以看到所有的容器都在运行中:

Docker Desktop上正在运行的容器的截图

如果你点击go_book_api容器,就会发现你的服务器正在8080端口上运行,这与之前的配置是一致的:

Docker Desktop上正在运行的容器的截图

由于你的应用程序运行在8080端口上,并且你为它设置了/health端点,因此你可以在浏览器中访问这个端点,看到“ok”这样的响应结果。

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

另外,如果你点击暴露出来的phpmyadmin端口,就可以在本地通过9000端口访问数据库客户端。根据.env文件中设置的环境变量,你可以登录到该数据库客户端。

浏览器中显示phpMyAdmin登录界面的截图

在Docker Desktop上还有一个很有趣的功能,那就是卷管理。有一个专门的“卷”选项卡,在那里你可以查看自己配置的mysql-go卷。

Docker Desktop上“卷”选项卡的截图

你随时都可以在Docker图形界面中打开这些卷或容器,查看其中的文件和日志,也可以尝试关闭某个容器,观察其他容器的反应情况,等等。

完成整个设置后,你会注意到什么吗?其实你根本不需要在本地安装Go、MySQL或phpMyAdmin。你只需要使用官方发布的基础镜像,就可以构建出一个完整的应用程序。这就是Docker的神奇之处。

总结

刚开始接触Docker时,它可能会让人觉得有些抽象,但一旦了解了它的根本用途,一切就会变得清晰起来。

在这篇文章中,你学习了什么是Docker,如何将一个简单的Go应用程序容器化,以及如何使用Docker Compose来管理多个容器。

<如果你无法理解为什么Dockerfile要按当前的顺序进行配置,我的建议是不要过分纠结于自己去弄清楚这些原因。作为Docker初学者,我发现将这个过程想象成在制作一份“食谱”会更容易理解。如果你尝试构建镜像但失败了,那么你就知道肯定有某个步骤被你忽略了。

<如果你想更深入地了解Docker,官方Docker文档提供了非常丰富的资源。我鼓励你去学习这些内容,因为这篇文章仅仅介绍了容器技术所能实现的那些功能的表面部分而已。

Comments are closed.