起因
对于 Golang 的 net.Listen() 函数,如果你不强行指定 IPv4 或 IPv6 的话,在双栈系统上默认只会监听 IPv6 地址。比如,用 Golang 实现一个 HTTP 服务非常简单:
package main
import (
	"net/http"
)
type helloHandler struct{}
func (h *helloHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Hello, world!"))
}
func main() {
	http.Handle("/", &helloHandler{})
	http.ListenAndServe(":6666", nil)
}
启动后,通过 netstat 查看可以确定服务只有在 IPv6 上监听:

这时候,如果 curl 的是服务的 IPv4 地址,其实也是可以看到预期输出的:
$ curl -i -s http://192.168.3.10:6666
HTTP/1.1 200 OK
Date: Sat, 08 Dec 2018 06:33:54 GMT
Content-Length: 13
Content-Type: text/plain; charset=utf-8
Hello, world!
之所以会有这样的行为,是因为在 linux 上有个内核参数 net.ipv6.bindv6only 默认为关闭状态,这样 IPv6 的 socket 也就可以解析映射到同一个网卡的 IPv4 请求了。这样的话,如果我们的服务需要同时提供 IPv4 和 IPv6 的访问能力,只需要监听一个 IPv6 的 socket 即可。
我这里并不希望 IPv4 可以访问 IPv6 的服务,所以我把 net.ipv6.bindv6only 置为了 1:
$ cat /proc/sys/net/ipv6/bindv6only
1
遗憾的是,开启了这个参数后,似乎并没有效果,curl 依然可以使用 IPv4 的地址来访问。
溯源
怎么解释这个问题?既然是看起来内核参数没有生效,问题多半是发生在 syscall 的调用上,祭出 strace 大杀器:

可以看到在 220 行 Golang 把准备 `listen` 的 socket 选项置为了 0。显然这个是 Golang 自身的行为。
通过追踪 Golang 的源码,问题最终定位在了这里 src/net/ipsock_posix.go :
func favoriteAddrFamily(network string, laddr, raddr sockaddr, mode string) (family int, ipv6only bool) {
    switch network[len(network)-1] {
    case '4':
        return syscall.AF_INET, false
    case '6':
        return syscall.AF_INET6, true
    }
    if mode == "listen" && (laddr == nil || laddr.isWildcard()) {
        if supportsIPv4map() || !supportsIPv4() {
            return syscall.AF_INET6, false
        }
        if laddr == nil {
            return syscall.AF_INET, false
        }
        return laddr.family(), false
    }
    if (laddr == nil || laddr.family() == syscall.AF_INET) &&
        (raddr == nil || raddr.family() == syscall.AF_INET) {
        return syscall.AF_INET, false
    }
    return syscall.AF_INET6, false
}
原来 Golang 自己定义了 IPV6_V6ONLY 这个行为,至于这么做的原因在官方 Github 也有一些讨论:net: Listen is unfriendly to multiple address families, endpoints and subflows
既然是这样,那如何解决这个问题?
- 自己定制需要的 net.Listen()
- listen完整的 IPv4 和 IPv6 地址
插曲
如果你用 Chrome 访问 http://localhost:6666 这样的地址,可能会看到 ERR_UNSAFE_PORT 这样的错误页面。这个其实是因为 Chrome 的非安全端口限制。

像这样的端口,一共有 64 个:
1,    // tcpmux
7,    // echo
9,    // discard
11,   // systat
13,   // daytime
15,   // netstat
17,   // qotd
19,   // chargen
20,   // ftp data
21,   // ftp access
22,   // ssh
23,   // telnet
25,   // smtp
37,   // time
42,   // name
43,   // nicname
53,   // domain
77,   // priv-rjs
79,   // finger
87,   // ttylink
95,   // supdup
101,  // hostriame
102,  // iso-tsap
103,  // gppitnp
104,  // acr-nema
109,  // pop2
110,  // pop3
111,  // sunrpc
113,  // auth
115,  // sftp
117,  // uucp-path
119,  // nntp
123,  // NTP
135,  // loc-srv /epmap
139,  // netbios
143,  // imap2
179,  // BGP
389,  // ldap
465,  // smtp+ssl
512,  // print / exec
513,  // login
514,  // shell
515,  // printer
526,  // tempo
530,  // courier
531,  // chat
532,  // netnews
540,  // uucp
556,  // remotefs
563,  // nntp+ssl
587,  // stmp?
601,  // ??
636,  // ldap+ssl
993,  // ldap+ssl
995,  // pop3+ssl
2049, // nfs
3659, // apple-sasl / PasswordServer
4045, // lockd
6000, // X11
6665, // Alternate IRC [Apple addition]
6666, // Alternate IRC [Apple addition]
6667, // Standard IRC [Apple addition]
6668, // Alternate IRC [Apple addition]
6669, // Alternate IRC [Apple addition]
