fix(caddy): proxy to only healthy containers

This commit is contained in:
Pavel Sviderski
2025-05-01 11:47:05 +10:00
parent cf7e333579
commit 38800924ed
2 changed files with 105 additions and 79 deletions
+16 -13
View File
@@ -15,7 +15,6 @@ import (
"github.com/caddyserver/caddy/v2/caddyconfig" "github.com/caddyserver/caddy/v2/caddyconfig"
"github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy" "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy"
"github.com/psviderski/uncloud/internal/machine/docker"
"github.com/psviderski/uncloud/pkg/api" "github.com/psviderski/uncloud/pkg/api"
) )
@@ -24,34 +23,38 @@ func GenerateConfig(containers []api.ServiceContainer, verifyResponse string) (*
httpHostUpstreams := make(map[string][]string) httpHostUpstreams := make(map[string][]string)
httpsHostUpstreams := make(map[string][]string) httpsHostUpstreams := make(map[string][]string)
for _, ctr := range containers { for _, ctr := range containers {
logger := slog.With("container", ctr.ID) if !ctr.Healthy() {
network, ok := ctr.NetworkSettings.Networks[docker.NetworkName]
if !ok {
// Container is not connected to the uncloud Docker network (could be host network).
continue
}
if network.IPAddress == "" {
logger.Error("Container has no IPv4 address.")
continue continue
} }
ip := ctr.UncloudNetworkIP()
if !ip.IsValid() {
// Container is not connected to the uncloud Docker network (could be host network).
continue
}
log := slog.With("container", ctr.ID)
ports, err := ctr.ServicePorts() ports, err := ctr.ServicePorts()
if err != nil { if err != nil {
logger.Error("Failed to parse service ports for container.", "err", err) log.Error("Failed to parse service ports for container.", "err", err)
continue continue
} }
for _, port := range ports { for _, port := range ports {
if port.Mode != api.PortModeIngress {
continue
}
switch port.Protocol { switch port.Protocol {
case api.ProtocolHTTP: case api.ProtocolHTTP:
upstream := net.JoinHostPort(network.IPAddress, strconv.Itoa(int(port.ContainerPort))) upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream) httpHostUpstreams[port.Hostname] = append(httpHostUpstreams[port.Hostname], upstream)
case api.ProtocolHTTPS: case api.ProtocolHTTPS:
upstream := net.JoinHostPort(network.IPAddress, strconv.Itoa(int(port.ContainerPort))) upstream := net.JoinHostPort(ip.String(), strconv.Itoa(int(port.ContainerPort)))
httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream) httpsHostUpstreams[port.Hostname] = append(httpsHostUpstreams[port.Hostname], upstream)
default: default:
// TODO: implement L4 ingress routing for TCP and UDP. // TODO: implement L4 ingress routing for TCP and UDP.
logger.Error("Unsupported protocol for ingress port.", "port", port) log.Error("Unsupported protocol for ingress port.", "port", port)
continue continue
} }
} }
+89 -66
View File
@@ -14,6 +14,25 @@ import (
) )
func TestGenerateConfig(t *testing.T) { func TestGenerateConfig(t *testing.T) {
configWithoutServices := `{
"servers": {
"http": {
"listen": [":80"],
"routes": [{
"match": [{"path": ["/.uncloud-verify"]}],
"handle": [{
"body": "verification-response-body",
"handler": "static_response",
"status_code": 200
}]
}]
},
"https": {
"listen": [":443"]
}
}
}`
tests := []struct { tests := []struct {
name string name string
containers []api.ServiceContainer containers []api.ServiceContainer
@@ -23,25 +42,8 @@ func TestGenerateConfig(t *testing.T) {
{ {
name: "empty containers", name: "empty containers",
containers: []api.ServiceContainer{}, containers: []api.ServiceContainer{},
want: `{ want: configWithoutServices,
"servers": { wantErr: false,
"http": {
"listen": [":80"],
"routes": [{
"match": [{"path": ["/.uncloud-verify"]}],
"handle": [{
"body": "verification-response-body",
"handler": "static_response",
"status_code": 200
}]
}]
},
"https": {
"listen": [":443"]
}
}
}`,
wantErr: false,
}, },
{ {
@@ -231,24 +233,7 @@ func TestGenerateConfig(t *testing.T) {
containers: []api.ServiceContainer{ containers: []api.ServiceContainer{
newContainerWithoutNetwork("ignored.example.com:8080/http"), newContainerWithoutNetwork("ignored.example.com:8080/http"),
}, },
want: `{ want: configWithoutServices,
"servers": {
"http": {
"listen": [":80"],
"routes": [{
"match": [{"path": ["/.uncloud-verify"]}],
"handle": [{
"body": "verification-response-body",
"handler": "static_response",
"status_code": 200
}]
}]
},
"https": {
"listen": [":443"]
}
}
}`,
wantErr: false, wantErr: false,
}, },
{ {
@@ -256,45 +241,63 @@ func TestGenerateConfig(t *testing.T) {
containers: []api.ServiceContainer{ containers: []api.ServiceContainer{
newContainer("10.210.0.2", "invalid-port"), newContainer("10.210.0.2", "invalid-port"),
}, },
want: `{ want: configWithoutServices,
"servers": {
"http": {
"listen": [":80"],
"routes": [{
"match": [{"path": ["/.uncloud-verify"]}],
"handle": [{
"body": "verification-response-body",
"handler": "static_response",
"status_code": 200
}]
}]
},
"https": {
"listen": [":443"]
}
}
}`,
wantErr: false, wantErr: false,
}, },
{ {
name: "containers with unsupported protocols ignored", name: "containers with unsupported protocols and host mode ignored",
containers: []api.ServiceContainer{ containers: []api.ServiceContainer{
newContainer("10.210.0.2", "5000/tcp"), newContainer("10.210.0.2", "5000/tcp"),
newContainer("10.210.0.3", "5000/udp"), newContainer("10.210.0.3", "5000/udp"),
newContainer("10.210.0.4", "80:8080/tcp@host"), newContainer("10.210.0.4", "80:8080/tcp@host"),
}, },
want: configWithoutServices,
wantErr: false,
},
{
name: "restarting container ignored",
containers: []api.ServiceContainer{
newRestartingContainer("10.210.0.2", "app.example.com:8080/http"),
},
want: configWithoutServices,
wantErr: false,
},
{
name: "stopped container ignored",
containers: []api.ServiceContainer{
newStoppedContainer("10.210.0.2", "app.example.com:8080/http"),
},
want: configWithoutServices,
wantErr: false,
},
{
name: "mix of running, restarting, and stopped containers",
containers: []api.ServiceContainer{
newContainer("10.210.0.2", "app.example.com:8080/http"),
newRestartingContainer("10.210.0.3", "app.example.com:8080/http"),
newStoppedContainer("10.210.0.4", "app.example.com:8080/http"),
},
want: `{ want: `{
"servers": { "servers": {
"http": { "http": {
"listen": [":80"], "listen": [":80"],
"routes": [{ "routes": [
"match": [{"path": ["/.uncloud-verify"]}], {
"handle": [{ "match": [{"host": ["app.example.com"]}],
"body": "verification-response-body", "handle": [{
"handler": "static_response", "handler": "reverse_proxy",
"status_code": 200 "upstreams": [{"dial": "10.210.0.2:8080"}]
}] }]
}] },
{
"match": [{"path": ["/.uncloud-verify"]}],
"handle": [{
"body": "verification-response-body",
"handler": "static_response",
"status_code": 200
}]
}
]
}, },
"https": { "https": {
"listen": [":443"] "listen": [":443"]
@@ -326,7 +329,11 @@ func TestGenerateConfig(t *testing.T) {
func newContainer(ip string, ports ...string) api.ServiceContainer { func newContainer(ip string, ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",") portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{ return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{}, ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{ NetworkSettings: &types.NetworkSettings{
Networks: map[string]*network.EndpointSettings{ Networks: map[string]*network.EndpointSettings{
docker.NetworkName: { docker.NetworkName: {
@@ -345,7 +352,11 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer { func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
portsLabel := strings.Join(ports, ",") portsLabel := strings.Join(ports, ",")
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{ return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
ContainerJSONBase: &types.ContainerJSONBase{}, ContainerJSONBase: &types.ContainerJSONBase{
State: &types.ContainerState{
Running: true,
},
},
NetworkSettings: &types.NetworkSettings{ NetworkSettings: &types.NetworkSettings{
Networks: map[string]*network.EndpointSettings{ Networks: map[string]*network.EndpointSettings{
"other-network": { "other-network": {
@@ -360,3 +371,15 @@ func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
}, },
}}} }}}
} }
func newRestartingContainer(ip string, ports ...string) api.ServiceContainer {
ctr := newContainer(ip, ports...)
ctr.Container.State.Restarting = true
return ctr
}
func newStoppedContainer(ip string, ports ...string) api.ServiceContainer {
ctr := newContainer(ip, ports...)
ctr.Container.State.Running = false
return ctr
}