GolangでDockerの公開ポート設定のように数値のペアをフラグとして受け取る

自分用スニペットです。 表題の「Dockerの公開ポート設定」とは以下のようなものです。

  • -p 8080:80
  • -p 192.168.1.100:8080:80
  • -p 8080:80/udp

Container networking | Docker Documentation

このように、-p 1:2として「1-->2」という対応、また-p "1:2, 3,4"として「1-->2」「3-->4」という対応を解釈するようなものを実装しました。

実装と解説

実装

  • main.go
package main

import (
    "flag"
    "fmt"
    "strconv"
    "strings"

    "github.com/k0kubun/pp"
)

func main() {
    p := flag.String("p", "1:2", "")
    flag.Parse()

    res, err := parsePair(*p)
    if err != nil {
        fmt.Println(err.Error())
        return
    }

    pp.Println(res)
}

type Pair struct {
    In  int64
    Out int64
}

func parsePair(expr string) ([]Pair, error) {
    res := []Pair{}

    for _, v1 := range strings.Split(expr, ",") {
        if v1 == "" {
            continue
        }

        n := strings.SplitN(v1, ":", 2)
        for i, v := range n {
            n[i] = strings.TrimSpace(v)
        }

        tmp := Pair{}

        if in, err := strconv.ParseInt(n[0], 10, 32); err == nil {
            tmp.In = in
        } else {
            return nil, err
        }

        if out, err := strconv.ParseInt(n[1], 10, 32); err == nil {
            tmp.Out = out
        } else {
            return nil, err
        }

        res = append(res, tmp)
    }

    return res, nil
}
  • 実行
$ go build -o out main.go 
$ ./out # デフォルト値
[]main.Pair{
  main.Pair{
    In:  1,
    Out: 2,
  },
}
$ ./out -p "0:0, 1:3, 120:5000"
[]main.Pair{
  main.Pair{
    In:  0,
    Out: 0,
  },
  main.Pair{
    In:  1,
    Out: 3,
  },
  main.Pair{
    In:  120,
    Out: 5000,
  },
}

解説

  • 任意個の「2つの数値のペア」を受け取る。
  • フラグとして渡される文字列は、「数値:数値」という形でペアを表している。ペアは「,」で区切られて複数渡されることもある。
  • 構造体Pairは、In・OutというInt64型の要素を持つ。
  • 関数parsePairは、Pairのスライスにフラグの解析結果を詰めて返す。

Dockerではどう実装しているか

ザっとコードリーディングしてみた。

-pで受け取った値のゆくえ

-pフラグの設定は以下でされている。

https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/cli/command/container/opts.go#L224

  • -pで受け取った値は、関数parseの中で変数copts.publishに格納される。
  • 変数copts.publishcontainerOptions型で、同じファイル内で定義されている。
  • containerOptions型のpublish要素は、opts.ListOpts型。
  • 以下のように、変数copts.publishGetAll()メソッドが呼ばれ、変数publishOptsが生成される。
publishOpts := copts.publish.GetAll()
  • 以下のように、変数publishOptsが関数convertToStandardNotationに渡され、変数convertedOptsが生成されている。
convertedOpts, err = convertToStandardNotation(publishOpts)
  • 関数convertToStandardNotationは以下で定義されている。

https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/cli/command/container/opts.go#L810

  • 変数convertedOpts,は[]string型。
  • 以下のように、変数convertedOptsは関数nat.ParsePortSpecsに渡され、ports, portBindingsを生成される。
ports, portBindings, err = nat.ParsePortSpecs(convertedOpts)
  • 変数portsmap[nat.Port]struct{}型で、後に*container.Config型の変数configの要素ExposedPortsに格納される。
  • 変数portBindingsmap[nat.Port][]nat.PortBinding型の変数で、後に*container.HostConfig型の変数hostConfigの要素PortBindingsに格納される。

map[nat.Port][]nat.PortBinding

ここまでの処理で、結局はmap[nat.Port][]nat.PortBinding型の変数で「ポートとポートの対応関係」を表しているらしいことがわかった。この型はつまり、「 『nat.Port』と『nat.PortBindingのスライス』の対応」を表している。

ここで、nat.Portは以下のように定義されている。

// Port is a string containing port number and protocol in the format "80/tcp"
type Port string

https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/vendor/github.com/docker/go-connections/nat/nat.go#L31

また、nat.PortBindingは以下のように定義されている

// PortBinding represents a binding between a Host IP address and a Host Port
type PortBinding struct {
    // HostIP is the host IP Address
    HostIP string `json:"HostIp"`
    // HostPort is the host port number
    HostPort string
}

https://github.com/docker/cli/blob/a32cd16160f1b41c1c4ae7bee4dac929d1484e59/vendor/github.com/docker/go-connections/nat/nat.go#L17

つまり、map[nat.Port][]nat.PortBinding型は「『コンテナ側のポート』と『「ホストのIPとポート」のスライス』の対応」を表している。

ここで、どうしてnat.PortBindingをスライスにしているのかと不思議に思いました。試しにやってみたところ、「コンテナのポート」に対して複数の「ホストのポート」を対応付けられるんですね。docker run -p 8080:8080 -p 8081:8080 -it alpineとすれば、コンテナ内の8080番・8081番両方の通信を、ホストの8080番にバインドできました。

nat.Portnat.PortBindingの生成

前述の処理の流れの中で、以下のようにnat.Port型・nat.PortBinding型の変数をそれぞれ生成する処理がありました。

ports, portBindings, err = nat.ParsePortSpecs(convertedOpts)

natパッケージ内にはParsePortSpecsという名前の関数が2つあり、それらは引数と戻り値が異なります。ここで呼ばれている関数はfunc ParsePortSpecs(ports []string) (map[Port]struct{}, map[Port][]PortBinding, error)です。

こちらのParsePortSpecsは以下のように定義されています。

// ParsePortSpecs receives port specs in the format of ip:public:private/proto and parses
// these in to the internal types
func ParsePortSpecs(ports []string) (map[Port]struct{}, map[Port][]PortBinding, error) {
    var (
        exposedPorts = make(map[Port]struct{}, len(ports))
        bindings     = make(map[Port][]PortBinding)
    )
    for _, rawPort := range ports {
        portMappings, err := ParsePortSpec(rawPort)
        if err != nil {
            return nil, nil, err
        }

        for _, portMapping := range portMappings {
            port := portMapping.Port
            if _, exists := exposedPorts[port]; !exists {
                exposedPorts[port] = struct{}{}
            }
            bslice, exists := bindings[port]
            if !exists {
                bslice = []PortBinding{}
            }
            bindings[port] = append(bslice, portMapping.Binding)
        }
    }
    return exposedPorts, bindings, nil
}

もう一方のParsePortSpecを呼び出していることがわかります。もう一方のParsePortSpecは以下のように定義されています。

// ParsePortSpec parses a port specification string into a slice of PortMappings
func ParsePortSpec(rawPort string) ([]PortMapping, error) {
    var proto string
    rawIP, hostPort, containerPort := splitParts(rawPort)
    proto, containerPort = SplitProtoPort(containerPort)

    // Strip [] from IPV6 addresses
    ip, _, err := net.SplitHostPort(rawIP + ":")
    if err != nil {
        return nil, fmt.Errorf("Invalid ip address %v: %s", rawIP, err)
    }
    if ip != "" && net.ParseIP(ip) == nil {
        return nil, fmt.Errorf("Invalid ip address: %s", ip)
    }
    if containerPort == "" {
        return nil, fmt.Errorf("No port specified: %s<empty>", rawPort)
    }

    startPort, endPort, err := ParsePortRange(containerPort)
    if err != nil {
        return nil, fmt.Errorf("Invalid containerPort: %s", containerPort)
    }

    var startHostPort, endHostPort uint64 = 0, 0
    if len(hostPort) > 0 {
        startHostPort, endHostPort, err = ParsePortRange(hostPort)
        if err != nil {
            return nil, fmt.Errorf("Invalid hostPort: %s", hostPort)
        }
    }

    if hostPort != "" && (endPort-startPort) != (endHostPort-startHostPort) {
        // Allow host port range iff containerPort is not a range.
        // In this case, use the host port range as the dynamic
        // host port range to allocate into.
        if endPort != startPort {
            return nil, fmt.Errorf("Invalid ranges specified for container and host Ports: %s and %s", containerPort, hostPort)
        }
    }

    if !validateProto(strings.ToLower(proto)) {
        return nil, fmt.Errorf("Invalid proto: %s", proto)
    }

    ports := []PortMapping{}
    for i := uint64(0); i <= (endPort - startPort); i++ {
        containerPort = strconv.FormatUint(startPort+i, 10)
        if len(hostPort) > 0 {
            hostPort = strconv.FormatUint(startHostPort+i, 10)
        }
        // Set hostPort to a range only if there is a single container port
        // and a dynamic host port.
        if startPort == endPort && startHostPort != endHostPort {
            hostPort = fmt.Sprintf("%s-%s", hostPort, strconv.FormatUint(endHostPort, 10))
        }
        port, err := NewPort(strings.ToLower(proto), containerPort)
        if err != nil {
            return nil, err
        }

        binding := PortBinding{
            HostIP:   ip,
            HostPort: hostPort,
        }
        ports = append(ports, PortMapping{Port: port, Binding: binding})
    }
    return ports, nil
}

こちらのParsePortSpec[]PortMapping型の変数を返します。PortMapping型は以下のように定義されています。

// PortMapping is a data object mapping a Port to a PortBinding
type PortMapping struct {
    Port    Port
    Binding PortBinding
}

まさにnat.Portnat.PortBindingの対応を表す型ですね。

ここで、ParsePortSpecs(ports []string) (map[Port]struct{}, map[Port][]PortBinding, error)の処理をまとめると、以下のようになります。

  • ParsePortSpecs(ports []string) (map[Port]struct{}, map[Port][]PortBinding, error)にポートバインディングの設定を表すstring型のスライスが渡される。
  • 引数のstring型のスライスの各要素(変数rawPort)について、以下の処理をする。
    • func ParsePortSpec(rawPort string) ([]PortMapping, error)rawPortを渡す。
      • rawPortを解析し、PortMappingのスライスを返す。
    • 受け取ったPortMapping型のデータをすべて分解して、map[Port]struct{}map[Port][]PortBindingの形にまとめなおす。

おわりに

おまけでDockerの実装を追ってみましたが、思ったより分量があって遭難しかけました。