Linux Network NamespaceをGoで操作する

# TL;DR

1. Go言語のgoroutineはdefaultではpreemptiveに動作するOS Threadが切り替わるのでOS Threadに強く紐づくlinuxのnamespace関連の操作を行うときは```runtime.LockOSThread()```しておく必要がある。[^1]
2. Go言語でLinuxのnetwork namespaceを操作したい場合は[CNIのライブラリ](https://github.com/containernetworking/plugins/tree/master/pkg/ns)を使うのが便利

[^1]: https://github.com/golang/go/wiki/LockOSThread


# なんでこんな事してるの？

テナント（200~）毎にVMを用意してると管理やコストが大きいため、アドレス空間が衝突してるテナントに対してHTTP(S)リバースプロキシを提供する仕組みを作ってみようと思った。

# Proof of Concept

試しに下記のコードを実行してみる。


```golang
package main

import (
        "log"
        "net"
        "net/http"
        "os"
        "runtime"

        "github.com/containernetworking/plugins/pkg/ns"
)

func main() {
        nspath := os.Args[1]
        addr := os.Args[2]
        var err error
        var l net.Listener
        ns.WithNetNSPath(nspath, func(_ ns.NetNS) error {
                l, err = net.Listen("tcp", addr)
                return nil
        })
        runtime.UnlockOSThread()
        if err != nil {
                log.Fatal(err)
        }
        if err := http.Serve(l, nil); err != nil {
                log.Fatal(err)
        }
}
```

このコード動かすには下記の様にネットワーク的に隔離されたコンテナを用意しておくとよい。

```bash
# build binary
go build -o nsproxy nsproxy.go
# setup environment
docker run -d --net none --name pause k8s.gcr.io/pause:3.1
ns=$(docker inspect --format '{{ .NetworkSettings.SandboxKey }}' pause)
# run program
sudo ./nsproxy "$ns" 127.0.0.1:8080 &
```

このバイナリを動かした場合、HTTPサーバーとして動作しているタイミングではコンテナのnetwork namaspace(以後netnsと表記)には存在していない。

```console
# ls -l /proc/1/ns/net # hostの初期netnsの情報
lrwxrwxrwx 1 root root 0 Dec 24 21:42 /proc/1/ns/net -> 'net:[4026531984]'
# ls -l /proc/$(pgrep nsproxy)/task/*/ns/net # nsproxyプロセスはホストのnetnsに居る
lrwxrwxrwx 1 root root 0 Dec 24 21:42 /proc/4377/task/4377/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4378/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4379/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4380/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4381/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4382/ns/net -> 'net:[4026531984]'
lrwxrwxrwx 1 root root 0 Dec 24 21:47 /proc/4377/task/4393/ns/net -> 'net:[4026531984]'
# ls -l /proc/$(docker inspect --format '{{.State.Pid}}' pause)/task/*/ns/net # containerのnetnsの情報
lrwxrwxrwx 1 root root 0 Dec 24 21:50 /proc/3867/task/3867/ns/net -> 'net:[4026532117]'
```

しかしながらnsenterを用いてコンテナのnetnsの中に入ると```127.0.0.1:8080```でhttpサーバーが動作していることが分かる。

```console
# nsenter --net=$(docker inspect --format '{{ .NetworkSettings.SandboxKey }}' pause) bash
# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
# ss -ltn
State           Recv-Q          Send-Q                   Local Address:Port                     Peer Address:Port
LISTEN          0               128                          127.0.0.1:8080                          0.0.0.0:*
# curl http://127.0.0.1:8080 -v
* Expire in 0 ms for 6 (transfer 0x5627619e7f50)
*   Trying 127.0.0.1...
* TCP_NODELAY set
* Expire in 200 ms for 4 (transfer 0x5627619e7f50)
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.64.0
> Accept: */*
>
< HTTP/1.1 404 Not Found
< Content-Type: text/plain; charset=utf-8
< X-Content-Type-Options: nosniff
< Date: Tue, 24 Dec 2019 12:58:10 GMT
< Content-Length: 19
<
404 page not found
* Connection #0 to host 127.0.0.1 left intact
```

# たくさんのコンテナからアクセスできるようにしてみる

この方法がどれだけスケールすのか試してみる。
Listenするポートを複数になるように拡張する。

```golang
package main

import (
        "log"
        "net"
        "net/http"
        "os"
        "runtime"
        "sync"

        "github.com/containernetworking/plugins/pkg/ns"
)

func main() {
        addr := os.Args[1]
        var ls []net.Listener
        for _, nspath := range os.Args[2:] {
                ns.WithNetNSPath(nspath, func(_ ns.NetNS) error {
                        l, err := net.Listen("tcp", addr)
                        if err != nil {
                                log.Fatal(err)
                        }
                        ls = append(ls, l)
                        return nil
                })
        }
        runtime.UnlockOSThread()
        var wg sync.WaitGroup
        for _, l := range ls {
                wg.Add(1)
                go func(l net.Listener){
                        err := http.Serve(l, nil)
                        if err != nil {
                                log.Print(err)
                        }
                        wg.Done()
                }(l)
        }
        wg.Wait()
}
```

下記の様に100個ほどコンテナを用意する

```
# 100個のコンテナを作成する
seq 1000 1999 | xargs -I '{}' -exec docker run -d --net none --name 'pause{}' k8s.gcr.io/pause:3.1
# 100個のコンテナに対してListenする
sudo ./nsproxy 127.0.0.1:8080 $(docker inspect --format '{{.NetworkSettings.SandboxKey}}' pause{100..199} ) &
```

プロセスの稼働開始直後の状態

```console
$ sudo cat /proc/$(pgrep nsproxy)/status
Name:   nsproxy
Umask:  0022
State:  S (sleeping)
Tgid:   17082
Ngid:   0
Pid:    17082
PPid:   17068
TracerPid:      0
Uid:    0       0       0       0
Gid:    0       0       0       0
FDSize: 128
Groups: 0
NStgid: 17082
NSpid:  17082
NSpgid: 17068
NSsid:  3567
VmPeak:   618548 kB
VmSize:   561720 kB
VmLck:         0 kB
VmPin:         0 kB
VmHWM:     10980 kB
VmRSS:     10980 kB
RssAnon:            6608 kB
RssFile:            4372 kB
RssShmem:              0 kB
VmData:   161968 kB
VmStk:       140 kB
VmExe:      2444 kB
VmLib:      1500 kB
VmPTE:       140 kB
VmSwap:        0 kB
HugetlbPages:          0 kB
CoreDumping:    0
Threads:        7
SigQ:   0/15453
SigPnd: 0000000000000000
ShdPnd: 0000000000000000
SigBlk: 0000000000000000
SigIgn: 0000000000000000
SigCgt: ffffffffffc1feff
CapInh: 0000000000000000
CapPrm: 0000003fffffffff
CapEff: 0000003fffffffff
CapBnd: 0000003fffffffff
CapAmb: 0000000000000000
NoNewPrivs:     0
Seccomp:        0
Speculation_Store_Bypass:       thread vulnerable
Cpus_allowed:   ffff,ffffffff,ffffffff,ffffffff,ffffffff,ffffffff,ffffffff,ffffffff
Cpus_allowed_list:      0-239
Mems_allowed:   00000000,00000001
Mems_allowed_list:      0
voluntary_ctxt_switches:        6
nonvoluntary_ctxt_switches:     0
```

開始直後ではRSSが10980 kB程度とかなり軽量であることが分かる。

# まとめ

network namespaceを触るのは怖くないので皆さんも触ってみてください。CNIのライブラリ自体は軽量なのでぜひとも実装自体を覗いてみてください。