zzxworld

使用 Docker 一步一步安装搭建 Go 语言开发环境

我最近一直在找一门理想中的静态编译语言。看过 Rust,也体验过 Crystal 和 Go。从语法角度来看,Crystal 完全照搬 Ruby 的格式让我十分喜欢。但 Go 的热度又让我觉得这会是一个更加务实的选择。至于 Rust,语法过于怪异,还有点难以接受。所以我决定在 Crystal 和 Go 之间做出选择。

从 Crystal 到 Go

Crystal 作为我的心意之选,在试用的过程中碰到了一些难以解决的问题。比如在我目前的 Arch Linux 上做静态编译时就无法成功,提示找不到一些 libssl,libpcre 之类的组件库。但实际上这些库都已经安装了。尝试了各种办法都无法解决后,我决定还是忍痛先放弃这门语言,等待它继续变的成熟和完善一些。

务实的 Go 成了我目前唯一的选择。

为什么使用 Docker

关于这一点,我在之前介绍 PHP 开发环境搭建的文章《使用 Docker 的容器化 PHP 开发环境实践》中有过解释。主要就是以下三点:

  1. 可以保持系统软件环境的纯净。这一点对于喜欢折腾各种工具软件,然后把系统依赖环境搞的一团糟的我来说,特别受用。

  2. 开发环境和当前使用系统不再强依赖。在接触 Docker 之前,我的主力开发设备是一台 MBP,每年出了新系统后都会有一段要重新编译各种开发工具的阵痛,因为系统环境和依赖组件的版本都有变化了。

    在最新的系统上解决各种软件依赖对我来说是一项很痛苦的工作。而 Docker 很好的解决了这个问题。无论系统再怎么升级,都不会再影响到我的开发环境。即便是我的开发环境已经从 macOS 系统换成了 Linux 系统,开发环境毫无大碍。

  3. 开发软件的管理方式更加统一。各种编程语言都有各自的安装流程和步骤,各种应用服务的安装和配置方式也千差万别。通过 Docker,从一个更高的维度抽象和统一了这些差异。不论是 MySQL,还是 Redis,我都只需要拉镜像,映射端口,然后启动容器就行了。

对于 Go 来说,我同样希望能通过 Docker 来支撑完整的开发流程。

搭建流程

作为一个刚接触 Go 语言的新人,我将一步一步的把这次折腾过程都事无巨细的记录下来。先从 Docker 镜像开始。

测试镜像

使用 Docker 来构建开发环境的第一步就是先找镜像。在 Docker hub 上就有 Go 语言的官方镜像:golang,先用下面的命令测试一下这个镜像。

docker run --rm golang:alpine go version

对不熟悉 Docker 的朋友简单解释下上面这条命令:

  • 这条命令以 go 为分界线,前面的部分属于 Docker,表示执行 golang 镜像容器。 alpine 是这个镜像的标签,我个人喜欢 alpine 的小巧,所以选择的这个。
    • --rm 参数表示执行完成后就删除容器,避免浪费存储空间。
  • 后面的 go version 则是执行了镜像容器中的 go 命令,表示查看当前 Go 语言的版本。

这条命令在我的系统上执行结果如下:

image-20210919183619086

命令包装

通过这个镜像,本身已经足以支撑 Go 的学习和使用了。不过每次使用都要敲上这么一长串 Docker 命令,有点不太合适。可以用 Shell 命令来精简一下。

找个位置创建一个 Shell 文件,保存为 go,内容如下:

#!/bin/bash

docker run --rm golang:alpine go @

然后给它可执行权限:

chmod +x ./go

现在就可以通过执行这个 Shell 脚本来运行 Go 命令了:

./go version

不过还有点小瑕疵。就是执行这个 Shell 脚本时,前面必须要带上地址。想要在任何位置都可以通过输入 go 两个字符的方式来执行 Go 命令,目前有两个方案:

  1. 把这个 go 命名的 Shell 脚本丢到系统命令目录中。比如 /usr/local/bin 目录下。
  2. 把这个 Shell 脚本当前的目录添加到系统的 $PATH 环境变量中。

我有一个目录专门用来存放一些自定义脚本,所以选择了第二种方案。

做完这些,我就可以和其他安装方式一样,在任何位置执行 go 命令了。

看看现在的使用效果:

image-20210919185256778

能看出是通过 Docker 执行的么?

执行 Go 代码

上面的 go 命令虽然可以执行了,不过还不能运行 go 代码文件。因为还没提供目录挂载功能。把这儿自定义的 Shell 脚本完善一下,内容如下:

#!/bin/bash

docker run --rm \
    -v $PWD:/srv/app \
    -w /srv/app \
    golang:alpine go $@

添加了一些新的参数,简单解释一下:

  • -v Docker 挂载目录的参数。$PWD 代表当前执行命令的位置,/srv/app 是镜像容器中的目录。
  • -w Docker 设置容器运行时的工作主目录。这主要是为了配合上面的 -v 参数来执行当前目录下的 go 代码。

到了这步,这个基于 Docker 的 Go 运行环境才算是真正可用了。创建一个简单的 Go 代码测试一下。

在任何目录下创建一个 hello.go 文件,代码如下:

package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello, Go.")
}

保存后,执行 go run hello.go 命令。就能得到以下输出:

image-20210919191719821

使用镜像

在国内选择任何一门编程语言时,一个首要的考量就是包管理工具有没有提供镜像加速功能。Crystal 目前就没有这个功能,这也是我暂时放弃它的一方面。

Go 可以通过环境变量的方式来配置镜像,Docker 也支持设置容器运行时的环境变量。再次完善一下自定义的 go Shell 脚本,目前它的内容如下:

#!/bin/bash

docker run --rm \
    -e GOPROXY=https://goproxy.cn \
    -v $PWD:/srv/app \
    -w /srv/app \
    golang:alpine go $@

添加了一个 -e 参数,这是 Docker 用来设置容器运行时的环境变量,通过这个参数把后面 Go 的镜像地址配置带入运行的容器。

用 Go 语言的 Web 开发框架 Gin 来测试一下配置镜像参数后的效果:

go get -u github.com/gin-gonic/gin

执行结果如下:

image-20210919193842259

33 秒就完成了软件包的下载,我对这个速度很满意。没添加镜像配置前根本就下载不下来。

Go mod 功能完善

为了方便的使用软件包,Go mod 功能必然是不可少的。上面我们已经添加了镜像加速功能,并且测试了可以正常下载,但还没测试是否能正常使用下载的软件包。

还是以 Web 框架 Gin 为例。把 hello.go 文件的代码换成如下的内容:

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()

    r.GET("/ping", func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message": "pong",
        })
    })

    r.Run()
}

因为要使用 mod 功能,所以先在 hello.go 文件目录下生成一个 go.mod 文件:

go mod init hello

命令执行完成后,看下当前目录,会发现多了一个 go.mod 文件。不过权限貌似有点问题:

image-20210919195748297

生成的 go.mod 是 root 权限,这有点不太合适。

调整一下 go Shell 脚本,改成如下的内容:

#!/bin/bash

docker run --rm \
    -u $UID:$UID \
    -e GOPROXY=https://goproxy.cn \
    -v $PWD:/srv/app \
    -w /srv/app \
    golang:alpine go $@

使用 Docker 的 -u 参数指定了当前用户 ID。再次执行一下初始化 go.mod 的代码,权限就是当前用户了。

处理完权限问题,继续 mod 包的添加流程,再次执行获取 Gin 包的命令:

go get -u github.com/gin-gonic/gin

虽然包下载成功,不过注意一下最后一行,貌似一个缓存用途的目录因为权限问题创建失败。

image-20210919201113708

这估计是刚才调整了运行用户导致的。但我不可能改回 root,这不是一个正常项目下应该出现的用户。

通过一番搜索和了解,大致找到了原因和解决方案。简单来说,这是只有采用 Docker ,并以普通用户方式运行容器里的 go 命令时才会碰到的一个问题。解决方法就是再次完善一下自定义的 go Shell 脚本,调整后的脚本内容如下:

#!/bin/bash

docker run --rm \
    -u $UID:$UID \
    -e XDG_CACHE_HOME=/tmp/.cache \
    -e GOPROXY=https://goproxy.cn \
    -v $PWD:/srv/app \
    -w /srv/app \
    golang:alpine go $@

添加了一个 XDG_CACHE_HOME 环境变量,把它的默认值定义到了 /tmp 目录。

再次运行获取 Gin 软件包的命令,这回正常了。命令执行完成后,当前目录下会多了一个 go.sum 文件。这是软件包的校验文件。有了这个文件后,就能正常执行刚刚修改后的 hello.go 代码了:

go run hello.go

执行结果如下:

image-20210919202655836

从最后结尾的输出信息就能看出,通过 mod 安装的包起作用了。程序开始监听 8080 端口,根据代码中的定义,如果访问 localhost:8080/ping 这个地址,应该能得到包含 Pong 字符串的 Json 返回数据。

不过这个地址大概率是无法访问的。因为我想起来自定义的 Shell 脚本中,没有启用端口映射,所以还无法直接访问这个容器里面运行的 Go 程序。除此之外,我还发现两个问题:

  1. 刚才明明已经通过 go mod get 命令下载过 Gin 包了,但现在运行时又下载了。
  2. 我无法通过 Ctrl + c 组合键来终止程序。

所以继续调整自定义的 Go Shell 脚本,调整后的内容如下:

#!/bin/bash

docker run --rm -it \
    -u $UID:$UID \
    -e XDG_CACHE_HOME=/tmp/.cache \
    -e GOPROXY=https://goproxy.cn \
    -v $PWD:/srv/app \
    -v $HOME/go:/go \
    -w /srv/app \
    -p 8080:8080 \
    golang:alpine go $@

针对上面的三个问题添加了三处配置:

  1. 添加了 -it 参数,使运行的容器可以接受交互操作,所以按 Ctrl + c 就可以终止程序了。
  2. 映射了一组新的目录 -v $HOME/go:/go,这是因为 go mod 会把软件包安装到 $GOPATH 定义的位置,这个位置如果不映射到容器外的目录,每次执行结束都会跟随容器一起销毁,所以每次都会要重新下载项目依赖的软件包。
  3. 添加了端口映射,所以再次执行代码,就能通过浏览器正常访问代码定义的端口和内容了。

编译

编译是 Go 语言最具特色的一个环节。少了这个环节,Go 语言的魅力对我来说就荡然无存了。上面自定义的 go Shell 脚本虽然也能执行 build 命令,但我发现编译后的执行文件无法在容器外执行。

image-20210919204821126

这是因为我使用了 alpine 标签的 go 语言 Docker 镜像。要解决这个问题有两个方案:

  1. 采用和当前开发系统一致的 Docker 镜像。
  2. 使用 Go 语言的交叉编译功能。

我选择了后者。所以创建了一个新的自定义脚本,命名为 gobuild。内容如下:

#!/bin/bash

docker run --rm -it \
    -u $UID:$UID \
    -e XDG_CACHE_HOME=/tmp/.cache \
    -e GOPROXY=https://goproxy.cn \
    -e CGO_ENABLED=0 \
    -e GOOS=linux \
    -e GOARCH=amd64 \
    -v $PWD:/srv/app \
    -v ~/Services/go:/go \
    -w /srv/app \
    golang:alpine go build $@

脚本主要就是添加了 CGO_ENABLEDGOOSGOARCH 这三个交叉编译环境变量。并且把 build 参数添加到了 go 命令的后面。

上面 hello.go 的代码,我通过如下命令来执行编译:

gobuild hello.go

通过此命令再次编译后生成的可执行文件,就能正常在容器外执行了。

image-20210919205523303

我还尝试把这个文件上传到了一台线上服务器上,结果也能正常执行。

一个编译好的二进制包,服务器上不用再安装任何代码运行环境,丢上去就能运行。如果搭配微服务,是不是很性感?

Am I Sexy?

这就是静态编译语言的魅力。

总结

Go 对我来说还是一块处女地,所以这份搭建流程的记录也忠实反应了我一步一步尝试过程中碰到的问题和解决的思路。我之所以记录下其中的细节,就是希望以此能给一些刚接触编程的朋友们带来一些思路上的帮助。

对于编程来说,什么技术还没学会这不重要。重要的是解决问题的思路和能力。所以希望这篇文章能给一些朋友带来启发。

最后,对于刚接触编程的新人朋友,不建议你们采用我当前这种完全基于 Docker 的开发环境搭建方式。原因就是这篇洋洋洒洒几千字的文章,以及碰到并解决的各种问题,其实就等效于 Arch Linux 下这样一行命令:

sudo pacman -S go