Add Service Discovery option for non-Swarm Docker (#316)

This commit is contained in:
Matthew McCune 2024-08-04 15:22:39 -07:00 committed by GitHub
parent 8b3180d852
commit 5d7063f73e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 513 additions and 180 deletions

View File

@ -1,4 +1,4 @@
FROM golang:1.20 as builder FROM golang:1.22 AS builder
WORKDIR /build WORKDIR /build

View File

@ -22,12 +22,16 @@ Routes Minecraft client connections to backend servers based upon the requested
Enable debug logs (env DEBUG) Enable debug logs (env DEBUG)
-default string -default string
host:port of a default Minecraft server to use when mapping not found (env DEFAULT) host:port of a default Minecraft server to use when mapping not found (env DEFAULT)
-docker-socket
Path to Docker socket to use (env DOCKER_SOCKET) (default "unix:///var/run/docker.sock")
-docker-refresh-interval int -docker-refresh-interval int
Refresh interval in seconds for the Docker Swarm integration (env DOCKER_REFRESH_INTERVAL) (default 15) Refresh interval in seconds for the Docker Swarm integration (env DOCKER_REFRESH_INTERVAL) (default 15)
-docker-timeout int -docker-timeout int
Timeout configuration in seconds for the Docker Swarm integration (env DOCKER_TIMEOUT) Timeout configuration in seconds for the Docker Swarm integration (env DOCKER_TIMEOUT)
-in-docker
Use Docker service discovery (env IN_DOCKER)
-in-docker-swarm -in-docker-swarm
Use in-swarm Docker config (env IN_DOCKER_SWARM) Use Docker Swarm service discovery (env IN_DOCKER_SWARM)
-in-kube-cluster -in-kube-cluster
Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER) Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER)
-kube-config string -kube-config string
@ -114,6 +118,33 @@ To test out this example, add these two entries to my "hosts" file:
127.0.0.1 forge.example.com 127.0.0.1 forge.example.com
``` ```
### Using Docker auto-discovery
When running `mc-router` in a Docker environment you can pass the `--in-docker` or `--in-docker-swarm`
command-line argument and it will poll the Docker API periodically to find all the running
containers/services for Minecraft instances. To enable discovery you have to set the `mc-router.host`
label on the container. These are the labels scanned:
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to
connect to the server. The container/service endpoint will be used as the routed backend. You can
use more than one hostname by splitting it with a comma.
- `mc-router.port`: This value must be set to the port the Minecraft server is listening on.
The default value is 25565.
- `mc-router.default`: Set this to a truthy value to make this server the default backend.
Please note that `mc-router.host` is still required to be set.
- `mc-router.network`: Specify the network you are using for the router if multiple are
present in the container/service. You can either use the network ID, it's full name or an alias.
#### Example Docker deployment
Refer to [this example docker-compose.yml](docs/sd-docker.docker-compose.yml) to see how to
configure two different Minecraft servers and a `mc-router` instance for use with Docker service discovery.
#### Example Docker Swarm deployment
Refer to [this example docker-compose.yml](docs/swarm.docker-compose.yml) to see how to
configure two different Minecraft servers and a `mc-router` instance for use with Docker Swarm service discovery.
## Routing Configuration ## Routing Configuration
The routing configuration allows routing via a config file rather than a command. The routing configuration allows routing via a config file rather than a command.
@ -213,32 +244,6 @@ rules:
verbs: ["watch","list","get","update"] verbs: ["watch","list","get","update"]
``` ```
## Docker Swarm Usage
### Using Docker Swarm Service auto-discovery
When running `mc-router` in a Docker Swarm environment you can pass the `--in-docker-swarm`
command-line argument and it will poll the Docker API periodically to find all the running
services for minecraft instances. To enable discovery you have to set the `mc-router.host`
label on the service. These are the labels scanned:
- `mc-router.host`: Used to configure the hostname the Minecraft clients would use to
connect to the server. The service endpoint will be used as the routed backend. You can
use more than one hostname by splitting it with a comma.
- `mc-router.port`: This value must be set to the port the Minecraft server is listening on.
The default value is 25565.
- `mc-router.default`: Set this to a truthy value to make this server the deafult backend.
Please note that `mc-router.host` is still required to be set.
- `mc-router.network`: Specify the network you are using for the router if multiple are
present in the service. You can either use the network ID, it's full name or an alias.
### Example Docker Swarm deployment
Refer to [this example docker-compose.yml](docs/swarm.docker-compose.yml) to see how to
configure two different Minecraft servers and a `mc-router` instance. Notice how you don't
have to expose the Minecraft instances ports, but all the containers are required to be in
the same network.
## REST API ## REST API
* `GET /routes` (with `Accept: application/json`) * `GET /routes` (with `Accept: application/json`)
@ -365,4 +370,4 @@ docker run -it --rm \
## Related Projects ## Related Projects
* https://github.com/haveachin/infrared * https://github.com/haveachin/infrared

View File

@ -40,9 +40,11 @@ type Config struct {
InKubeCluster bool `usage:"Use in-cluster Kubernetes config"` InKubeCluster bool `usage:"Use in-cluster Kubernetes config"`
KubeConfig string `usage:"The path to a Kubernetes configuration file"` KubeConfig string `usage:"The path to a Kubernetes configuration file"`
AutoScaleUp bool `usage:"Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed"` AutoScaleUp bool `usage:"Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed"`
InDockerSwarm bool `usage:"Use in-swarm Docker config"` InDocker bool `usage:"Use Docker service discovery"`
DockerTimeout int `default:"0" usage:"Timeout configuration in seconds for the Docker Swarm integration"` InDockerSwarm bool `usage:"Use Docker Swarm service discovery"`
DockerRefreshInterval int `default:"15" usage:"Refresh interval in seconds for the Docker Swarm integration"` DockerSocket string `default:"unix:///var/run/docker.sock" usage:"Path to Docker socket to use"`
DockerTimeout int `default:"0" usage:"Timeout configuration in seconds for the Docker integrations"`
DockerRefreshInterval int `default:"15" usage:"Refresh interval in seconds for the Docker integrations"`
MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb"` MetricsBackend string `default:"discard" usage:"Backend to use for metrics exposure/publishing: discard,expvar,influxdb"`
UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"` UseProxyProtocol bool `default:"false" usage:"Send PROXY protocol to backend servers"`
ReceiveProxyProtocol bool `default:"false" usage:"Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies"` ReceiveProxyProtocol bool `default:"false" usage:"Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies"`
@ -161,12 +163,21 @@ func main() {
} }
} }
if config.InDocker {
err = server.DockerWatcher.Start(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval)
if err != nil {
logrus.WithError(err).Fatal("Unable to start docker integration")
} else {
defer server.DockerWatcher.Stop()
}
}
if config.InDockerSwarm { if config.InDockerSwarm {
err = server.DockerWatcher.StartInSwarm(config.DockerTimeout, config.DockerRefreshInterval) err = server.DockerSwarmWatcher.Start(config.DockerSocket, config.DockerTimeout, config.DockerRefreshInterval)
if err != nil { if err != nil {
logrus.WithError(err).Fatal("Unable to start docker swarm integration") logrus.WithError(err).Fatal("Unable to start docker swarm integration")
} else { } else {
defer server.DockerWatcher.Stop() defer server.DockerSwarmWatcher.Stop()
} }
} }

View File

@ -0,0 +1,34 @@
services:
mcfoo:
image: itzg/minecraft-server
environment:
EULA: "TRUE"
MOTD: "foo"
volumes:
- mcfoodata:/data
labels:
mc-router.host: "foo.localhost"
mc-router.default: true
mcbar:
image: itzg/minecraft-server
environment:
EULA: "TRUE"
MOTD: "bar"
volumes:
- mcbardata:/data
labels:
mc-router.host: "bar.localhost"
router:
image: itzg/mc-router:latest
command: "-debug -in-docker"
depends_on:
- mcfoo
- mcbar
ports:
- "25565:25565"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
volumes:
mcfoodata:
mcbardata:

View File

@ -3,26 +3,30 @@ package server
import ( import (
"context" "context"
"fmt" "fmt"
"net"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
dockertypes "github.com/docker/docker/api/types" dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
swarmtypes "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client" "github.com/docker/docker/client"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
) )
type IDockerWatcher interface { type IDockerWatcher interface {
StartInSwarm(timeoutSeconds int, refreshIntervalSeconds int) error Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error
Stop() Stop()
} }
const (
DockerAPIVersion = "1.24"
DockerRouterLabelHost = "mc-router.host"
DockerRouterLabelPort = "mc-router.port"
DockerRouterLabelDefault = "mc-router.default"
DockerRouterLabelNetwork = "mc-router.network"
)
var DockerWatcher IDockerWatcher = &dockerWatcherImpl{} var DockerWatcher IDockerWatcher = &dockerWatcherImpl{}
type dockerWatcherImpl struct { type dockerWatcherImpl struct {
@ -31,29 +35,20 @@ type dockerWatcherImpl struct {
contextCancel context.CancelFunc contextCancel context.CancelFunc
} }
const ( func (w *dockerWatcherImpl) makeWakerFunc(_ *routableContainer) func(ctx context.Context) error {
DockerConfigHost = "unix:///var/run/docker.sock"
DockerAPIVersion = "1.24"
DockerRouterLabelHost = "mc-router.host"
DockerRouterLabelPort = "mc-router.port"
DockerRouterLabelDefault = "mc-router.default"
DockerRouterLabelNetwork = "mc-router.network"
)
func (w *dockerWatcherImpl) makeWakerFunc(service *routableService) func(ctx context.Context) error {
return func(ctx context.Context) error { return func(ctx context.Context) error {
return nil return nil
} }
} }
func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeconds int) error { func (w *dockerWatcherImpl) Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error {
var err error var err error
timeout := time.Duration(timeoutSeconds) * time.Second timeout := time.Duration(timeoutSeconds) * time.Second
refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second
opts := []client.Opt{ opts := []client.Opt{
client.WithHost(DockerConfigHost), client.WithHost(socket),
client.WithTimeout(timeout), client.WithTimeout(timeout),
client.WithHTTPHeaders(map[string]string{ client.WithHTTPHeaders(map[string]string{
"User-Agent": "mc-router ", "User-Agent": "mc-router ",
@ -67,22 +62,22 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
} }
ticker := time.NewTicker(refreshInterval) ticker := time.NewTicker(refreshInterval)
serviceMap := map[string]*routableService{} containerMap := map[string]*routableContainer{}
var ctx context.Context var ctx context.Context
ctx, w.contextCancel = context.WithCancel(context.Background()) ctx, w.contextCancel = context.WithCancel(context.Background())
initialServices, err := w.listServices(ctx) initialContainers, err := w.listContainers(ctx)
if err != nil { if err != nil {
return err return err
} }
for _, s := range initialServices { for _, c := range initialContainers {
serviceMap[s.externalServiceName] = s containerMap[c.externalContainerName] = c
if s.externalServiceName != "" { if c.externalContainerName != "" {
Routes.CreateMapping(s.externalServiceName, s.containerEndpoint, w.makeWakerFunc(s)) Routes.CreateMapping(c.externalContainerName, c.containerEndpoint, w.makeWakerFunc(c))
} else { } else {
Routes.SetDefaultRoute(s.containerEndpoint) Routes.SetDefaultRoute(c.containerEndpoint)
} }
} }
@ -90,43 +85,43 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
services, err := w.listServices(ctx) containers, err := w.listContainers(ctx)
if err != nil { if err != nil {
logrus.WithError(err).Error("Docker failed to list services") logrus.WithError(err).Error("Docker failed to list containers")
return return
} }
visited := map[string]struct{}{} visited := map[string]struct{}{}
for _, rs := range services { for _, rs := range containers {
if oldRs, ok := serviceMap[rs.externalServiceName]; !ok { if oldRs, ok := containerMap[rs.externalContainerName]; !ok {
serviceMap[rs.externalServiceName] = rs containerMap[rs.externalContainerName] = rs
logrus.WithField("routableService", rs).Debug("ADD") logrus.WithField("routableContainer", rs).Debug("ADD")
if rs.externalServiceName != "" { if rs.externalContainerName != "" {
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs)) Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, w.makeWakerFunc(rs))
} else { } else {
Routes.SetDefaultRoute(rs.containerEndpoint) Routes.SetDefaultRoute(rs.containerEndpoint)
} }
} else if oldRs.containerEndpoint != rs.containerEndpoint { } else if oldRs.containerEndpoint != rs.containerEndpoint {
serviceMap[rs.externalServiceName] = rs containerMap[rs.externalContainerName] = rs
if rs.externalServiceName != "" { if rs.externalContainerName != "" {
Routes.DeleteMapping(rs.externalServiceName) Routes.DeleteMapping(rs.externalContainerName)
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs)) Routes.CreateMapping(rs.externalContainerName, rs.containerEndpoint, w.makeWakerFunc(rs))
} else { } else {
Routes.SetDefaultRoute(rs.containerEndpoint) Routes.SetDefaultRoute(rs.containerEndpoint)
} }
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE") logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
} }
visited[rs.externalServiceName] = struct{}{} visited[rs.externalContainerName] = struct{}{}
} }
for _, rs := range serviceMap { for _, rs := range containerMap {
if _, ok := visited[rs.externalServiceName]; !ok { if _, ok := visited[rs.externalContainerName]; !ok {
delete(serviceMap, rs.externalServiceName) delete(containerMap, rs.externalContainerName)
if rs.externalServiceName != "" { if rs.externalContainerName != "" {
Routes.DeleteMapping(rs.externalServiceName) Routes.DeleteMapping(rs.externalContainerName)
} else { } else {
Routes.SetDefaultRoute("") Routes.SetDefaultRoute("")
} }
logrus.WithField("routableService", rs).Debug("DELETE") logrus.WithField("routableContainer", rs).Debug("DELETE")
} }
} }
@ -137,64 +132,33 @@ func (w *dockerWatcherImpl) StartInSwarm(timeoutSeconds int, refreshIntervalSeco
} }
}() }()
logrus.Info("Monitoring Docker for Minecraft services") logrus.Info("Monitoring Docker for Minecraft containers")
return nil return nil
} }
func (w *dockerWatcherImpl) listServices(ctx context.Context) ([]*routableService, error) { func (w *dockerWatcherImpl) listContainers(ctx context.Context) ([]*routableContainer, error) {
services, err := w.client.ServiceList(ctx, dockertypes.ServiceListOptions{}) containers, err := w.client.ContainerList(ctx, container.ListOptions{})
if err != nil { if err != nil {
return nil, err return nil, err
} }
serverVersion, err := w.client.ServerVersion(ctx) var result []*routableContainer
if err != nil { for _, container := range containers {
return nil, err data, ok := w.parseContainerData(&container)
}
networkListArgs := filters.NewArgs()
// https://docs.docker.com/engine/api/v1.29/#tag/Network (Docker 17.06)
if versions.GreaterThanOrEqualTo(serverVersion.APIVersion, "1.29") {
networkListArgs.Add("scope", "swarm")
} else {
networkListArgs.Add("driver", "overlay")
}
networkList, err := w.client.NetworkList(ctx, dockertypes.NetworkListOptions{Filters: networkListArgs})
if err != nil {
return nil, err
}
networkMap := make(map[string]*dockertypes.NetworkResource)
for _, network := range networkList {
networkToAdd := network
networkMap[network.ID] = &networkToAdd
}
var result []*routableService
for _, service := range services {
if service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP {
continue
}
if len(service.Endpoint.VirtualIPs) == 0 {
continue
}
data, ok := w.parseServiceData(&service, networkMap)
if !ok { if !ok {
continue continue
} }
for _, host := range data.hosts { for _, host := range data.hosts {
result = append(result, &routableService{ result = append(result, &routableContainer{
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port), containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
externalServiceName: host, externalContainerName: host,
}) })
} }
if data.def != nil && *data.def { if data.def != nil && *data.def {
result = append(result, &routableService{ result = append(result, &routableContainer{
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port), containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
externalServiceName: "", externalContainerName: "",
}) })
} }
} }
@ -202,28 +166,7 @@ func (w *dockerWatcherImpl) listServices(ctx context.Context) ([]*routableServic
return result, nil return result, nil
} }
func dockerCheckNetworkName(id string, name string, networkMap map[string]*dockertypes.NetworkResource, networkAliases map[string][]string) (bool, error) { type parsedDockerContainerData struct {
// we allow to specify the id instead
if id == name {
return true, nil
}
if network := networkMap[id]; network != nil {
if network.Name == name {
return true, nil
}
aliases := networkAliases[id]
for _, alias := range aliases {
if alias == name {
return true, nil
}
}
return false, nil
}
return false, fmt.Errorf("network not found %s", id)
}
type parsedDockerServiceData struct {
hosts []string hosts []string
port uint64 port uint64
def *bool def *bool
@ -231,40 +174,36 @@ type parsedDockerServiceData struct {
ip string ip string
} }
func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service, networkMap map[string]*dockertypes.NetworkResource) (data parsedDockerServiceData, ok bool) { func (w *dockerWatcherImpl) parseContainerData(container *dockertypes.Container) (data parsedDockerContainerData, ok bool) {
networkAliases := map[string][]string{} for key, value := range container.Labels {
for _, network := range service.Spec.TaskTemplate.Networks {
networkAliases[network.Target] = network.Aliases
}
for key, value := range service.Spec.Labels {
if key == DockerRouterLabelHost { if key == DockerRouterLabelHost {
if data.hosts != nil { if data.hosts != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelHost) Warnf("ignoring container with duplicate %s label", DockerRouterLabelHost)
return return
} }
data.hosts = strings.Split(value, ",") data.hosts = strings.Split(value, ",")
} }
if key == DockerRouterLabelPort { if key == DockerRouterLabelPort {
if data.port != 0 { if data.port != 0 {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelPort) Warnf("ignoring container with duplicate %s label", DockerRouterLabelPort)
return return
} }
var err error var err error
data.port, err = strconv.ParseUint(value, 10, 32) data.port, err = strconv.ParseUint(value, 10, 32)
if err != nil { if err != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
WithError(err). WithError(err).
Warnf("ignoring service with invalid %s", DockerRouterLabelPort) Warnf("ignoring container with invalid %s label", DockerRouterLabelPort)
return return
} }
} }
if key == DockerRouterLabelDefault { if key == DockerRouterLabelDefault {
if data.def != nil { if data.def != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelDefault) Warnf("ignoring container with duplicate %s label", DockerRouterLabelDefault)
return return
} }
data.def = new(bool) data.def = new(bool)
@ -274,8 +213,8 @@ func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service, networkMap
} }
if key == DockerRouterLabelNetwork { if key == DockerRouterLabelNetwork {
if data.network != nil { if data.network != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelNetwork) Warnf("ignoring container with duplicate %s label", DockerRouterLabelNetwork)
return return
} }
data.network = new(string) data.network = new(string)
@ -288,9 +227,9 @@ func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service, networkMap
return return
} }
if len(service.Endpoint.VirtualIPs) == 0 { if len(container.NetworkSettings.Networks) == 0 {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring service, no VirtualIPs found") Warnf("ignoring container, no networks found")
return return
} }
@ -298,32 +237,50 @@ func (w *dockerWatcherImpl) parseServiceData(service *swarm.Service, networkMap
data.port = 25565 data.port = 25565
} }
vipIndex := -1
if data.network != nil { if data.network != nil {
for i, vip := range service.Endpoint.VirtualIPs { // Loop through all the container's networks and attempt to find one whose Network ID, Name, or Aliases match the
if ok, err := dockerCheckNetworkName(vip.NetworkID, *data.network, networkMap, networkAliases); ok { // specified network
vipIndex = i for name, endpoint := range container.NetworkSettings.Networks {
if name == endpoint.NetworkID {
data.ip = endpoint.IPAddress
}
if name == *data.network {
data.ip = endpoint.IPAddress
break break
} else if err != nil { }
// we intentionally ignore name check errors
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). for _, alias := range endpoint.Aliases {
Debugf("%v", err) if alias == name {
data.ip = endpoint.IPAddress
break
}
} }
} }
if vipIndex == -1 { } else {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}). // If there's no endpoint specified we can just assume the only one is the network we should use. One caveat is
Warnf("ignoring service, network %s not found", *data.network) // if there's more than one network on this container, we should require that the user specifies a network to avoid
// weird problems.
if len(container.NetworkSettings.Networks) > 1 {
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring container, multiple networks found and none specified using label %s", DockerRouterLabelNetwork)
return return
} }
} else {
// if network isn't specified assume it's the first one for _, endpoint := range container.NetworkSettings.Networks {
vipIndex = 0 data.ip = endpoint.IPAddress
break
}
}
if data.ip == "" {
logrus.WithFields(logrus.Fields{"containerId": container.ID, "containerNames": container.Names}).
Warnf("ignoring container, unable to find accessible ip address")
return
} }
virtualIP := service.Endpoint.VirtualIPs[vipIndex]
ip, _, _ := net.ParseCIDR(virtualIP.Addr)
data.ip = ip.String()
ok = true ok = true
return return
} }
@ -332,3 +289,8 @@ func (w *dockerWatcherImpl) Stop() {
w.contextCancel() w.contextCancel()
} }
} }
type routableContainer struct {
externalContainerName string
containerEndpoint string
}

321
server/docker_swarm.go Normal file
View File

@ -0,0 +1,321 @@
package server
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
swarmtypes "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/versions"
"github.com/docker/docker/client"
"github.com/sirupsen/logrus"
)
var DockerSwarmWatcher IDockerWatcher = &dockerSwarmWatcherImpl{}
type dockerSwarmWatcherImpl struct {
sync.RWMutex
client *client.Client
contextCancel context.CancelFunc
}
func (w *dockerSwarmWatcherImpl) makeWakerFunc(_ *routableService) func(ctx context.Context) error {
return func(ctx context.Context) error {
return nil
}
}
func (w *dockerSwarmWatcherImpl) Start(socket string, timeoutSeconds int, refreshIntervalSeconds int) error {
var err error
timeout := time.Duration(timeoutSeconds) * time.Second
refreshInterval := time.Duration(refreshIntervalSeconds) * time.Second
opts := []client.Opt{
client.WithHost(socket),
client.WithTimeout(timeout),
client.WithHTTPHeaders(map[string]string{
"User-Agent": "mc-router ",
}),
client.WithVersion(DockerAPIVersion),
}
w.client, err = client.NewClientWithOpts(opts...)
if err != nil {
return err
}
ticker := time.NewTicker(refreshInterval)
serviceMap := map[string]*routableService{}
var ctx context.Context
ctx, w.contextCancel = context.WithCancel(context.Background())
initialServices, err := w.listServices(ctx)
if err != nil {
return err
}
for _, s := range initialServices {
serviceMap[s.externalServiceName] = s
if s.externalServiceName != "" {
Routes.CreateMapping(s.externalServiceName, s.containerEndpoint, w.makeWakerFunc(s))
} else {
Routes.SetDefaultRoute(s.containerEndpoint)
}
}
go func() {
for {
select {
case <-ticker.C:
services, err := w.listServices(ctx)
if err != nil {
logrus.WithError(err).Error("Docker failed to list services")
return
}
visited := map[string]struct{}{}
for _, rs := range services {
if oldRs, ok := serviceMap[rs.externalServiceName]; !ok {
serviceMap[rs.externalServiceName] = rs
logrus.WithField("routableService", rs).Debug("ADD")
if rs.externalServiceName != "" {
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
} else {
Routes.SetDefaultRoute(rs.containerEndpoint)
}
} else if oldRs.containerEndpoint != rs.containerEndpoint {
serviceMap[rs.externalServiceName] = rs
if rs.externalServiceName != "" {
Routes.DeleteMapping(rs.externalServiceName)
Routes.CreateMapping(rs.externalServiceName, rs.containerEndpoint, w.makeWakerFunc(rs))
} else {
Routes.SetDefaultRoute(rs.containerEndpoint)
}
logrus.WithFields(logrus.Fields{"old": oldRs, "new": rs}).Debug("UPDATE")
}
visited[rs.externalServiceName] = struct{}{}
}
for _, rs := range serviceMap {
if _, ok := visited[rs.externalServiceName]; !ok {
delete(serviceMap, rs.externalServiceName)
if rs.externalServiceName != "" {
Routes.DeleteMapping(rs.externalServiceName)
} else {
Routes.SetDefaultRoute("")
}
logrus.WithField("routableService", rs).Debug("DELETE")
}
}
case <-ctx.Done():
ticker.Stop()
return
}
}
}()
logrus.Info("Monitoring Docker Swarm for Minecraft services")
return nil
}
func (w *dockerSwarmWatcherImpl) listServices(ctx context.Context) ([]*routableService, error) {
services, err := w.client.ServiceList(ctx, dockertypes.ServiceListOptions{})
if err != nil {
return nil, err
}
serverVersion, err := w.client.ServerVersion(ctx)
if err != nil {
return nil, err
}
networkListArgs := filters.NewArgs()
// https://docs.docker.com/engine/api/v1.29/#tag/Network (Docker 17.06)
if versions.GreaterThanOrEqualTo(serverVersion.APIVersion, "1.29") {
networkListArgs.Add("scope", "swarm")
} else {
networkListArgs.Add("driver", "overlay")
}
networkList, err := w.client.NetworkList(ctx, network.ListOptions{Filters: networkListArgs})
if err != nil {
return nil, err
}
networkMap := make(map[string]*network.Inspect)
for _, network := range networkList {
networkToAdd := network
networkMap[network.ID] = &networkToAdd
}
var result []*routableService
for _, service := range services {
if service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP {
continue
}
if len(service.Endpoint.VirtualIPs) == 0 {
continue
}
data, ok := w.parseServiceData(&service, networkMap)
if !ok {
continue
}
for _, host := range data.hosts {
result = append(result, &routableService{
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
externalServiceName: host,
})
}
if data.def != nil && *data.def {
result = append(result, &routableService{
containerEndpoint: fmt.Sprintf("%s:%d", data.ip, data.port),
externalServiceName: "",
})
}
}
return result, nil
}
func dockerCheckNetworkName(id string, name string, networkMap map[string]*network.Inspect, networkAliases map[string][]string) (bool, error) {
// we allow to specify the id instead
if id == name {
return true, nil
}
if network := networkMap[id]; network != nil {
if network.Name == name {
return true, nil
}
aliases := networkAliases[id]
for _, alias := range aliases {
if alias == name {
return true, nil
}
}
return false, nil
}
return false, fmt.Errorf("network not found %s", id)
}
type parsedDockerServiceData struct {
hosts []string
port uint64
def *bool
network *string
ip string
}
func (w *dockerSwarmWatcherImpl) parseServiceData(service *swarm.Service, networkMap map[string]*network.Inspect) (data parsedDockerServiceData, ok bool) {
networkAliases := map[string][]string{}
for _, network := range service.Spec.TaskTemplate.Networks {
networkAliases[network.Target] = network.Aliases
}
for key, value := range service.Spec.Labels {
if key == DockerRouterLabelHost {
if data.hosts != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelHost)
return
}
data.hosts = strings.Split(value, ",")
}
if key == DockerRouterLabelPort {
if data.port != 0 {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelPort)
return
}
var err error
data.port, err = strconv.ParseUint(value, 10, 32)
if err != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
WithError(err).
Warnf("ignoring service with invalid %s", DockerRouterLabelPort)
return
}
}
if key == DockerRouterLabelDefault {
if data.def != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelDefault)
return
}
data.def = new(bool)
lowerValue := strings.TrimSpace(strings.ToLower(value))
*data.def = lowerValue != "" && lowerValue != "0" && lowerValue != "false" && lowerValue != "no"
}
if key == DockerRouterLabelNetwork {
if data.network != nil {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service with duplicate %s", DockerRouterLabelNetwork)
return
}
data.network = new(string)
*data.network = value
}
}
// probably not minecraft related
if len(data.hosts) == 0 {
return
}
if len(service.Endpoint.VirtualIPs) == 0 {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service, no VirtualIPs found")
return
}
if data.port == 0 {
data.port = 25565
}
vipIndex := -1
if data.network != nil {
for i, vip := range service.Endpoint.VirtualIPs {
if ok, err := dockerCheckNetworkName(vip.NetworkID, *data.network, networkMap, networkAliases); ok {
vipIndex = i
break
} else if err != nil {
// we intentionally ignore name check errors
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Debugf("%v", err)
}
}
if vipIndex == -1 {
logrus.WithFields(logrus.Fields{"serviceId": service.ID, "serviceName": service.Spec.Name}).
Warnf("ignoring service, network %s not found", *data.network)
return
}
} else {
// if network isn't specified assume it's the first one
vipIndex = 0
}
virtualIP := service.Endpoint.VirtualIPs[vipIndex]
ip, _, _ := net.ParseCIDR(virtualIP.Addr)
data.ip = ip.String()
ok = true
return
}
func (w *dockerSwarmWatcherImpl) Stop() {
if w.contextCancel != nil {
w.contextCancel()
}
}