Files
sablier/app/providers/docker/docker.go
Alexis Couvreur be7ace0e05 perf(providers): retrieve state on start instead of assuming starting (#350)
When an instance does not exist yet and needs to be started, its status is not assumed to be starting anymore.

Instead, the statue will be retrieved from the provider. This changes one thing, it's that you may be able to start and access your services instantly because they'll be instantly seen as ready.

With this change, you might want to make sure that your containers have a proper healthcheck used to determine when the application is able to process incoming requests.

* refactor: add interface guards

* refactor(providers): remove instance.State as a return value from Stop and Start

* test(e2e): add healthcheck on nginx container

Because now the container check is so fast, we need to add a delay on which the container is considered started and healthy to have a proper waiting page.

* fix(tests): using acouvreur/whoami:v1.10.2 instead of containous/whoami:v1.5.0

This image simply retrieve the curl binary from curlimages/curl:8.8.0 to be able to add proper docker healthcheck commands.

Once this is merged with traefik/whoami, I'll update back to the original image.

See https://github.com/traefik/whoami/issues/33
2024-07-08 00:10:39 -04:00

157 lines
5.0 KiB
Go

package docker
import (
"context"
"errors"
"fmt"
"github.com/acouvreur/sablier/app/discovery"
"github.com/acouvreur/sablier/app/providers"
"io"
"strings"
"github.com/docker/docker/api/types/container"
"github.com/acouvreur/sablier/app/instance"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/events"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/client"
log "github.com/sirupsen/logrus"
)
// Interface guard
var _ providers.Provider = (*DockerClassicProvider)(nil)
type DockerClassicProvider struct {
Client client.APIClient
desiredReplicas int
}
func NewDockerClassicProvider() (*DockerClassicProvider, error) {
cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
if err != nil {
return nil, fmt.Errorf("cannot create docker client: %v", err)
}
serverVersion, err := cli.ServerVersion(context.Background())
if err != nil {
return nil, fmt.Errorf("cannot connect to docker host: %v", err)
}
log.Tracef("connection established with docker %s (API %s)", serverVersion.Version, serverVersion.APIVersion)
return &DockerClassicProvider{
Client: cli,
desiredReplicas: 1,
}, nil
}
func (provider *DockerClassicProvider) GetGroups(ctx context.Context) (map[string][]string, error) {
args := filters.NewArgs()
args.Add("label", fmt.Sprintf("%s=true", discovery.LabelEnable))
containers, err := provider.Client.ContainerList(ctx, container.ListOptions{
All: true,
Filters: args,
})
if err != nil {
return nil, err
}
groups := make(map[string][]string)
for _, c := range containers {
groupName := c.Labels[discovery.LabelGroup]
if len(groupName) == 0 {
groupName = discovery.LabelGroupDefaultValue
}
group := groups[groupName]
group = append(group, strings.TrimPrefix(c.Names[0], "/"))
groups[groupName] = group
}
log.Debug(fmt.Sprintf("%v", groups))
return groups, nil
}
func (provider *DockerClassicProvider) Start(ctx context.Context, name string) error {
return provider.Client.ContainerStart(ctx, name, container.StartOptions{})
}
func (provider *DockerClassicProvider) Stop(ctx context.Context, name string) error {
return provider.Client.ContainerStop(ctx, name, container.StopOptions{})
}
func (provider *DockerClassicProvider) GetState(ctx context.Context, name string) (instance.State, error) {
spec, err := provider.Client.ContainerInspect(ctx, name)
if err != nil {
return instance.ErrorInstanceState(name, err, provider.desiredReplicas)
}
// "created", "running", "paused", "restarting", "removing", "exited", or "dead"
switch spec.State.Status {
case "created", "paused", "restarting", "removing":
return instance.NotReadyInstanceState(name, 0, provider.desiredReplicas)
case "running":
if spec.State.Health != nil {
// // "starting", "healthy" or "unhealthy"
if spec.State.Health.Status == "healthy" {
return instance.ReadyInstanceState(name, provider.desiredReplicas)
} else if spec.State.Health.Status == "unhealthy" {
if len(spec.State.Health.Log) >= 1 {
lastLog := spec.State.Health.Log[len(spec.State.Health.Log)-1]
return instance.UnrecoverableInstanceState(name, fmt.Sprintf("container is unhealthy: %s (%d)", lastLog.Output, lastLog.ExitCode), provider.desiredReplicas)
} else {
return instance.UnrecoverableInstanceState(name, "container is unhealthy: no log available", provider.desiredReplicas)
}
} else {
return instance.NotReadyInstanceState(name, 0, provider.desiredReplicas)
}
}
return instance.ReadyInstanceState(name, provider.desiredReplicas)
case "exited":
if spec.State.ExitCode != 0 {
return instance.UnrecoverableInstanceState(name, fmt.Sprintf("container exited with code \"%d\"", spec.State.ExitCode), provider.desiredReplicas)
}
return instance.NotReadyInstanceState(name, 0, provider.desiredReplicas)
case "dead":
return instance.UnrecoverableInstanceState(name, "container in \"dead\" state cannot be restarted", provider.desiredReplicas)
default:
return instance.UnrecoverableInstanceState(name, fmt.Sprintf("container status \"%s\" not handled", spec.State.Status), provider.desiredReplicas)
}
}
func (provider *DockerClassicProvider) NotifyInstanceStopped(ctx context.Context, instance chan<- string) {
msgs, errs := provider.Client.Events(ctx, types.EventsOptions{
Filters: filters.NewArgs(
filters.Arg("scope", "local"),
filters.Arg("type", string(events.ContainerEventType)),
filters.Arg("event", "die"),
),
})
for {
select {
case msg, ok := <-msgs:
if !ok {
log.Error("provider event stream is closed")
return
}
// Send the container that has died to the channel
instance <- strings.TrimPrefix(msg.Actor.Attributes["name"], "/")
case err, ok := <-errs:
if !ok {
log.Error("provider event stream is closed", err)
return
}
if errors.Is(err, io.EOF) {
log.Debug("provider event stream closed")
return
}
log.Error("provider event stream error", err)
case <-ctx.Done():
return
}
}
}