从简单到强大:再次探索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

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

成为那个拿锤子的人

本文永久链接 – https://tonybai.com/2024/11/03/become-the-one-with-the-hammer

“当你有一个锤子时,每件事看起来都像一个钉子”,这句来自心理学家亚伯拉罕·马斯洛(没错!就是提出五层需求理论的那个马斯洛)的名言揭示了人们在掌握一种技能或工具时,很容易将其视作通用解决方案的倾向,在技术领域,这种倾向尤为明显。

同时这句话也常被用来描述人们对工具的过度依赖和思维的局限性。

在程序员圈子中,“语言战争(programming language war)”是一个永不过时的话题,而马斯洛的“锤子”观点在每一种新语言兴起并掀起波澜时,总会被用作“讥讽”该语言拥趸的“思想武器”,细数当前的主流语言,莫不如此:

  • 上世纪90年代初,随着图形用户界面(GUI)和大型软件开发而兴起的C++语言;
  • 上世纪90年代末至2000年初期,随着互联网的普及和企业应用程序需求增加而大火儿的Java语言;
  • 从2001年开始,特别是在微软.NET框架推出之后逐渐成为Windows应用开发新霸主的C#语言;
  • 2004年以后,随着Ruby on Rails框架的推出,而在Web应用开发领域变成网红且语法优雅的Ruby语言;
  • 2009年以后,刚发布就赢得TIOBE编程语言排行榜年度最佳语言,并在之后引领云原生时代的Go语言
  • 2010年诞生,从2016年开始连续8年霸榜Stackoverflow最受欢迎编程语言、打出“用Rust重写一切”的Rust语言

这些语言都有自己的高光时刻,语言拥趸们举起大锤到处砸钉子,伴随而来的是来自其他语言阵营的讥讽。以我最熟悉的Go为例,在Go 1.5版本实现自举并实现GC延迟大幅度下降后,Go社区迎来了快速发展。Go也开始飘了!Gopher们乃至Go团队开始了在各个领域积(四)极(处)探(出)索(击),除了云原生基础设施和服务、Cli和Web这几个主流领域之外,Go还进军了GUI、游戏、移动开发以及嵌入式系统等领域,这让Go语言一度也面临过与目前Rust相似的境遇和挑战,遭遇了一些质疑和嘲讽:

然而,这真的是一种糟糕的状态吗?手握大锤找钉子真的有错吗?让我们将视野从狭小的编程语言领域拓展到更广阔的其他领域。

我们先来看看汽车领域,如果说内燃机驱动技术和机械变速箱技术属于上一代成熟技术的话,那么基于锂电池和电动机的新能源驱动技术就是这个领域的“新锤子”,它也一直在被以丰田为代表的传统主机厂诟病。但以特斯拉为代表的的新能源车企是如何使用这柄锤子的呢?下面是特斯拉的产品发布历史:

  • 2008年 Roadster:特斯拉的首款量产电动车
  • 2012年 Model S:高档电动轿车,获得广泛好评,具有长续航和高性能,奠定了特斯拉在豪华车市场的地位。
  • 2015年 Model X:一款豪华电动SUV,以独特的鹰翼门设计和高度的安全性著称。
  • 2017年 Model 3:面向大众市场的紧凑型电动车,成为全球销量最高的电动车之一。
  • 2020年 Model Y:一款电动跨界SUV,基于Model 3平台,迅速赢得市场。
  • 2021年 Cybertruck:特斯拉的电动皮卡
  • 2022年 Tesla Semi:电动重型卡车,专注于运输行业的可持续性。

哦,没错!就像编程界一样,一旦他们拿到这柄锤子,也会到处找钉子:从轿车、SUV、皮卡到电动重卡,甚至国内一些新能源主机厂已经发布了几款概念版电动飞行汽车:

我们再来看看四轴或多轴无人机领域,随着大疆等厂商拿到这把锤子后,无人机的应用范围得到了极大的拓展。从最初的航拍工具和玩具,逐渐演变为物流配送的利器,甚至展望未来,它们有可能成为飞行汽车的一部分。此外,一些军工企业也开始将无人机用于战场,成为一种武器。

如今,大语言模型正成为新时代的”锤子”,从自然语言处理到代码生成,从内容创作到自动驾驶决策辅助,从寻找新蛋白质到新药研发等,正在重塑各个领域的工作方式。

到这里,我们看到每一种新技术的诞生,都像一把新锤子,重塑着所在领域的版图。它们不是简单的工具替换,而是带来了全新的思维方式和解决方案。现在,你还担心拿着锤子找钉子会遭到他人的“讥讽”吗?

在不断演变的科技世界,真正驱动变革的往往就是那些“拿锤子”的人。他们不只是拥有先进技术的工具,更重要的是,他们拥有通过这些工具改变世界的意愿。因此,找到并精通一项核心技术,就像获得了一把改变世界的锤子。这不是局限,而是机遇。重要的不是担心把所有问题都看成钉子,而是要深入理解你的”锤子”,保持开放的心态,发现新的应用场景,勇于尝试用锤子去创新性解决问题。

由此可见,本文开头处马斯洛的那句话在今天有了新的意义:成为那个拿锤子的人,意味着你有能力参与改变世界的进程。拥抱你手中的锤子吧,这是你的幸运!我们要做的就是善用这份幸运,创造更大的价值

作为程序员,我们需要挑选一柄锤子并握在手中,而Go是一个很好的候选。如果你觉得拥抱Go这柄锤子,那我推荐大家关注极客时间的“Go语言第一课”,这是一个很好的起点,帮助你入门Go语言并深入理解Go语言的精髓。

img{512x368}

同时,我的书籍《Go语言精进之路》也将为你提供更深入的知识和实用技巧。

img{512x368}

让我们一起在这条道路上不断探索,提升自我,以更好地应对未来的挑战!


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

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