improve container events watch robustness

Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
This commit is contained in:
Nicolas De Loof 2021-05-26 12:17:37 +02:00
parent c16834cba6
commit 6f6ae071d6
No known key found for this signature in database
GPG Key ID: 9858809D6F8F6E7E
5 changed files with 96 additions and 88 deletions

View File

@ -379,7 +379,9 @@ type ContainerEvent struct {
Container string Container string
Service string Service string
Line string Line string
// ContainerEventExit only
ExitCode int ExitCode int
Restarting bool
} }
const ( const (

View File

@ -62,19 +62,22 @@ func (p *printer) Run(cascadeStop bool, exitCodeFrom string, stopFn func() error
containers := map[string]struct{}{} containers := map[string]struct{}{}
for { for {
event := <-p.queue event := <-p.queue
container := event.Container
switch event.Type { switch event.Type {
case UserCancel: case UserCancel:
aborting = true aborting = true
case ContainerEventAttach: case ContainerEventAttach:
if _, ok := containers[event.Container]; ok { if _, ok := containers[container]; ok {
continue continue
} }
containers[event.Container] = struct{}{} containers[container] = struct{}{}
p.consumer.Register(event.Container) p.consumer.Register(container)
case ContainerEventExit: case ContainerEventExit:
delete(containers, event.Container) if !event.Restarting {
delete(containers, container)
}
if !aborting { if !aborting {
p.consumer.Status(event.Container, fmt.Sprintf("exited with code %d", event.ExitCode)) p.consumer.Status(container, fmt.Sprintf("exited with code %d", event.ExitCode))
} }
if cascadeStop { if cascadeStop {
if !aborting { if !aborting {
@ -99,7 +102,7 @@ func (p *printer) Run(cascadeStop bool, exitCodeFrom string, stopFn func() error
} }
case ContainerEventLog: case ContainerEventLog:
if !aborting { if !aborting {
p.consumer.Log(event.Container, event.Service, event.Line) p.consumer.Log(container, event.Service, event.Line)
} }
} }
} }

View File

@ -20,68 +20,30 @@ import (
"context" "context"
"github.com/docker/compose-cli/api/compose" "github.com/docker/compose-cli/api/compose"
convert "github.com/docker/compose-cli/local/moby"
"github.com/docker/compose-cli/utils" "github.com/docker/compose-cli/utils"
"github.com/compose-spec/compose-go/types" "github.com/compose-spec/compose-go/types"
moby "github.com/docker/docker/api/types" moby "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container" "github.com/pkg/errors"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
) )
func (s *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error { func (s *composeService) Start(ctx context.Context, project *types.Project, options compose.StartOptions) error {
listener := options.Attach
if len(options.Services) == 0 { if len(options.Services) == 0 {
options.Services = project.ServiceNames() options.Services = project.ServiceNames()
} }
var containers Containers eg, ctx := errgroup.WithContext(ctx)
if options.Attach != nil { if listener != nil {
attached, err := s.attach(ctx, project, options.Attach, options.Services) attached, err := s.attach(ctx, project, listener, options.Services)
if err != nil {
return err
}
containers = attached
// Watch events to capture container restart and re-attach
go func() {
watched := map[string]struct{}{}
for _, c := range containers {
watched[c.ID] = struct{}{}
}
s.Events(ctx, project.Name, compose.EventsOptions{ // nolint: errcheck
Services: options.Services,
Consumer: func(event compose.Event) error {
if event.Status == "start" {
inspect, err := s.apiClient.ContainerInspect(ctx, event.Container)
if err != nil { if err != nil {
return err return err
} }
container := moby.Container{ eg.Go(func() error {
ID: event.Container, return s.watchContainers(project, options.Services, listener, attached)
Names: []string{inspect.Name},
State: convert.ContainerRunning,
Labels: map[string]string{
projectLabel: project.Name,
serviceLabel: event.Service,
},
}
// Just ignore errors when reattaching to already crashed containers
s.attachContainer(ctx, container, options.Attach, project) // nolint: errcheck
if _, ok := watched[inspect.ID]; !ok {
// a container has been added to the service, see --scale option
watched[inspect.ID] = struct{}{}
go func() {
s.waitContainer(container, options.Attach) // nolint: errcheck
}()
}
}
return nil
},
}) })
}()
} }
err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error { err := InDependencyOrder(ctx, project, func(c context.Context, service types.ServiceConfig) error {
@ -93,34 +55,79 @@ func (s *composeService) Start(ctx context.Context, project *types.Project, opti
if err != nil { if err != nil {
return err return err
} }
if options.Attach == nil {
return nil
}
eg, ctx := errgroup.WithContext(ctx)
for _, c := range containers {
c := c
eg.Go(func() error {
return s.waitContainer(c, options.Attach)
})
}
return eg.Wait() return eg.Wait()
} }
func (s *composeService) waitContainer(c moby.Container, listener compose.ContainerEventListener) error { // watchContainers uses engine events to capture container start/die and notify ContainerEventListener
statusC, errC := s.apiClient.ContainerWait(context.Background(), c.ID, container.WaitConditionNotRunning) func (s *composeService) watchContainers(project *types.Project, services []string, listener compose.ContainerEventListener, containers Containers) error {
name := getContainerNameWithoutProject(c) watched := map[string]int{}
select { for _, c := range containers {
case status := <-statusC: watched[c.ID] = 0
}
ctx, stop := context.WithCancel(context.Background())
err := s.Events(ctx, project.Name, compose.EventsOptions{
Services: services,
Consumer: func(event compose.Event) error {
inspected, err := s.apiClient.ContainerInspect(ctx, event.Container)
if err != nil {
return err
}
container := moby.Container{
ID: inspected.ID,
Names: []string{inspected.Name},
Labels: inspected.Config.Labels,
}
name := getContainerNameWithoutProject(container)
if event.Status == "die" {
restarted := watched[container.ID]
watched[container.ID] = restarted + 1
// Container terminated.
willRestart := inspected.HostConfig.RestartPolicy.MaximumRetryCount > restarted
listener(compose.ContainerEvent{ listener(compose.ContainerEvent{
Type: compose.ContainerEventExit, Type: compose.ContainerEventExit,
Container: name, Container: name,
Service: c.Labels[serviceLabel], Service: container.Labels[serviceLabel],
ExitCode: int(status.StatusCode), ExitCode: inspected.State.ExitCode,
Restarting: willRestart,
}) })
if !willRestart {
// we're done with this one
delete(watched, container.ID)
}
if len(watched) == 0 {
// all project containers stopped, we're done
stop()
}
return nil return nil
case err := <-errC: }
if event.Status == "start" {
count, ok := watched[container.ID]
mustAttach := ok && count > 0 // Container restarted, need to re-attach
if !ok {
// A new container has just been added to service by scale
watched[container.ID] = 0
mustAttach = true
}
if mustAttach {
// Container restarted, need to re-attach
err := s.attachContainer(ctx, container, listener, project)
if err != nil {
return err return err
} }
}
}
return nil
},
})
if errors.Is(ctx.Err(), context.Canceled) {
return nil
}
return err
} }

View File

@ -27,6 +27,7 @@ import (
"testing" "testing"
"time" "time"
testify "github.com/stretchr/testify/assert"
"gotest.tools/v3/assert" "gotest.tools/v3/assert"
"gotest.tools/v3/icmd" "gotest.tools/v3/icmd"
@ -197,10 +198,10 @@ func TestAttachRestart(t *testing.T) {
c.WaitForCondition(func() (bool, string) { c.WaitForCondition(func() (bool, string) {
debug := res.Combined() debug := res.Combined()
return strings.Count(res.Stdout(), "another_1 exited with code 1") == 3, fmt.Sprintf("'another_1 exited with code 1' not found 3 times in : \n%s\n", debug) return strings.Count(res.Stdout(), "failing_1 exited with code 1") == 3, fmt.Sprintf("'failing_1 exited with code 1' not found 3 times in : \n%s\n", debug)
}, 2*time.Minute, 2*time.Second) }, 2*time.Minute, 2*time.Second)
assert.Equal(t, strings.Count(res.Stdout(), "another_1 | world"), 3, res.Combined()) assert.Equal(t, strings.Count(res.Stdout(), "failing_1 | world"), 3, res.Combined())
} }
func TestInitContainer(t *testing.T) { func TestInitContainer(t *testing.T) {
@ -208,7 +209,5 @@ func TestInitContainer(t *testing.T) {
res := c.RunDockerOrExitError("compose", "--ansi=never", "--project-directory", "./fixtures/init-container", "up") res := c.RunDockerOrExitError("compose", "--ansi=never", "--project-directory", "./fixtures/init-container", "up")
defer c.RunDockerOrExitError("compose", "-p", "init-container", "down") defer c.RunDockerOrExitError("compose", "-p", "init-container", "down")
output := res.Stdout() testify.Regexp(t, "foo_1 | hello(?m:.*)bar_1 | world", res.Stdout())
assert.Assert(t, strings.Contains(output, "foo_1 | hello\nbar_1 | world"), res.Combined())
} }

View File

@ -1,8 +1,5 @@
services: services:
simple: failing:
image: alpine
command: sh -c "sleep infinity"
another:
image: alpine image: alpine
command: sh -c "sleep 0.1 && echo world && /bin/false" command: sh -c "sleep 0.1 && echo world && /bin/false"
deploy: deploy: