Allow discovering non-running Docker containers

This commit is contained in:
Alexis Couvreur 2025-10-24 08:08:04 -04:00 committed by GitHub
parent 5c489c05fc
commit 10be359327
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 600 additions and 11 deletions

View File

@ -688,6 +688,27 @@ You can tell Traefik to consider (or not) the container by setting `traefik.enab
This option overrides the value of `exposedByDefault`.
#### `traefik.docker.allownonrunning`
```yaml
- "traefik.docker.allownonrunning=true"
```
By default, Traefik only considers containers in "running" state.
This option controls whether containers that are not in "running" state (e.g., stopped, paused, exited) should still be visible to Traefik for service discovery.
When this label is set to true, Traefik will:
- Keep the router and service configuration even when the container is not running
- Create services with empty backend server lists
- Return 503 Service Unavailable for requests to stopped containers (instead of 404 Not Found)
- Execute the full middleware chain, allowing middlewares to intercept requests
!!! warning "Configuration Collision"
As the `traefik.docker.allownonrunning` enables the discovery of all containers exposing this option disregarding their state,
if multiple stopped containers expose the same router but their configurations diverge, then the routers will be dropped.
#### `traefik.docker.network`
```yaml
@ -700,4 +721,5 @@ If a container is linked to several networks, be sure to set the proper network
otherwise it will randomly pick one (depending on how docker is returning them).
!!! warning
When deploying a stack from a compose file `stack`, the networks defined are prefixed with `stack`.

View File

@ -32,7 +32,7 @@ func (s *DockerSuite) TearDownSuite() {
}
func (s *DockerSuite) TearDownTest() {
s.composeStop("simple", "withtcplabels", "withlabels1", "withlabels2", "withonelabelmissing", "powpow")
s.composeStop("simple", "withtcplabels", "withlabels1", "withlabels2", "withonelabelmissing", "powpow", "nonRunning")
}
func (s *DockerSuite) TestSimpleConfiguration() {
@ -222,3 +222,59 @@ func (s *DockerSuite) TestRestartDockerContainers() {
err = try.GetRequest("http://127.0.0.1:8080/api/rawdata", 60*time.Second, try.BodyContains("powpow"))
require.NoError(s.T(), err)
}
func (s *DockerSuite) TestDockerAllowNonRunning() {
tempObjects := struct {
DockerHost string
DefaultRule string
}{
DockerHost: s.getDockerHost(),
DefaultRule: "Host(`{{ normalize .Name }}.docker.localhost`)",
}
file := s.adaptFile("fixtures/docker/simple.toml", tempObjects)
s.composeUp("nonRunning")
// Start traefik
s.traefikCmd(withConfigFile(file))
// Verify the container is working when running
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
req.Host = "non.running.host"
resp, err := try.ResponseUntilStatusCode(req, 3*time.Second, http.StatusOK)
require.NoError(s.T(), err)
body, err := io.ReadAll(resp.Body)
require.NoError(s.T(), err)
assert.Contains(s.T(), string(body), "Hostname:")
// Verify the router exists in Traefik configuration
err = try.GetRequest("http://127.0.0.1:8080/api/http/routers", 1*time.Second, try.BodyContains("NonRunning"))
require.NoError(s.T(), err)
// Stop the container
s.composeStop("nonRunning")
// Wait a bit for container stop to be detected
time.Sleep(2 * time.Second)
// Verify the router still exists in configuration even though container is stopped
// This is the key test - the router should persist due to allowNonRunning=true
err = try.GetRequest("http://127.0.0.1:8080/api/http/routers", 10*time.Second, try.BodyContains("NonRunning"))
require.NoError(s.T(), err)
// Verify the service still exists in configuration
err = try.GetRequest("http://127.0.0.1:8080/api/http/services", 1*time.Second, try.BodyContains("nonRunning"))
require.NoError(s.T(), err)
// HTTP requests should fail (502 Bad Gateway) since container is stopped but router exists
req, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/", nil)
require.NoError(s.T(), err)
req.Host = "non.running.host"
err = try.Request(req, 3*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable))
require.NoError(s.T(), err)
}

View File

@ -35,3 +35,9 @@ services:
labels:
traefik.http.Routers.Super.Rule: Host(`my.super.host`)
traefik.http.Services.powpow.LoadBalancer.server.Port: 2375
nonRunning:
image: traefik/whoami
labels:
traefik.http.Routers.NonRunning.Rule: Host(`non.running.host`)
traefik.docker.allownonrunning: "true"

View File

@ -12,6 +12,7 @@ func containerJSON(ops ...func(*containertypes.InspectResponse)) containertypes.
ContainerJSONBase: &containertypes.ContainerJSONBase{
Name: "fake",
HostConfig: &containertypes.HostConfig{},
State: &containertypes.State{},
},
Config: &containertypes.Config{},
NetworkSettings: &containertypes.NetworkSettings{

View File

@ -114,6 +114,11 @@ func (p *DynConfBuilder) buildTCPServiceConfiguration(ctx context.Context, conta
}
}
// Keep an empty server load-balancer for non-running containers.
if container.Status != "" && container.Status != containertypes.StateRunning {
return nil
}
// Keep an empty server load-balancer for unhealthy containers.
if container.Health != "" && container.Health != containertypes.Healthy {
return nil
}
@ -138,6 +143,11 @@ func (p *DynConfBuilder) buildUDPServiceConfiguration(ctx context.Context, conta
}
}
// Keep an empty server load-balancer for non-running containers.
if container.Status != "" && container.Status != containertypes.StateRunning {
return nil
}
// Keep an empty server load-balancer for unhealthy containers.
if container.Health != "" && container.Health != containertypes.Healthy {
return nil
}
@ -164,6 +174,11 @@ func (p *DynConfBuilder) buildServiceConfiguration(ctx context.Context, containe
}
}
// Keep an empty server load-balancer for non-running containers.
if container.Status != "" && container.Status != containertypes.StateRunning {
return nil
}
// Keep an empty server load-balancer for unhealthy containers.
if container.Health != "" && container.Health != containertypes.Healthy {
return nil
}
@ -196,6 +211,19 @@ func (p *DynConfBuilder) keepContainer(ctx context.Context, container dockerData
return false
}
// AllowNonRunning has precedence over AllowEmptyServices.
// If AllowNonRunning is true, we don't care about the container health/status,
// and we need to quit before checking it.
// Only configurable with the Docker provider.
if container.ExtraConf.AllowNonRunning {
return true
}
if container.Status != "" && container.Status != containertypes.StateRunning {
logger.Debug().Msg("Filtering non running container")
return false
}
if !p.AllowEmptyServices && container.Health != "" && container.Health != containertypes.Healthy {
logger.Debug().Msg("Filtering unhealthy or starting container")
return false

View File

@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/require"
ptypes "github.com/traefik/paerser/types"
"github.com/traefik/traefik/v3/pkg/config/dynamic"
"github.com/traefik/traefik/v3/pkg/provider"
"github.com/traefik/traefik/v3/pkg/tls"
"github.com/traefik/traefik/v3/pkg/types"
)
@ -3935,6 +3936,464 @@ func TestDynConfBuilder_build(t *testing.T) {
}
}
func TestDynConfBuilder_build_allowNonRunning(t *testing.T) {
testCases := []struct {
desc string
containers []dockerData
expected *dynamic.Configuration
}{
{
desc: "exited container with allowNonRunning=true should create router and service without servers",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "exited",
Health: "",
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"Test": {
Service: "Test",
Rule: "Host(`Test`)",
DefaultRule: true,
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"Test": {
LoadBalancer: &dynamic.ServersLoadBalancer{
PassHostHeader: pointer(true),
Strategy: "wrr",
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "exited container with allowNonRunning=false should not create anything",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "exited",
Health: "",
ExtraConf: configuration{
Enable: true,
AllowNonRunning: false,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "running container with allowNonRunning=true should work normally with servers",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "running",
Health: "",
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"Test": {
Service: "Test",
Rule: "Host(`Test`)",
DefaultRule: true,
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"Test": {
LoadBalancer: &dynamic.ServersLoadBalancer{
Servers: []dynamic.Server{
{
URL: "http://127.0.0.1:80",
},
},
PassHostHeader: pointer(true),
Strategy: "wrr",
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "created container with allowNonRunning=true should create router and service without servers)",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "created",
Health: "",
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"Test": {
Service: "Test",
Rule: "Host(`Test`)",
DefaultRule: true,
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"Test": {
LoadBalancer: &dynamic.ServersLoadBalancer{
PassHostHeader: pointer(true),
Strategy: "wrr",
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "dead container with allowNonRunning=true should create router and service without servers)",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "dead",
Health: "",
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{
"Test": {
Service: "Test",
Rule: "Host(`Test`)",
DefaultRule: true,
},
},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{
"Test": {
LoadBalancer: &dynamic.ServersLoadBalancer{
PassHostHeader: pointer(true),
Strategy: "wrr",
ResponseForwarding: &dynamic.ResponseForwarding{
FlushInterval: ptypes.Duration(100 * time.Millisecond),
},
},
},
},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "exited container with TCP configuration and allowNonRunning=true should create TCP service without servers",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "exited",
Health: "",
Labels: map[string]string{
"traefik.tcp.routers.Test.rule": "HostSNI(`test.localhost`)",
},
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/tcp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{
"Test": {
Service: "Test",
Rule: "HostSNI(`test.localhost`)",
},
},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{
"Test": {
LoadBalancer: &dynamic.TCPServersLoadBalancer{},
},
},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{},
Services: map[string]*dynamic.UDPService{},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
{
desc: "exited container with UDP configuration and allowNonRunning=true should create UDP service without servers",
containers: []dockerData{
{
ServiceName: "Test",
Name: "Test",
Status: "exited",
Health: "",
Labels: map[string]string{
"traefik.udp.routers.Test.entrypoints": "udp",
},
ExtraConf: configuration{
Enable: true,
AllowNonRunning: true,
},
NetworkSettings: networkSettings{
NetworkMode: "bridge",
Ports: nat.PortMap{
"80/udp": []nat.PortBinding{},
},
Networks: map[string]*networkData{
"bridge": {
Name: "bridge",
Addr: "127.0.0.1",
},
},
},
},
},
expected: &dynamic.Configuration{
TCP: &dynamic.TCPConfiguration{
Routers: map[string]*dynamic.TCPRouter{},
Middlewares: map[string]*dynamic.TCPMiddleware{},
Services: map[string]*dynamic.TCPService{},
ServersTransports: map[string]*dynamic.TCPServersTransport{},
},
UDP: &dynamic.UDPConfiguration{
Routers: map[string]*dynamic.UDPRouter{
"Test": {
Service: "Test",
EntryPoints: []string{"udp"},
},
},
Services: map[string]*dynamic.UDPService{
"Test": {
LoadBalancer: &dynamic.UDPServersLoadBalancer{},
},
},
},
HTTP: &dynamic.HTTPConfiguration{
Routers: map[string]*dynamic.Router{},
Middlewares: map[string]*dynamic.Middleware{},
Services: map[string]*dynamic.Service{},
ServersTransports: map[string]*dynamic.ServersTransport{},
},
TLS: &dynamic.TLSConfiguration{
Stores: map[string]tls.Store{},
},
},
},
}
for _, test := range testCases {
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
defaultRuleTpl, err := provider.MakeDefaultRuleTemplate(DefaultTemplateRule, nil)
require.NoError(t, err)
p := Shared{
ExposedByDefault: true,
DefaultRule: DefaultTemplateRule,
defaultRuleTpl: defaultRuleTpl,
}
builder := NewDynConfBuilder(p, nil, false)
configuration := builder.build(t.Context(), test.containers)
assert.Equal(t, test.expected, configuration)
})
}
}
func TestDynConfBuilder_getIPPort_docker(t *testing.T) {
type expected struct {
ip string

View File

@ -10,6 +10,7 @@ type dockerData struct {
ID string
ServiceName string
Name string
Status string
Labels map[string]string // List of labels set to container or service
NetworkSettings networkSettings
Health string

View File

@ -165,7 +165,9 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
}
func (p *Provider) listContainers(ctx context.Context, dockerClient client.ContainerAPIClient) ([]dockerData, error) {
containerList, err := dockerClient.ContainerList(ctx, container.ListOptions{})
containerList, err := dockerClient.ContainerList(ctx, container.ListOptions{
All: true,
})
if err != nil {
return nil, err
}

View File

@ -43,9 +43,9 @@ func inspectContainers(ctx context.Context, dockerClient client.ContainerAPIClie
return dockerData{}
}
// This condition is here to avoid to have empty IP https://github.com/traefik/traefik/issues/2459
// We register only container which are running
if containerInspected.ContainerJSONBase != nil && containerInspected.ContainerJSONBase.State != nil && containerInspected.ContainerJSONBase.State.Running {
// Always parse all containers (running and stopped)
// The allowNonRunning filtering will be applied later in service configuration
if containerInspected.ContainerJSONBase != nil && containerInspected.ContainerJSONBase.State != nil {
return parseContainer(containerInspected)
}
@ -61,6 +61,7 @@ func parseContainer(container containertypes.InspectResponse) dockerData {
dData.ID = container.ContainerJSONBase.ID
dData.Name = container.ContainerJSONBase.Name
dData.ServiceName = dData.Name // Default ServiceName to be the container's Name.
dData.Status = container.ContainerJSONBase.State.Status
if container.ContainerJSONBase.HostConfig != nil {
dData.NetworkSettings.NetworkMode = container.ContainerJSONBase.HostConfig.NetworkMode

View File

@ -16,17 +16,24 @@ const (
// configuration contains information from the labels that are globals (not related to the dynamic configuration)
// or specific to the provider.
type configuration struct {
Enable bool
Network string
LBSwarm bool
Enable bool
Network string
LBSwarm bool
AllowNonRunning bool
}
type labelConfiguration struct {
Enable bool
Docker *specificConfiguration
Docker *dockerSpecificConfiguration
Swarm *specificConfiguration
}
type dockerSpecificConfiguration struct {
Network *string
LBSwarm bool
AllowNonRunning bool
}
type specificConfiguration struct {
Network *string
LBSwarm bool
@ -43,9 +50,15 @@ func (p *Shared) extractDockerLabels(container dockerData) (configuration, error
network = *conf.Docker.Network
}
var allowNonRunning bool
if conf.Docker != nil {
allowNonRunning = conf.Docker.AllowNonRunning
}
return configuration{
Enable: conf.Enable,
Network: network,
Enable: conf.Enable,
Network: network,
AllowNonRunning: allowNonRunning,
}, nil
}