标签 Go 下的文章

Go编译的几个细节,连专家也要停下来想想

本文永久链接 – https://tonybai.com/2024/11/11/some-details-about-go-compilation

在Go开发中,编译相关的问题看似简单,但实则蕴含许多细节。有时,即使是Go专家也需要停下来,花时间思考答案或亲自验证。本文将通过几个具体问题,和大家一起探讨Go编译过程中的一些你可能之前未曾关注的细节。

注:本文示例使用的环境为Go 1.23.0、Linux Kernel 3.10.0和CentOS 7.9。

1. Go编译默认采用静态链接还是动态链接?

我们来看第一个问题:Go编译默认采用静态链接还是动态链接呢?

很多人脱口而出:动态链接,因为CGO_ENABLED默认值为1,即开启Cgo。也有些人会说:“其实Go编译器默认是静态链接的,只有在使用C语言库时才会动态链接”。那么到底哪个是正确的呢?

我们来看一个具体的示例。但在这之前,我们要承认一个事实,那就是CGO_ENABLED默认值为1,你可以通过下面命令来验证这一点:

$go env|grep CGO_ENABLED
CGO_ENABLED='1'

验证Go默认究竟是哪种链接,我们写一个hello, world的Go程序即可:

// go-compilation/main.go

package main

import "fmt"

func main() {
    fmt.Println("hello, world")
}

构建该程序:

$go build -o helloworld-default main.go

之后,我们查看一下生成的可执行文件helloworld-default的文件属性:

$file helloworld-default
helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-default
   不是动态可执行文件

我们看到,虽然CGO_ENABLED=1,但默认情况下,Go构建出的helloworld程序是静态链接的(statically linked)。

那么默认情况下,Go编译器是否都会采用静态链接的方式来构建Go程序呢?我们给上面的main.go添加一行代码:

// go-compilation/main-with-os-user.go

package main

import (
    "fmt"
    _ "os/user"
)

func main() {
    fmt.Println("hello, world")
}

和之前的hello, world不同的是,这段代码多了一行包的空导入,导入的是os/user这个包。

编译这段代码,我们得到helloworld-with-os-user可执行文件。

$go build -o helloworld-with-os-user main-with-os-user.go

使用file和ldd检视文件helloworld-with-os-user:

$file helloworld-with-os-user
helloworld-with-os-user: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-os-user
    linux-vdso.so.1 =>  (0x00007ffcb8fd4000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fb5d6fce000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fb5d6c00000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fb5d71ea000)

我们看到:一行新代码居然让helloworld从静态链接变为了动态链接,同时这也是如何编译出一个hello world版的动态链接Go程序的答案。

通过nm命令我们还可以查看Go程序依赖了哪些C库的符号:

$nm -a helloworld-with-os-user |grep " U "
                 U abort
                 U __errno_location
                 U fprintf
                 U fputc
                 U free
                 U fwrite
                 U malloc
                 U mmap
                 U munmap
                 U nanosleep
                 U pthread_attr_destroy
                 U pthread_attr_getstack
                 U pthread_attr_getstacksize
                 U pthread_attr_init
                 U pthread_cond_broadcast
                 U pthread_cond_wait
                 U pthread_create
                 U pthread_detach
                 U pthread_getattr_np
                 U pthread_key_create
                 U pthread_mutex_lock
                 U pthread_mutex_unlock
                 U pthread_self
                 U pthread_setspecific
                 U pthread_sigmask
                 U setenv
                 U sigaction
                 U sigaddset
                 U sigemptyset
                 U sigfillset
                 U sigismember
                 U stderr
                 U strerror
                 U unsetenv
                 U vfprintf

由此,我们可以得到一个结论,在默认情况下(CGO_ENABLED=1),Go会尽力使用静态链接的方式,但在某些情况下,会采用动态链接。那么究竟在哪些情况下会默认生成动态链接的程序呢?我们继续往下看。

2. 在何种情况下默认会生成动态链接的Go程序?

在以下几种情况下,Go编译器会默认(CGO_ENABLED=1)生成动态链接的可执行文件,我们逐一来看一下。

2.1 一些使用C实现的标准库包

根据上述示例,我们可以看到,在某些情况下,即使只依赖标准库,Go 仍会在CGO_ENABLED=1的情况下采用动态链接。这是因为代码依赖的标准库包使用了C版本的实现。虽然这种情况并不常见,但os/user包net包是两个典型的例子。

os/user包的示例在前面我们已经见识过了。user包允许开发者通过名称或ID查找用户账户。对于大多数Unix系统(包括linux),该包内部有两种版本的实现,用于解析用户和组ID到名称,并列出附加组ID。一种是用纯Go编写,解析/etc/passwd和/etc/group文件。另一种是基于cgo的,依赖于标准C库(libc)中的例程,如getpwuid_r、getgrnam_r和getgrouplist。当cgo可用(CGO_ENABLED=1),并且特定平台的libc实现了所需的例程时,将使用基于cgo的(libc支持的)代码,即采用动态链接方式。

同样,net包在名称解析(Name Resolution,即域名或主机名对应IP查找)上针对大多数Unix系统也有两个版本的实现:一个是纯Go版本,另一个是基于C的版本。C版本会在cgo可用且特定平台实现了相关C函数(比如getaddrinfo和getnameinfo等)时使用。

下面是一个简单的使用net包并采用动态链接的示例:

// go-compilation/main-with-net.go

package main

import (
    "fmt"
    _ "net"
)

func main() {
    fmt.Println("hello, world")
}

编译后,我们查看一下文件属性:

$go build -o helloworld-with-net main-with-net.go 

$file helloworld-with-net
helloworld-with-net: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped

$ldd helloworld-with-net
    linux-vdso.so.1 =>  (0x00007ffd75dfd000)
    libresolv.so.2 => /lib64/libresolv.so.2 (0x00007fdda2cf9000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fdda2add000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fdda270f000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fdda2f13000)

我们看到C版本实现依赖了libresolv.so这个用于名称解析的C库。

由此可得,当Go在默认cgo开启时,一旦依赖了标准库中拥有C版本实现的包,比如os/user、net等,Go编译器会采用动态链接的方式编译Go可执行程序。

2.2 显式使用cgo调用外部C程序

如果使用cgo与外部C代码交互,那么生成的可执行文件必然会包含动态链接。下面我们来看一个调用cgo的简单示例。

首先,建立一个简单的C lib:

// go-compilation/my-c-lib

$tree my-c-lib
my-c-lib
├── Makefile
├── mylib.c
└── mylib.h

// go-compilation/my-c-lib/Makefile

.PHONY:  all static

all:
        gcc -c -fPIC -o mylib.o mylib.c
        gcc -shared -o libmylib.so mylib.o
static:
        gcc -c -fPIC -o mylib.o mylib.c
        ar rcs libmylib.a mylib.o

// go-compilation/my-c-lib/mylib.h

#ifndef MYLIB_H
#define MYLIB_H

void hello();
int add(int a, int b);

#endif // MYLIB_H

// go-compilation/my-c-lib/mylib.c

#include <stdio.h>

void hello() {
    printf("Hello from C!\n");
}

int add(int a, int b) {
    return a + b;
}

执行make all构建出动态链接库libmylib.so!接下来,我们编写一个Go程序通过cgo调用libmylib.so中:

// go-compilation/main-with-call-myclib.go 

package main

/*
#cgo CFLAGS: -I ./my-c-lib
#cgo LDFLAGS: -L ./my-c-lib -lmylib
#include "mylib.h"
*/
import "C"
import "fmt"

func main() {
    // 调用 C 函数
    C.hello()

    // 调用 C 中的加法函数
    result := C.add(3, 4)
    fmt.Printf("Result of addition: %d\n", result)
}

编译该源码:

$go build -o helloworld-with-call-myclib main-with-call-myclib.go

通过ldd可以看到,可执行文件helloworld-with-call-myclib是动态链接的,并依赖libmylib.so:

$ldd helloworld-with-call-myclib
    linux-vdso.so.1 =>  (0x00007ffcc39d8000)
    libmylib.so => not found
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f7166df5000)
    libc.so.6 => /lib64/libc.so.6 (0x00007f7166a27000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f7167011000)

设置LD_LIBRARY_PATH(为了让程序找到libmylib.so)并运行可执行文件helloworld-with-call-myclib:

$ LD_LIBRARY_PATH=./my-c-lib:$LD_LIBRARY_PATH ./helloworld-with-call-myclib
Hello from C!
Result of addition: 7

2.3 使用了依赖cgo的第三方包

在日常开发中,我们经常依赖一些第三方包,有些时候这些第三方包依赖cgo,比如mattn/go-sqlite3。下面就是一个依赖go-sqlite3包的示例:

// go-compilation/go-sqlite3/main.go
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/mattn/go-sqlite3"
)

func main() {
    // 打开数据库(如果不存在,则创建)
    db, err := sql.Open("sqlite3", "./test.db")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    // 创建表
    sqlStmt := `CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);`
    _, err = db.Exec(sqlStmt)
    if err != nil {
        log.Fatalf("%q: %s\n", err, sqlStmt)
    }

    // 插入数据
    _, err = db.Exec(`INSERT INTO user (name) VALUES (?)`, "Alice")
    if err != nil {
        log.Fatal(err)
    }

    // 查询数据
    rows, err := db.Query(`SELECT id, name FROM user;`)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()

    for rows.Next() {
        var id int
        var name string
        err = rows.Scan(&id, &name)
        if err != nil {
            log.Fatal(err)
        }
        fmt.Printf("%d: %s\n", id, name)
    }

    // 检查查询中的错误
    if err = rows.Err(); err != nil {
        log.Fatal(err)
    }
}

编译和运行该源码:

$go build demo
$ldd demo
    linux-vdso.so.1 =>  (0x00007ffe23d8e000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007faf0ddef000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007faf0dbd3000)
    libc.so.6 => /lib64/libc.so.6 (0x00007faf0d805000)
    /lib64/ld-linux-x86-64.so.2 (0x00007faf0dff3000)
$./demo
1: Alice

到这里,有些读者可能会问一个问题:如果需要在上述依赖场景中生成静态链接的Go程序,该怎么做呢?接下来,我们就来看看这个问题的解决细节。

3. 如何在上述情况下实现静态链接?

到这里是不是有些烧脑了啊!我们针对上一节的三种情况,分别对应来看一下静态编译的方案。

3.1 仅依赖标准包

在前面我们说过,之所以在使用os/user、net包时会在默认情况下采用动态链接,是因为Go使用了这两个包对应功能的C版实现,如果要做静态编译,让Go编译器选择它们的纯Go版实现即可。那我们仅需要关闭CGO即可,以依赖标准库os/user为例:

$CGO_ENABLED=0 go build -o helloworld-with-os-user-static main-with-os-user.go
$file helloworld-with-os-user-static
helloworld-with-os-user-static: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-with-os-user-static
    不是动态可执行文件

3.2 使用cgo调用外部c程序(静态链接)

对于依赖cgo调用外部c的程序,我们要使用静态链接就必须要求外部c库提供静态库,因此,我们需要my-c-lib提供一份libmylib.a,这通过下面命令可以实现(或执行make static):

$gcc -c -fPIC -o mylib.o mylib.c
$ar rcs libmylib.a mylib.o

有了libmylib.a后,我们还要让Go程序静态链接该.a文件,于是我们需要修改一下Go源码中cgo链接的flag,加上静态链接的选项:

// go-compilation/main-with-call-myclib-static.go
... ...
#cgo LDFLAGS: -static -L my-c-lib -lmylib
... ...

编译链接并查看一下文件属性:

$go build -o helloworld-with-call-myclib-static main-with-call-myclib-static.go

$file helloworld-with-call-myclib-static
helloworld-with-call-myclib-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=b3da3ed817d0d04230460069b048cab5f5bfc3b9, not stripped

我们得到了预期的结果!

3.3 依赖使用cgo的外部go包(静态链接)

最麻烦的是这类情况,要想实现静态链接,我们需要找出外部go依赖的所有c库的.a文件(静态共享库)。以我们的go-sqlite3示例为例,go-sqlite3是sqlite库的go binding,它依赖sqlite库,同时所有第三方c库都依赖libc,我们还要准备一份libc的.a文件,下面我们就先安装这些:

$yum install -y gcc glibc-static sqlite-devel
... ...

已安装:
  sqlite-devel.x86_64 0:3.7.17-8.el7_7.1                                                                                          

更新完毕:
  glibc-static.x86_64 0:2.17-326.el7_9.3

接下来,我们就来以静态链接的方式在go-compilation/go-sqlite3-static下编译一下:

$go build -tags 'sqlite_omit_load_extension' -ldflags '-linkmode external -extldflags "-static"' demo

$file ./demo
./demo: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=c779f5c3eaa945d916de059b56d94c23974ce61c, not stripped

这里命令行中的-tags ‘sqlite_omit_load_extension’用于禁用SQLite3的动态加载功能,确保更好的静态链接兼容性。而-ldflags ‘-linkmode external -extldflags “-static”‘的含义是使用外部链接器(比如gcc linker),并强制静态链接所有库。

我们再看完略烧脑的几个细节后,再来看一个略轻松的话题。

4. Go编译出的可执行文件过大,能优化吗?

Go编译出的二进制文件一般较大,一个简单的“Hello World”程序通常在2MB左右:

$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月  3 10:39 helloworld-default

这一方面是因为Go将整个runtime都编译到可执行文件中了,另一方面也是因为Go静态编译所致。那么在默认情况下,Go二进制文件的大小还有优化空间么?方法不多,有两种可以尝试:

  • 去除符号表和调试信息

在编译时使用-ldflags=”-s -w”标志可以去除符号表和调试符号,其中-s用于去掉符号表和调试信息,-w用于去掉DWARF调试信息,这样能显著减小文件体积。以helloworld为例,可执行文件的size减少了近四成:

$go build -ldflags="-s -w" -o helloworld-default-nosym main.go
$ls -l
-rwxr-xr-x 1 root root 2124504 11月  3 10:39 helloworld-default
-rwxr-xr-x 1 root root 1384600 11月  3 13:34 helloworld-default-nosym
  • 使用tinygo

TinyGo是一个Go语言的编译器,它专为资源受限的环境而设计,例如微控制器、WebAssembly和其他嵌入式设备。TinyGo的目标是提供一个轻量级的、能在小型设备上运行的Go运行时,同时尽可能支持Go语言的特性。tinygo的一大优点就是生成的二进制文件通常比标准Go编译器生成的文件小得多:

$tinygo build -o helloworld-tinygo main.go
$ls -l
总用量 2728
-rwxr-xr-x  1 root root 2128909 11月  5 05:43 helloworld-default*
-rwxr-xr-x  1 root root  647600 11月  5 05:45 helloworld-tinygo*

我们看到:tinygo生成的可执行文件的size仅是原来的30%。

注:虽然TinyGo在特定场景(如IoT和嵌入式开发)中非常有用,但在常规服务器环境中,由于生态系统兼容性、性能、调试支持等方面的限制,可能并不是最佳选择。对于需要高并发、复杂功能和良好调试支持的应用,标准Go仍然是更合适的选择。

注:这里使用的tinygo为0.34.0版本。

5. 未使用的符号是否会被编译到Go二进制文件中?

到这里,相信读者心中也都会萦绕一些问题:到底哪些符号被编译到最终的Go二进制文件中了呢?未使用的符号是否会被编译到Go二进制文件中吗?在这一小节中,我们就来探索一下。

出于对Go的了解,我们已经知道无论是GOPATH时代,还是Go module时代,Go的编译单元始终是包(package),一个包(无论包中包含多少个Go源文件)都会作为一个编译单元被编译为一个目标文件(.a),然后Go链接器会将多个目标文件链接在一起生成可执行文件,因此如果一个包被依赖,那么它就会进入到Go二进制文件中,它内部的符号也会进入到Go二进制文件中。

那么问题来了!是否被依赖包中的所有符号都会被放到最终的可执行文件中呢?我们以最简单的helloworld-default为例,它依赖fmt包,并调用了fmt包的Println函数,我们看看Println这个符号是否会出现在最终的可执行文件中:

$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln

居然没有!我们初步怀疑是inline优化在作祟。接下来,关闭优化再来试试:

$go build -o helloworld-default-noinline -gcflags='-l -N' main.go

$nm -a helloworld-default-noinline | grep "Println"
000000000048ec00 T fmt.(*pp).doPrintln
0000000000489ee0 T fmt.Println

看来的确如此!不过当使用”fmt.”去过滤helloworld-default-noinline的所有符号时,我们发现fmt包的一些常见的符号并未包含在其中,比如Printf、Fprintf、Scanf等。

这是因为Go编译器的一个重要特性:死码消除(dead code elimination),即编译器会将未使用的代码和数据从最终的二进制文件中剔除。

我们再来继续探讨一个衍生问题:如果Go源码使用空导入方式导入了一个包,那么这个包是否会被编译到Go二进制文件中呢?其实道理是一样的,如果用到了里面的符号,就会存在,否则不会。

以空导入os/user为例,即便在CGO_ENABLED=0的情况下,因为没有使用os/user中的任何符号,在最终的二进制文件中也不会包含user包:

$CGO_ENABLED=0 go build -o helloworld-with-os-user-noinline -gcflags='-l -N' main-with-os-user.go
[root@iZ2ze18rmx2avqb5xgb4omZ helloworld]# nm -a helloworld-with-os-user-noinline |grep user
0000000000551ac0 B runtime.userArenaState

但是如果是带有init函数的包,且init函数中调用了同包其他符号的情况呢?我们以expvar包为例看一下:

// go-compilation/main-with-expvar.go

package main

import (
    _ "expvar"
    "fmt"
)

func main() {
    fmt.Println("hello, world")
}

编译并查看一下其中的符号:

$go build -o helloworld-with-expvar-noinline -gcflags='-l -N' main-with-expvar.go
$nm -a helloworld-with-expvar-noinline|grep expvar
0000000000556480 T expvar.appendJSONQuote
00000000005562e0 T expvar.cmdline
00000000005561c0 T expvar.expvarHandler
00000000005568e0 T expvar.(*Func).String
0000000000555ee0 T expvar.Func.String
00000000005563a0 T expvar.init.0
00000000006e0560 D expvar..inittask
0000000000704550 d expvar..interfaceSwitch.0
... ...

除此之外,如果一个包即便没有init函数,但有需要初始化的全局变量,比如crypto包的hashes:

// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)

crypto包的相关如何也会进入最终的可执行文件中,大家自己动手不妨试试。下面是我得到的一些输出:

$go build -o helloworld-with-crypto-noinline -gcflags='-l -N' main-with-crypto.go
$nm -a helloworld-with-crypto-noinline|grep crypto
00000000005517b0 B crypto.hashes
000000000048ee60 T crypto.init
0000000000547280 D crypto..inittask

有人会问:os/user包也有一些全局变量啊,为什么这些符号没有被包含在可执行文件中呢?比如:

// $GOROOT/src/os/user/user.go
var (
    userImplemented      = true
    groupImplemented     = true
    groupListImplemented = true
)

这就要涉及Go包初始化的逻辑了。我们看到crypto包包含在可执行文件中的符号中有crypto.init和crypto..inittask这两个符号,显然这不是crypto包代码中的符号,而是Go编译器为crypto包自动生成的init函数和inittask结构。

Go编译器会为每个包生成一个init函数,即使包中没有显式定义init函数,同时每个包都会有一个inittask结构,用于运行时的包初始化系统。当然这么说也不足够精确,如果一个包没有init函数、需要初始化的全局变量或其他需要运行时初始化的内容,则编译器不会为其生成init函数和inittask。比如上面的os/user包。

os/user包确实有上述全局变量的定义,但是这些变量是在编译期就可以确定值的常量布尔值,而且未被包外引用或在包内用于影响控制流。Go编译器足够智能,能够判断出这些初始化是”无副作用的”,不需要在运行时进行初始化。只有真正需要运行时初始化的包才会生成init和inittask。这也解释了为什么空导入os/user包时没有相关的init和inittask符号,而crypto、expvar包有的init.0和inittask符号。

6. 如何快速判断Go项目是否依赖cgo?

在使用开源Go项目时,我们经常会遇到项目文档中没有明确说明是否依赖Cgo的情况。这种情况下,如果我们需要在特定环境(比如CGO_ENABLED=0)下使用该项目,就需要事先判断项目是否依赖Cgo,有些时候还要快速地给出判断。

那究竟是否可以做到这种快速判断呢?我们先来看看一些常见的作法。

第一类作法是源码层面的静态分析。最直接的方式是检查源码中是否存在import “C”语句,这种引入方式是CGO使用的显著标志。

// 在项目根目录中执行
$grep -rn 'import "C"' .

这个命令会递归搜索当前目录下所有文件,显示包含import “C”的行号和文件路径,帮助快速定位CGO的使用位置。

此外,CGO项目通常包含特殊的编译指令,这些指令以注释形式出现在源码中,比如前面见识过的#cgo CFLAGS、#cgo LDFLAGS等,通过对这些编译指令的检测,同样可以来判断项目是否依赖CGO。

不过第一类作法并不能查找出Go项目的依赖包是否依赖cgo。而找出直接依赖或间接依赖是否依赖cgo,我们需要工具帮忙,比如使用Go工具链提供的命令分析项目依赖:

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'

其中ImportPath是依赖包的导入路径,而CgoFiles则是依赖中包含import “C”的Go源文件。我们以go-sqlite3那个依赖cgo的示例来验证一下:

// cd go-compilation/go-sqlite3

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./...  | grep -v '\[\]'
runtime/cgo: [cgo.go]
github.com/mattn/go-sqlite3: [backup.go callback.go error.go sqlite3.go sqlite3_context.go sqlite3_load_extension.go sqlite3_opt_serialize.go sqlite3_opt_userauth_omit.go sqlite3_other.go sqlite3_type.go]

用空导入os/user的示例再来看一下:

$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}'  main-with-os-user.go | grep -v '\[\]'
runtime/cgo: [cgo.go]
os/user: [cgo_lookup_cgo.go getgrouplist_unix.go]

我们知道os/user有纯go和C版本两个实现,因此上述判断只能说“对了一半”,当我关闭CGO_ENABLED时,Go编译器不会使用基于cgo的C版实现。

那是否在禁用cgo的前提下对源码进行一次编译便能验证项目是否对cgo有依赖呢?这样做显然谈不上是一种“快速”的方法,那是否有效呢?我们来对上面的go-sqlite3项目做一个测试,我们在关闭CGO_ENABLED时,编译一下该示例:

// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo

我们看到,Go编译器并未报错!似乎该项目不需要cgo! 但真的是这样吗?我们运行一下编译后的demo可执行文件:

$ ./demo
2024/11/03 22:10:36 "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub": CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);

我们看到成功编译出来的程序居然出现运行时错误,提示需要cgo!

到这里,没有一种方法可以快速、精确的给出项目是否依赖cgo的判断。也许判断Go项目是否依赖CGO并没有捷径,需要从源码分析、依赖检查和构建测试等多个维度进行。

7. 小结

在本文中,我们深入探讨了Go语言编译过程中的几个重要细节,尤其是在静态链接和动态链接的选择上。通过具体示例,我们了解到:

  • 默认链接方式:尽管CGO_ENABLED默认值为1,Go编译器在大多数情况下会采用静态链接,只有在依赖特定的C库或标准库包时,才会切换到动态链接。

  • 动态链接的条件:我们讨论了几种情况下Go会默认生成动态链接的可执行文件,包括依赖使用C实现的标准库包、显式使用cgo调用外部C程序,以及使用依赖cgo的第三方包。

  • 实现静态链接:对于需要动态链接的场景,我们也提供了将其转为静态链接的解决方案,包括关闭CGO、使用静态库,以及处理依赖cgo的外部包的静态链接问题。

  • 二进制文件优化:我们还介绍了如何通过去除符号表和使用TinyGo等方法来优化生成的Go二进制文件的大小,以满足不同场景下的需求。

  • 符号编译与死码消除:最后,我们探讨了未使用的符号是否会被编译到最终的二进制文件中,并解释了Go编译器的死码消除机制。

通过这些细节探讨,我希望能够帮助大家更好地理解Go编译的复杂性,并在实际开发中做出更明智的选择,亦能在面对Go编译相关问题时,提供有效的解决方案。

本文涉及的源码可以在这里下载。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

从简单到强大:再次探索Caddy服务器的魅力

本文永久链接 – https://tonybai.com/2024/11/07/exploring-caddy

Go语言诞生十多年来,社区涌现出众多优秀的Web服务器和反向代理解决方案。其中,最引人注目的无疑是CaddyTraefik。这两者都为开发者和系统管理员提供了更简单、更安全的现代化Web服务器和反向代理部署选项。尽管它们的目标略有不同,Caddy最初旨在满足开发者快速搭建反向代理的需求,特别关注配置的简易性,并在后期增加了自动HTTPS和全面的API支持;而Traefik则更强调云原生架构,适合基于微服务的应用,尤其是使用Docker或Kubernetes部署的场景,提供动态服务发现和灵活的路由能力。

我于2015年首次体验了开源发布的Caddy,其超简单的配置确实给我留下了深刻的印象。之后也一直关注着Caddy的发展,Caddy在支持通过ACME协议自动为服务的域名获取免费HTTPS证书的功能后,Caddy就被我部署在自己的VPS上,为Gopher Daily等站点提供反向代理服务,运行十分稳定。Caddy这一为域名自动获取免费HTTPS证书的功能是其简化站点部署初衷的延续,也为Caddy赢得的广泛的用户和赞誉,并且这一特性不仅使得Caddy在个人项目和小型部署中大受欢迎,也让它在企业级应用中占有一席之地。

近10年后,我打算在这篇文章中再次探索一下Caddy,了解一下如今的Caddy都提供哪些强大的功能特性,为后续更好地使用Caddy做铺垫。

注:Caddy发展了近10年,支持了很多标准特性以及非标准特性(由社区提供,caddy官方不提供保证和support),这里仅就笔者感兴趣的特性做探索。目前Caddy依靠sponsor的赞助进行着可持续演进,其所有标准功能都是免费的,但其作者Matt Holt也会为企业级赞助商进行定制功能开发。

1. Caddy的运行方法与基本配置

1.1 Caddy的启停

Caddy使用Go开发,因此继承了Go应用部署的一贯特点:只有一个可执行文件。将下载的Caddy放到\$PATH路径下,我们就可以在任意目录下执行它了:

$caddy version
v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=

$caddy run
2024/10/11 07:56:24.664 INFO    admin   admin endpoint started  {"address": "localhost:2019", "enforce_origin": false, "origins": ["//127.0.0.1:2019", "//localhost:2019", "//[::1]:2019"]}

这么启动后,caddy就会作为一个前台进程一直运行着,直到你停掉它。当然,我们也可以使用start命令将caddy作为后台进程启动:

$caddy start
2024/10/11 08:32:07.557 INFO    admin   admin endpoint started  {"address": "localhost:2019", "enforce_origin": false, "origins": ["//127.0.0.1:2019", "//localhost:2019", "//[::1]:2019"]}
2024/10/11 08:32:07.557 INFO    serving initial configuration
Successfully started Caddy (pid=31215) - Caddy is running in the background

使用stop命令可以停到该后台进程:

$caddy stop
2024/10/11 08:32:37.043 INFO    admin.api   received request    {"method": "POST", "host": "localhost:2019", "uri": "/stop", "remote_ip": "127.0.0.1", "remote_port": "65178", "headers": {"Accept-Encoding":["gzip"],"Content-Length":["0"],"Origin":["http://localhost:2019"],"User-Agent":["Go-http-client/1.1"]}}
2024/10/11 08:32:37.043 WARN    admin.api   exiting; byeee!!
2024/10/11 08:32:37.043 INFO    admin   stopped previous server {"address": "localhost:2019"}
2024/10/11 08:32:37.043 INFO    admin.api   shutdown complete   {"exit_code": 0}

1.2 使用Caddyfile配置站点信息

不过如此启动后的caddy并没有什么卵用,因为没有任何关于站点的配置信息。但caddy提供了config API(默认使用2019端口),我们可以使用下面方式访问该API:

$curl localhost:2019/config/
null

由于没有任何配置数据,该接口返回null。Caddy提供了强大的API可以在Caddy运行是动态设置站点配置信息,这个我们后续再说,因为首次使用Caddy时,开发者通常更愿意使用Caddyfile来提供初始配置信息,Caddyfile也是最初caddy开源时唯一支持的配置方式。我们以server1.com为例来看看在本地使用caddy为其建立反向代理有多简单。下面是Caddyfile的内容:

server1.com {
    tls internal
    reverse_proxy localhost:9001
}

然后我们基于该Caddyfile启动caddy,如果不显式传入配置文件,caddy默认使用当前目录(cwd)下的Caddyfile作为配置文件:

$caddy run
2024/10/11 08:49:36.916 INFO    using adjacent Caddyfile
2024/10/11 08:49:36.920 INFO    adapted config to JSON  {"adapter": "caddyfile"}
2024/10/11 08:49:36.926 INFO    admin   admin endpoint started  {"address": "localhost:2019", "enforce_origin": false, "origins": ["//localhost:2019", "//[::1]:2019", "//127.0.0.1:2019"]}
2024/10/11 08:49:36.928 INFO    tls.cache.maintenance   started background certificate maintenance  {"cache": "0xc0005add80"}
2024/10/11 08:49:36.936 INFO    http.auto_https server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS {"server_name": "srv0", "https_port": 443}
2024/10/11 08:49:36.936 INFO    http.auto_https enabling automatic HTTP->HTTPS redirects    {"server_name": "srv0"}
2024/10/11 08:49:36.964 WARN    pki.ca.local    installing root certificate (you might be prompted for password)    {"path": "storage:pki/authorities/local/root.crt"}
2024/10/11 08:49:37.024 INFO    warning: "certutil" is not available, install "certutil" with "brew install nss" and try again
2024/10/11 08:49:37.024 INFO    define JAVA_HOME environment variable to use the Java trust
Password:
2024/10/11 08:49:41.629 INFO    certificate installed properly in macOS keychain
2024/10/11 08:49:41.629 INFO    http    enabling HTTP/3 listener    {"addr": ":443"}
2024/10/11 08:49:41.632 INFO    http.log    server running  {"name": "srv0", "protocols": ["h1", "h2", "h3"]}
2024/10/11 08:49:41.632 INFO    http.log    server running  {"name": "remaining_auto_https_redirects", "protocols": ["h1", "h2", "h3"]}
2024/10/11 08:49:41.632 INFO    http    enabling automatic TLS certificate management   {"domains": ["server1.com"]}
2024/10/11 08:49:41.656 INFO    tls cleaning storage unit   {"storage": "FileStorage:/Users/tonybai/Library/Application Support/Caddy"}
2024/10/11 08:49:41.656 INFO    autosaved config (load with --resume flag)  {"file": "/Users/tonybai/Library/Application Support/Caddy/autosave.json"}
2024/10/11 08:49:41.656 INFO    serving initial configuration
2024/10/11 08:49:41.657 INFO    tls finished cleaning storage units
2024/10/11 08:49:41.657 INFO    tls.obtain  acquiring lock  {"identifier": "server1.com"}
2024/10/11 08:49:41.676 INFO    tls.obtain  lock acquired   {"identifier": "server1.com"}
2024/10/11 08:49:41.676 INFO    tls.obtain  obtaining certificate   {"identifier": "server1.com"}
2024/10/11 08:49:41.684 INFO    tls.obtain  certificate obtained successfully   {"identifier": "server1.com", "issuer": "local"}
2024/10/11 08:49:41.685 INFO    tls.obtain  releasing lock  {"identifier": "server1.com"}
2024/10/11 08:49:41.686 WARN    tls stapling OCSP   {"error": "no OCSP stapling for [server1.com]: no OCSP server specified in certificate", "identifiers": ["server1.com"]}

这段日志“信息量”很大,我们后面一点点来看。现在我们先验证一下caddy启动后是否能成功访问到server1.com这个“站点”,拓扑图如下:

server1.com的程序如下:

// server1.go
package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello, server1.com")
}

func main() {
    http.HandleFunc("/", handler)
    fmt.Println("Server is listening on port 9001...")
    if err := http.ListenAndServe("localhost:9001", nil); err != nil {
        fmt.Println("Error starting server:", err)
    }
}

启动server1后,我们使用curl访问server1.com(注:请先将server1.com放入/etc/hosts中,映射到本地127.0.0.1):

$go run server1.go
$curl https://server1.com
hello, server1.com

是不是非常简单 – 短短几行配置就能在本地搭建出一个可以测试https站点的环境

1.3 Caddyfile背后的那些事儿

现在是时候基于上面caddy run之后输出的日志以及Caddyfile的内容来说说caddy的一些运行机制了。

首先,当前版本的Caddy的默认配置信息格式已经不再是我们在Caddyfile中看到的那样了,而是改为了json格式。虽然上面我们是基于Caddyfile启动的caddy,但实际上caddy程序会在内部启用caddyfile adapt,将Caddyfile的格式转换为json格式后,再作为配置信息提供给caddy的后续逻辑:

比如上面的Caddyfile被转换为json后的配置如下:

{
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [
            ":443"
          ],
          "routes": [
            {
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "handler": "reverse_proxy",
                          "upstreams": [
                            {
                              "dial": "localhost:9001"
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ],
              "match": [
                {
                  "host": [
                    "server1.com"
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    },
    "tls": {
      "automation": {
        "policies": [
          {
            "issuers": [
              {
                "module": "internal"
              }
            ],
            "subjects": [
              "server1.com"
            ]
          }
        ]
      }
    }
  }
}

当然caddy也支持直接将该json格式配置作为启动时所需的初始配置文件:

$caddy run --config caddy.json

即便是基于Caddyfile启动,caddy也会将当前配置自动保存起来(以下是macOS下启动caddy的日志):

2024/10/11 08:49:41.656 INFO    autosaved config (load with --resume flag)  {"file": "/Users/tonybai/Library/Application Support/Caddy/autosave.json"}

注:linux上caddy默认保存config的位置为/var/lib/caddy/.config/caddy/autosave.json。

正如日志中所提到的,下次启动时如果带上了–resume标志位,Caddy会基于自动保存的json配置文件启动!

如果caddy启动时带有–resume标志位,但在指定路径下找不到autosave.json时,它就会基于当前目录下的Caddyfile启动,除非使用–config指定配置文件。

在Caddyfile的server1.com site block中,我们使用tls directive

server1.com {
    tls internal
    reverse_proxy localhost:9001
}

tls directive的值是internal,意味着使用Caddy的内部、本地受信任的CA为本站点生成证书。Caddy会在本地创建自签的CA(默认名字是local),并会尝试将自建的CA根证书安装到系统信任存储区,当以非特权用户运行Caddy时,可能会让你输入sudo用户的密码。接下来,Caddy就会用该CA为像server1.com这样的域名签发证书了。在macOS的用户的Library/Application Support/Caddy下我们能看到CA相关和为站点域名生成的相关私钥和证书:

➜  /Users/tonybai/Library/Application Support/Caddy git:(master) ✗ $tree
.
├── autosave.json
├── certificates
│   └── local
│       └── server1.com
│           ├── server1.com.crt
│           ├── server1.com.json
│           └── server1.com.key
├── instance.uuid
├── last_clean.json
├── locks
└── pki
    └── authorities
        └── local
            ├── intermediate.crt
            ├── intermediate.key
            ├── root.crt
            └── root.key

1.4 四层代理配置和grpc

日常工作中,除了http/https代理,还有两个最常见的反向代理和负载均衡配置,一个是纯四层的Raw TCP和UDP,另外一个则是RPC(以gRPC最为广泛)。那么Caddy对这两种情况支持的如何呢?我们接下来就来看看。

1.4.1 Raw TCP和UDP

Caddy正式版目前不支持四层反向代理和负载均衡,但通过一些插件可以支持,其中mholt/caddy-l4是其中最著名的,这也是由Caddy作者建立的项目,但目前还处于WIP状态,可以体验,但不建议用于生产环境

由于Caddy是Go实现的,Go对插件实现的方案方面不是很友好,Caddy采用了重新编译的方案,但提供了名为xcaddy的构建工具可以十分方便的支持带有插件的caddy编译,这也算将Go在编译方面的优势充分利用了起来了。

如果本地已经安装了go,那么安装xcaddy十分方便:

$go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
go: downloading github.com/caddyserver/xcaddy v0.4.2
go: downloading github.com/Masterminds/semver/v3 v3.2.1
go: downloading github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
go: downloading github.com/josephspurrier/goversioninfo v1.4.0
go: downloading github.com/akavel/rsrc v0.10.2

接下来,我们就以用xcaddy编译带有mholt/caddy-l4插件了,这个过程大约持续1-2分钟吧,主要是下载依赖包耗时较长:

$xcaddy build --with github.com/mholt/caddy-l4
2024/10/11 12:31:46 [INFO] absolute output file path: /Users/tonybai/caddy
2024/10/11 12:31:46 [INFO] Temporary folder: /Users/tonybai/buildenv_2024-10-17-1231.4160508500
2024/10/11 12:31:46 [INFO] Writing main module: /Users/tonybai/buildenv_2024-10-17-1231.4160508500/main.go
package main

import (
    caddycmd "github.com/caddyserver/caddy/v2/cmd"

    // plug in Caddy modules here
    _ "github.com/caddyserver/caddy/v2/modules/standard"
    _ "github.com/mholt/caddy-l4"
)

func main() {
    caddycmd.Main()
}
2024/10/11 12:31:46 [INFO] Initializing Go module
2024/10/11 12:31:46 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go mod init caddy
go: creating new go.mod: module caddy
go: to add module requirements and sums:
    go mod tidy
2024/10/11 12:31:46 [INFO] Pinning versions
2024/10/11 12:31:46 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go get -d -v github.com/caddyserver/caddy/v2
go: -d flag is deprecated. -d=true is a no-op
go: downloading github.com/caddyserver/caddy v1.0.5
go: downloading github.com/caddyserver/caddy/v2 v2.8.4
go: downloading github.com/caddyserver/certmagic v0.21.3
go: downloading github.com/prometheus/client_golang v1.19.1
go: downloading github.com/quic-go/quic-go v0.44.0
go: downloading github.com/cespare/xxhash v1.1.0
go: downloading go.uber.org/zap/exp v0.2.0
go: downloading golang.org/x/term v0.20.0
go: downloading golang.org/x/time v0.5.0
go: downloading go.uber.org/multierr v1.11.0
... ...
go: added golang.org/x/term v0.20.0
go: added golang.org/x/text v0.15.0
go: added golang.org/x/time v0.5.0
go: added golang.org/x/tools v0.21.0
go: added google.golang.org/protobuf v1.34.1
2024/10/11 12:31:53 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go get -d -v github.com/mholt/caddy-l4 github.com/caddyserver/caddy/v2
go: -d flag is deprecated. -d=true is a no-op
go: downloading github.com/mholt/caddy-l4 v0.0.0-20241012124037-5764d700c21c
go: accepting indirect upgrade from github.com/google/pprof@v0.0.0-20231212022811-ec68065c825e to v0.0.0-20240207164012-fb44976bdcd5
go: accepting indirect upgrade from github.com/miekg/dns@v1.1.59 to v1.1.62
go: accepting indirect upgrade from github.com/onsi/ginkgo/v2@v2.13.2 to v2.15.0
go: accepting indirect upgrade from golang.org/x/crypto@v0.23.0 to v0.28.0
go: accepting indirect upgrade from golang.org/x/mod@v0.17.0 to v0.18.0
go: accepting indirect upgrade from golang.org/x/net@v0.25.0 to v0.30.0
... ...
go: upgraded golang.org/x/sys v0.20.0 => v0.26.0
go: upgraded golang.org/x/term v0.20.0 => v0.25.0
go: upgraded golang.org/x/text v0.15.0 => v0.19.0
go: upgraded golang.org/x/time v0.5.0 => v0.7.0
go: upgraded golang.org/x/tools v0.21.0 => v0.22.0
2024/10/11 12:32:10 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go get -d -v
go: -d flag is deprecated. -d=true is a no-op
go: downloading github.com/go-chi/chi/v5 v5.0.12
go: downloading gopkg.in/natefinch/lumberjack.v2 v2.2.1
go: downloading github.com/fxamacker/cbor/v2 v2.6.0
go: downloading github.com/google/go-tpm v0.9.0
... ...
go: downloading github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd331745
go: downloading github.com/go-logr/stdr v1.2.2
go: downloading github.com/cenkalti/backoff/v4 v4.2.1
go: downloading github.com/grpc-ecosystem/grpc-gateway/v2 v2.18.0
2024/10/11 12:32:15 [INFO] Build environment ready
2024/10/11 12:32:15 [INFO] Building Caddy
2024/10/11 12:32:15 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go mod tidy -e
go: downloading github.com/onsi/gomega v1.30.0
... ...
go: downloading golang.org/x/oauth2 v0.20.0
go: downloading cloud.google.com/go/auth/oauth2adapt v0.2.2
go: downloading github.com/google/s2a-go v0.1.7
go: downloading cloud.google.com/go/compute/metadata v0.3.0
go: downloading cloud.google.com/go/compute v1.24.0
go: downloading go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0
go: downloading github.com/googleapis/enterprise-certificate-proxy v0.3.2
2024/10/11 12:32:31 [INFO] exec (timeout=0s): /Users/tonybai/.bin/go1.23.0/bin/go build -o /Users/tonybai/caddy -ldflags -w -s -trimpath -tags nobadger
2024/10/11 12:33:22 [INFO] Build complete: ./caddy
2024/10/11 12:33:22 [INFO] Cleaning up temporary folder: /Users/tonybai/buildenv_2024-10-17-1231.4160508500

././caddy version
v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=

编译后得到的caddy放在当前目录下:

$./caddy version
v2.8.4 h1:q3pe0wpBj1OcHFZ3n/1nl4V4bxBrYoSoab7rL9BMYNk=

为了与原先的caddy做区分,我们将新编译出来的caddy重命名为caddy-with-l4。下面我们就来看一个四层负载均衡的示例,先看一下Caddyfile的配置:

{
    layer4 {
        127.0.0.1:5000 {
            route {
                proxy localhost:9003 localhost:9004 {
                    lb_policy round_robin
                }
            }
        }
    }
}

这个配置非常好理解!如下面示意图,caddy将来自客户端到5000端口的连接按照round robin负载均衡算法分配到后面的两个服务localhost:9003和localhost:9004上:

看完TCP,我们再来看看UDP的反向代理的例子,我们修改一下Caddyfile:

{
    layer4 {
        udp/127.0.0.1:5000 {
            route {
                proxy udp/localhost:9005 udp/localhost:9006 {
                    lb_policy round_robin
                }
            }
        }
    }
}

这个配置同样非常好理解!如下面示意图,caddy将来自客户端到5000端口的udp连接按照round robin负载均衡算法分配到后面的两个服务localhost:9005和localhost:9006上:

注:关于上面两个tcp和udp的示例的client端和server端的代码,可以在github.com/bigwhite/experiments下的caddy-examples中找到,这里鉴于篇幅,就不贴出来了。

接下来,我们再看看RPC。

1.4.2 RPC

我们以最为流行的gRPC为例,来看看如何配置Caddy,试验拓扑如下:

请提前将rpc-server.com配置到/etc/hosts中,ip为localhost。然后,根据上面拓扑图,我们将Caddyfile更新为下面内容:

rpc-server.com {
    tls internal
    reverse_proxy h2c://localhost:9007 h2c://localhost:9008
}

gRPC使用HTTP/2帧,h2c://可以确保后端启用明文HTTP/2。

注:关于gRPC的grpc-client、grpc-server1和grpc-server2的代码,可以在github.com/bigwhite/experiments下的caddy-examples的rpc目录中找到,这里鉴于篇幅,就不贴出来了。

到这里,关于Caddy的运行方法以及针对各种协议的基本配置方法已经初步探索完了,接下来我们再来看一下Caddy的另一个强大的功能:基于API的运行时动态配置。

2. 运行时使用API对Caddy进行动态配置

Caddy提供了admin和config API,允许我们在运行时动态配置和管理服务器。前面提到过,Caddy默认的API端口和路径是http://localhost:2019/config/。不过,需要注意的是:通过API设置的路由配置仅存储在内存中,并未持久化。这意味着当Caddy服务器重启后,如果没有使用–resume恢复autosave.json中的配置,那么之前通过API进行的各种设置将失效。

在Caddy提供的API中,我们最关心的还是与服务器(server)、路由(routes)、处理器(handle)以及匹配器(match)的设置,以下面Caddyfile所表示的https服务器设置为例:

server1.com {
    tls internal
    reverse_proxy localhost:9001
}
server2.com {
    tls internal
    reverse_proxy localhost:9002 localhost:9012
}

该Caddyfile对应的拓扑图如下:

该Caddyfile转换为JSON格式后的配置数据如下:

{
  "apps": {
    "http": {
      "servers": {
        "srv0": {
          "listen": [
            ":443"
          ],
          "routes": [
            {
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "handler": "reverse_proxy",
                          "upstreams": [
                            {
                              "dial": "localhost:9001"
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ],
              "match": [
                {
                  "host": [
                    "server1.com"
                  ]
                }
              ],
              "terminal": true
            },
            {
              "handle": [
                {
                  "handler": "subroute",
                  "routes": [
                    {
                      "handle": [
                        {
                          "handler": "reverse_proxy",
                          "upstreams": [
                            {
                              "dial": "localhost:9002"
                            },
                            {
                              "dial": "localhost:9012"
                            }
                          ]
                        }
                      ]
                    }
                  ]
                }
              ],
              "match": [
                {
                  "host": [
                    "server2.com"
                  ]
                }
              ],
              "terminal": true
            }
          ]
        }
      }
    },
    "tls": {
      "automation": {
        "policies": [
          {
            "issuers": [
              {
                "module": "internal"
              }
            ],
            "subjects": [
              "server1.com",
              "server2.com"
            ]
          }
        ]
      }
    }
  }
}

其中,我们关注的服务器(server)、路由(routes)、处理器(handle)和匹配器(match)之间的隶属关系如下图,其他配置将由Caddy自动完成:

接下来,我们就基于这个示例,来看看通过Caddy API如何完成一些常见的站点设置操作。

2.1 POST /load

我们先看看整体替换的POST /load接口。通过该接口,我们可以用新的Caddy配置整体覆盖当前生效的Caddy配置,Caddy收到这个请求后,会阻塞住该调用,直到新配置加载完成或加载失败才会返回。如果加载失败,Caddy会回滚之前的配置。与caddy reload命令一样,该接口可以实现不停机更新并生效配置,无论是加载成功还是加载失败回滚。

下面我们修改一下上面json,将server2.com路由中的那个监听9012的upstream server去掉,并保存为caddy-load.json。如果担心自己修改的配置信息不正确,可以在调用接口之前,先用caddy validate对caddy-load.json进行有效性检查:

$caddy validate -c caddy-load.json
2024/10/11 02:50:28.649 INFO    using config from file  {"file": "caddy-load.json"}
2024/10/11 02:50:28.651 INFO    tls.cache.maintenance   started background certificate maintenance  {"cache": "0xc00012dd00"}
2024/10/11 02:50:28.652 INFO    http.auto_https server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS {"server_name": "srv0", "https_port": 443}
2024/10/11 02:50:28.652 INFO    http.auto_https enabling automatic HTTP->HTTPS redirects    {"server_name": "srv0"}
2024/10/11 02:50:28.652 INFO    tls.cache.maintenance   stopped background certificate maintenance  {"cache": "0xc00012dd00"}
Valid configuration

然后用下面curl命令调用load接口尝试新配置加载:

$curl "http://localhost:2019/load" \
    -H "Content-Type: application/json" \
    -d @caddy-load.json

此时Caddy会输出类似如下日志:

2024/10/11 02:53:15.191 INFO    admin.api   received request    {"method": "POST", "host": "localhost:2019", "uri": "/load", "remote_ip": "127.0.0.1", "remote_port": "60898", "headers": {"Accept":["*/*"],"Content-Length":["1968"],"Content-Type":["application/json"],"Expect":["100-continue"],"User-Agent":["curl/7.54.0"]}}
2024/10/11 02:53:15.226 INFO    admin   admin endpoint started  {"address": "localhost:2019", "enforce_origin": false, "origins": ["//[::1]:2019", "//127.0.0.1:2019", "//localhost:2019"]}
2024/10/11 02:53:15.240 INFO    http.auto_https server is listening only on the HTTPS port but has no TLS connection policies; adding one to enable TLS {"server_name": "srv0", "https_port": 443}
2024/10/11 02:53:15.240 INFO    http.auto_https enabling automatic HTTP->HTTPS redirects    {"server_name": "srv0"}
2024/10/11 02:53:15.254 INFO    pki.ca.local    root certificate is already trusted by system   {"path": "storage:pki/authorities/local/root.crt"}
2024/10/11 02:53:15.256 INFO    http    enabling HTTP/3 listener    {"addr": ":443"}
2024/10/11 02:53:15.257 INFO    http.log    server running  {"name": "srv0", "protocols": ["h1", "h2", "h3"]}
2024/10/11 02:53:15.257 INFO    http.log    server running  {"name": "remaining_auto_https_redirects", "protocols": ["h1", "h2", "h3"]}
2024/10/11 02:53:15.257 INFO    http    enabling automatic TLS certificate management   {"domains": ["server1.com", "server2.com"]}
2024/10/11 02:53:15.257 INFO    http    servers shutting down with eternal grace period
2024/10/11 02:53:15.258 INFO    autosaved config (load with --resume flag)  {"file": "/Users/tonybai/Library/Application Support/Caddy/autosave.json"}
2024/10/11 02:53:15.258 INFO    admin.api   load complete
2024/10/11 02:53:15.263 INFO    admin   stopped previous server {"address": "localhost:2019"}

更新后,你可以通过config API或autosaved.json查看变更后的配置,也可以通过测试验证新配置是否生效。

不过,这种整体替换显然更容易失败,如果Caddy代理的站点路由很多,json文件的Size也不可小觑。此外,要维护全量的配置,还要对Caddy的配置有较为系统的了解。在日常维护中,按配置路径更新局部配置更为实用一些,接下来我们就来看看如何基于配置路径管理服务器(server)、路由(routes)、处理器(handle)以及匹配器(match)的设置。

2.2 /config/[path]

通过在config后面加上要操作的配置路径,我们可以读取和更新对应路径上的配置信息。

2.2.1 读取特定路径下的配置

使用Http Get请求,可以读取在/config后面的指定路径上的配置。

  • 读取全部
$curl "http://localhost:2019/config/"
  • 读取所有服务器(server)配置
$curl "http://localhost:2019/config/apps/http/servers"
{"srv0":{"listen":[":443"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9001"}]}]}]}],"match":[{"host":["server1.com"]}],"terminal":true},{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9002"},{"dial":"localhost:9012"}]}]}]}],"match":[{"host":["server2.com"]}],"terminal":true}]}}
  • 读取某个服务器(server)的配置

以srv0为例:

$curl "http://localhost:2019/config/apps/http/servers/srv0"
{"listen":[":443"],"routes":[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9001"}]}]}]}],"match":[{"host":["server1.com"]}],"terminal":true},{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9002"},{"dial":"localhost:9012"}]}]}]}],"match":[{"host":["server2.com"]}],"terminal":true}]}
  • 读取srv0的listen配置
$curl "http://localhost:2019/config/apps/http/servers/srv0/listen/"
[":443"]
  • 读取srv0的所有路由
$curl "http://localhost:2019/config/apps/http/servers/srv0/routes/"
[{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9001"}]}]}]}],"match":[{"host":["server1.com"]}],"terminal":true},{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9002"},{"dial":"localhost:9012"}]}]}]}],"match":[{"host":["server2.com"]}],"terminal":true}]

路由是一个数组,要读取某个路由,可以使用数组下标,比如:

$curl "http://localhost:2019/config/apps/http/servers/srv0/routes/0/"
{"handle":[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9001"}]}]}]}],"match":[{"host":["server1.com"]}],"terminal":true}
  • 读取某路由的handle和match
$curl "http://localhost:2019/config/apps/http/servers/srv0/routes/0/handle/"
[{"handler":"subroute","routes":[{"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:9001"}]}]}]}]

$curl "http://localhost:2019/config/apps/http/servers/srv0/routes/0/match/"
[{"host":["server1.com"]}]

我们看到,就像上面这样按配置路径逐步细化,便可以读取到所有对应的配置,遇到数组类型,可以使用下标读取对应的“数组元素”的配置。

接下来,我们再来看看基于路径的配置修改方法。

2.2.2 更新特定路径下的配置

使用Http Post请求,可以创建或更新在/config后面的指定路径上的配置。如果指定路径对应的配置目标为一个数组,则POST会将json作为元素追加到数组中;如果目标是一个对象,则post会基于json信息创建新对象或更新对象。

我们先以apps/http/servers/srv0/listen/这个数组对象为例,为其添加一个新元素”:80″:

$curl -H "Content-Type: application/json" -d '":80"' "http://localhost:2019/config/apps/http/servers/srv0/listen"

成功之后,我们可以看到listen数组的变化:

$curl "http://localhost:2019/config/apps/http/servers/srv0/listen"
[":443",":80"]

如果是要更改某个数组元素,我们可以使用PATCH请求,比如将刚刚创建的”:80″改为”:90″:

$curl -X PATCH -H "Content-Type: application/json" -d '":90"' "http://localhost:2019/config/apps/http/servers/srv0/listen/1"
$curl "http://localhost:2019/config/apps/http/servers/srv0/listen"
[":443",":90"]

如果要删除刚才添加的数组元素,可以使用DELETE请求,根据下标值路径进行删除:

$curl -X DELETE  "http://localhost:2019/config/apps/http/servers/srv0/listen/1"
$curl "http://localhost:2019/config/apps/http/servers/srv0/listen"
[":443"]

下面我们来添加一个srv1对象,与上面的srv0并齐:

$curl -H "Content-Type: application/json" -d '{ "listen" : [":444"]}' "http://localhost:2019/config/apps/http/servers/srv1/"

创建后,我们得到下面配置:

$curl  "http://localhost:2019/config/apps/http/servers/" | gojq
{
  "srv0": {
    "listen": [
      ":443"
    ],
    "routes": [
      ... ...
    ]
  },
  "srv1": {
    "listen": [
      ":444"
    ]
  }
}

但我们不能这么创建:

$curl -H "Content-Type: application/json" -d '{ "srv1" : { "listen" : [":444"]}}' "http://localhost:2019/config/apps/http/servers/"

这样会覆盖掉servers的全部信息,整个servers信息将变为:

$curl  "http://localhost:2019/config/apps/http/servers/" | gojq
{
  "srv1": {
    "listen": [
      ":444"
    ]
  }
}

2.3 @id

虽然通过上面指定路径可以获取和更新对应的配置,但我们也看到了Caddy的json的缩进非常深,这给API的调用者带来了心智负担。Caddy提供了一种强大而灵活的方式来快速访问和修改配置中的特定部分,这就是使用@id标识符。通过在配置中为某些元素分配唯一的@id,我们可以直接引用这些元素,而无需指定完整的路径。这在处理复杂配置或需要频繁修改特定部分时特别有用。

在Caddy的配置中,@id可以应用于多个层次的配置元素。具体来说,在apps/http/servers下的各个层次都支持@id,包括但不限于:

  • 服务器(server)级别
  • 路由(routes)级别
  • 处理器(handle)级别
  • 匹配器(match)级别

下面让我们通过具体的例子来看看如何在这些不同的层次上使用@id。由于Caddyfile不支持@id,我们将使用新的配置作为示例:

我们建立一个新的json作为Caddy的启动配置文件:

{
  "apps": {
    "http": {
      "servers": {
        "myserver": {
          "@id": "main_server",
          "listen": [
            ":80"
          ],
          "routes": [
            {
              "@id": "main_route",
              "handle": [
                {
                  "@id": "main_handler",
                  "body": "Hello from main server!",
                  "handler": "static_response"
                }
              ],
              "match": [
                {
                  "@id": "path_matcher",
                  "path": [
                    "/api/*"
                  ]
                }
              ]
            }
          ]
        }
      }
    }
  }
}

我们先看看服务器级别的@id使用。在这里我们为myserver这个服务器赋予了一个新的@id字段,值为main_server,接下来,我们就可以使用下面路径获取和更新该server的配置信息:

$curl  "http://localhost:2019/id/main_server"
{"@id":"main_server","listen":[":80"],"routes":[{"handle":[{"body":"Hello from main server!","handler":"static_response"}]}]}

$curl  "http://localhost:2019/id/main_server/listen"
[":80"]

同理,在路由级别,我们也为为其中的一个路由设置了@id字段,值为main_route,通过下面命令便可以获取和更新该路由信息:

$curl  "http://localhost:2019/id/main_route/"
{"@id":"main_route","handle":[{"@id":"main_handler","body":"Hello from main server!","handler":"static_response"}],"match":[{"@id":"path_matcher","path":["/api/*"]}]}

$curl  "http://localhost:2019/id/main_route/handle"
[{"@id":"main_handler","body":"Hello from main server!","handler":"static_response"}]

通过handle(处理器)级别的@id,我们同样可以直接访问@id对应的对象的信息:

$curl  "http://localhost:2019/id/main_handler/"
{"@id":"main_handler","body":"Hello from main server!","handler":"static_response"}

$curl  "http://localhost:2019/id/main_handler/body"
"Hello from main server!"

最后是通过@id访问matcher:

$curl  "http://localhost:2019/id/path_matcher/"
{"@id":"path_matcher","path":["/api/*"]}

$curl  "http://localhost:2019/id/path_matcher/path"
["/api/*"]

我们看到:使用@id方式,我们可以像一个使用指针或传送点那样,直达特定路径下面,而无需一层一层的输入路径信息。在处理大型或复杂的配置时,它为管理员和开发者提供了一种更灵活、更直观的方式来操作Caddy的配置。

3. 生产环境的实践与ACME

最后我们来简单说说在生产环境使用Caddy的一些实践方法。

3.1 生产环境的Caddy配置方法

前面说了那么多的Caddy配置方法,那么在生产环境究竟应该使用哪种方法来进行Caddy的初始配置、运行时动态配置更新以及配置的持久化呢?

虽然Caddyfile简单,但如果要在生产环境中进行运行时的动态配置更新,json格式才是不二之选,我们首先可以基于标准格式准备一份json的初始配置作为caddy的初始启动配置,这个配置后续就可以不再使用了。

启动caddy时建议使用–resume,初始情况下因为还没有autosaved.json,caddy会基于初始配置启动,之后重启caddy都会基于autosaved.json启动。

而运行时,我们可直接基于API对caddy的配置进行修改,所有的修改都会立即生效,而且无需停机,并且配置变更会save到autosave.json中,即便caddy重启,下一次启动时caddy也会加载停机前的最新配置,而这一切都不需要我们干预。

3.2 自动HTTPS与ACME

在生产环境使用Caddy,除了其超级简单的配置和相对不错的性能之外,最主要就要用它的自动https,即自动为代理的站点域名从Let’s Encryptzerossl申请受信任的免费证书,并可以在证书过期前自动更新证书。Caddy是通过ACME协议与这两个站点进行交互并获取和维护证书的。

ACME协议是一个用于自动化数字证书管理的协议。它允许服务器或客户端软件自动向证书颁发机构 (CA) 请求、更新和撤销SSL/TLS证书。ACME协议的优势在于减少了人为错误,支持短期证书,提高了证书安全性,同时由于支持自动化,让大规模证书部署和管理成为可能。

该协议最早在2015年由Let’s Encrypt推出,旨在推广HTTPS,并使证书管理自动化和标准化。

ACME的API版本有两个,API v1规范于2016年发布。它支持为完全限定的域名颁发证书,例如example.com或cluster.example.com,但不支持*.example.com等通配符证书。API v2规范于2018年发布,被称为ACME v2,ACME v2不向后兼容v1。v2版本支持通配符域名证书,例如*.example.com。同时新增新的挑战(challenge)类型TLS-ALPN-01。

IETF在2019年正式将ACME作为标准协议发布(RFC 8555)。2021年,ACME v1版本废弃,不再提供支持。

ACME协议的主要组件包括客户端、ACME服务器(如Let’s Encrypt或ZeroSSL)、挑战机制(Challenges)以及证书颁发流程。客户端首先向ACME服务器请求证书,服务器通过挑战机制要求客户端证明对域名的控制权,验证通过后颁发证书。这里最复杂的就是挑战机制了。

Caddy Server支持以下ACME 挑战机制:

  • HTTP Challenge

CA机构执行该挑战时会对候选主机名的A/AAAA记录执行权威DNS查找,然后在端口80上使用HTTP请求一个临时的加密资源。如果CA(证书颁发机构)看到了预期的资源,则会颁发证书。该挑战机制要求端口80必须对外部可访问。在Caddy中,此挑战机制默认启用且无需显式配置。

  • TLS-ALPN Challenge

CA机构执行该挑战时会对候选主机名的A/AAAA记录执行权威DNS查找,然后在端口443上使用一个包含特殊ServerName和ALPN值的TLS握手请求临时的加密资源。如果CA看到了预期的资源,则会颁发证书。该挑战机制要求端口443必须对外部可访问。在Caddy中,此挑战机制也是默认启用的,且无需显式配置。

  • DNS Challenge

CA机构执行该挑战时会对候选主机名的TXT记录执行权威DNS查找,并查找包含特定值的TXT记录。如果CA看到了预期的值,则会颁发证书。

该挑战机制的优点是无需开放任何端口,并且请求证书的服务器不需要对外部可访问。但需要Caddy配置访问候选主机域名的DNS提供商的凭据(api token),以便Caddy能够通过api设置(和清除)特殊的TXT记录。如果启用了DNS挑战,默认情况下其他挑战会被禁用。

这三种挑战机制在不同场景下都有各自的优势,Caddy默认启用HTTP和TLS-ALPN挑战,并在需要时会自动选择最成功的挑战类型来使用。同时Caddy也为DNS challenge提供了对各种DNS提供商的插件支持,这些插件可以在https://github.com/caddy-dns中查找。

Go在ACME方面有着广泛的应用,很多标准的ACME client以及服务端都是由go实现的,比如cert-manager等,甚至包括支撑let’s encrypt自身的服务都是基于Go实现的,即用于实现CA的boulder开源项目

4. 小结

在本文中,我们深入探索了Caddy服务器的强大功能与简便配置。Caddy以其独特的设计理念,简化了Web服务器和反向代理的搭建过程,尤其是在自动HTTPS证书管理和API支持方面表现突出。通过Caddyfile的简单配置,用户可以迅速部署安全的HTTPS站点,而无需繁琐的步骤。

此外,Caddy的动态配置能力使得在运行时调整服务器设置成为可能,极大提高了灵活性和管理效率。尽管Caddy目前在四层代理和负载均衡的支持上还有待增强,但通过插件的方式也为用户提供了扩展的可能性。

总之,Caddy不仅适合个人项目的快速搭建,也在企业级应用中展现出强大的稳定性和高效性。随着社区的不断发展和支持,Caddy将继续成为开发者和系统管理员的重要工具。

本文涉及的源码可以在这里下载。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats