Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

daemon.ContainerLogs(): fix resource leak on follow #37576

Merged
merged 4 commits into from Sep 10, 2018

Conversation

kolyshkin
Copy link
Contributor

@kolyshkin kolyshkin commented Aug 1, 2018

When daemon.ContainerLogs() is called with options.follow=true
(as in docker logs --follow), the loggerutils.followLogs()
function never returns (even then the logs consumer is gone).
As a result, all the resources associated with it (including
an opened file descriptor for the log file being read, two FDs
for a pipe, and two FDs for inotify watch) are never released.

If this is repeated (such as by running docker logs --follow
and pressing Ctrl-C a few times), this results in DoS caused by
either hitting the limit of inotify watches, or the limit of
opened files. The only cure is daemon restart.

Apparently, what happens is:

  1. logs consumer is gone, properly calling (*LogWatcher).Close()

  2. WatchClose() is properly handled by a dedicated goroutine in
    followLogs(), cancelling the context.

  3. Upon receiving the ctx.Done(), the code tries to synchronously
    send the log message to the logWatcher.Msg channel. Since the
    channel reader is gone, this operation blocks forever, and
    resource cleanup set up in defer statements at the beginning
    of followLogs() never happens.

The fix is to remove the synchronous send.

This commit also removes the code after it, which tried to
read and send the rest of the log file. The code in question
first appeared in commit c0391bf ("Split reader interface from
logger interface"), but it is still unclear why it is/was
needed (it makes no sense to write logs once the consumer
signaled it is no longer interested in those).

There are a few alternative approaches to fix the issue,
such as:

a. amend the consumer to read all the data from the channel
until it is closed. This can be done in, say, (*LogWatcher) Close().
For one thing, this looks ugly (why read and write useless data?).
Also, this approach requires closing the channel from the sending
side, which, given the amount of exit paths, looks less elegant
than a solution in this commit.

b. always write to the channel asynchronously (i.e. in a non-blocking
fashion, i.e. inside a select). This is actually what happens
in the code after this commit.

Should fix #37391

Щас спою!
image from https://dubikvit.livejournal.com/409094.html

@thaJeztah
Copy link
Member

Looks like there's a failure; https://jenkins.dockerproject.org/job/Docker-PRs/50259/console

That test was marked as flaky a couple of times; https://github.com/moby/moby/search?q=TestLogsFollowSlowStdoutConsumer&type=Issues could that flakiness be related?

05:52:22 FAIL: docker_cli_logs_test.go:214: DockerSuite.TestLogsFollowSlowStdoutConsumer
05:52:22 
05:52:22 docker_cli_logs_test.go:246:
05:52:22     c.Assert(actual, checker.Equals, expected)
05:52:22 ... obtained int = 53412
05:52:22 ... expected int = 150000

@thaJeztah thaJeztah added the status/failing-ci Indicates that the PR in its current state fails the test suite label Aug 1, 2018
@kolyshkin
Copy link
Contributor Author

05:52:22 FAIL: docker_cli_logs_test.go:214: DockerSuite.TestLogsFollowSlowStdoutConsumer

Fortunately I know a bit about this test. OK, so now we know why this code was needed :) Need to think.

@kolyshkin
Copy link
Contributor Author

Simplest reproducer:

ID=$(docker run -d busybox sh -c "seq 1 100000"); docker logs --follow $ID | tail

Should output something like

...
99998
99999
100000

Instead it outputs:

...
51643
51644
51645

@thaJeztah
Copy link
Member

thaJeztah commented Aug 1, 2018

@kolyshkin but that's only with | tail, correct ? (is it because it starts reading while output is still being added, so at that moment 51645 is the last output?)

i.e., this works;

ID=$(docker run -d busybox sh -c "seq 1 100000"); sleep 3; docker logs --follow $ID | tail

And so does this (with a tty attached, so docker logs shows raw terminal output)

ID=$(docker run -dt busybox sh -c "seq 1 100000"); docker logs --follow $ID | tail

@kolyshkin
Copy link
Contributor Author

Nah, tail is just there to not clutter the terminal; it does not affect test case outcome. What tail does is it reads all the input into a circular buffer (sized N lines, default N is 20) until EOF, then prints the buffer contents.

It seems the problem is logWatcher.Close() is called when a container is stopped, not when a reader is gone. I will sleep on it and hopefully come up with something tomorrow.

@cpuguy83
Copy link
Member

cpuguy83 commented Aug 1, 2018

logWatcher.Close() is called when the client is gone:

moby/daemon/logs.go

Lines 106 to 114 in e158451

if cLogCreated {
defer func() {
if err = cLog.Close(); err != nil {
logrus.Errorf("Error closing logger: %v", err)
}
}()
}
// set up some defers
defer logs.Close()

Where cLog is the created log driver in cases where the container was already stopped and logs is the log watcher.

So we should not be trying to send logs when the log watcher is closed.
What may need to happen is to make sure to drain the message chan in daemon/logs.go after the log watcher is closed, otherwise we can end up in an easy to trigger race where a message send is happening while we are closing.
The buffer of this channel is also insanely high... a number which was just picked because someone asked for it.... I don't think we need a buffer, or if we do have a buffer it can be very small.

It would also be nice perhaps to change the log reader interface to take a context instead of closing the log watcher, may help simplify some of the logic.

Honestly, there are too many goroutines here (one layer added relatively recently to support swarm logs).

@kolyshkin
Copy link
Contributor Author

The (*logWatcher).Close() is also called from here:

// Close closes underlying file and signals all readers to stop.
func (l *JSONFileLogger) Close() error {
l.mu.Lock()
l.closed = true
err := l.writer.Close()
for r := range l.readers {
r.Close()
delete(l.readers, r)
}

So, it is called twice, once upon container stop, once upon consumer is gone. Whoever calls it first depends on the situation. In this scenario the logger is fast and the consumer is slow(er), so the first call is from container stop, thus (with the commit in this PR) the log consumer does not receive the remainder of the log.

If we comment out this line:

docker logs --follow works fine (with the commit from this PR), but it never exits (which, to my mind, is the correct behavior as the container could be started again, but is different from the current behavior).

For now, it seems like the best behavior would be to distinguish between "container stopped logging" and "logs consumer is gone" events, and act accordingly, i.e:

  1. when "logs consumer is gone" received, stop immediately. In order to not have a race, the receiver should be inside the same select as the message sender (I guess).
  2. when "container stopped logging" is received, disable the log rotation handling, otherwise continue until EOF.

Unfortunately it further complicates the code, which I wanted to avoid :-\

@codecov
Copy link

codecov bot commented Aug 6, 2018

Codecov Report

❗ No coverage uploaded for pull request base (master@7aa797f). Click here to learn what that means.
The diff coverage is 41.93%.

@@            Coverage Diff            @@
##             master   #37576   +/-   ##
=========================================
  Coverage          ?   36.13%           
=========================================
  Files             ?      609           
  Lines             ?    45056           
  Branches          ?        0           
=========================================
  Hits              ?    16283           
  Misses            ?    26532           
  Partials          ?     2241

@kolyshkin
Copy link
Contributor Author

It would also be nice perhaps to change the log reader interface to take a context instead of closing the log watcher, may help simplify some of the logic.

We have to react to two distinct events here:

  1. logs producer (a container) gone. In this case followLogs() should stop watching for and reacting to logrotate events, and treat EOF as being done, otherwise keep sucking and pushing logs.
  2. logs consumer (daemon.ContainerLogs()) gone. In this case we should clean up and be done.

A single context won't be enough here. Sigh :(

@kolyshkin kolyshkin force-pushed the logs-f-leak branch 3 times, most recently from e6c8001 to 16bc899 Compare August 6, 2018 22:37
@kolyshkin
Copy link
Contributor Author

kolyshkin commented Aug 6, 2018

OK, a test case DockerSuite.TestLogsFollowGoroutinesNoOutput uncovered a problem that I have fixed (not returning from followLogs() when consumer is gone but log is empty).

TODO: write a test case for #37391

@kolyshkin
Copy link
Contributor Author

ppc failure on DockerSwarmSuite.TearDownTest is #33041

@kolyshkin
Copy link
Contributor Author

OK, I removed the fixing commit and added the test case, doing a CI cycle to see if the newly added test case is detecting the issue.

@cpuguy83
Copy link
Member

cpuguy83 commented Aug 7, 2018

Yeah, so basically it looks like we just need to split notifications up. Would be nice not to modify the log watcher for this.

@kolyshkin
Copy link
Contributor Author

Yeah, so basically it looks like we just need to split notifications up.
Would be nice not to modify the log watcher for this.

Here's my current solution (will push here once test case is checked to work): kolyshkin@16bc8991a

It is somewhat ugly and big, so I am wide open to any ideas how to make it look better.

@kolyshkin kolyshkin force-pushed the logs-f-leak branch 2 times, most recently from 84681b8 to aa3bc5a Compare August 8, 2018 14:09
@kolyshkin kolyshkin force-pushed the logs-f-leak branch 2 times, most recently from 206574e to a91ea0a Compare August 30, 2018 05:33
@kolyshkin
Copy link
Contributor Author

OK, fixed the windows CI failure/issue by fixing bugs in pkg/filenotify/poller (see PR #37734). The commits from this PR is currently included in this one, for the sake of Windows CI. Once #37734 is merged I will rebase.

@kolyshkin
Copy link
Contributor Author

kolyshkin commented Aug 30, 2018

Windows CI failure <...> is apparently fixed by commit b3992ad.

Nope, the real fix is apparently commit 8269ff0dc5c (and that's another issue #27782 masks/workarounds). Anyway, all three fixes to filenotify make some sense.

@kolyshkin
Copy link
Contributor Author

@cpuguy83 are you aware of any (unit or integration) test cases to check log rotation? As this PR touches that precious code that deals with rotated log files, I want to assess whether it broke something in that area...

@kolyshkin
Copy link
Contributor Author

ci failures

janky:

01:52:53.778 FAIL: docker_cli_swarm_test.go:1376: DockerSwarmSuite.TestSwarmClusterRotateUnlockKey

power:

01:48:13.817 FAIL: docker_api_swarm_test.go:296: DockerSwarmSuite.TestAPISwarmLeaderElection
02:05:53.173 FAIL: docker_cli_swarm_test.go:1376: DockerSwarmSuite.TestSwarmClusterRotateUnlockKey

z:

01:52:20.860 FAIL: docker_cli_swarm_test.go:1376: DockerSwarmSuite.TestSwarmClusterRotateUnlockKey

both are flaky and unrelated to this PR

@kolyshkin
Copy link
Contributor Author

I think this PR is ready for review/merge

@kolyshkin kolyshkin changed the title [DO NOT MERGE] daemon.ContainerLogs(): fix resource leak on follow daemon.ContainerLogs(): fix resource leak on follow Aug 30, 2018
@thaJeztah thaJeztah added this to backlog in maintainers-session Aug 30, 2018
@kolyshkin
Copy link
Contributor Author

Speaking of log rotation, I was not sure I hadn't broken anything in that area, so I wrote a small test:

ID=$(docker run -d --log-opt=max-size=50K --log-opt=max-file=20 busybox sh -c 'i=0; while test $i -lt 10000; do echo $i; let i++; sleep 0; done')
docker logs --follow $ID | tee $ID.logs | wc -l

If it works, it should print 10000 (number of logs lines received).

It works on current master as well as with the code from this PR. I am not sure if I should make an integration test case out of it, but in any case this can be addressed in a followup.

PS note if I remove sleep 0 the test fails on both branches -- the logs are rotated way to fast to follow. This also means that the above test case has some empirical values built into it, and might not work on a system different from my laptop.

@kolyshkin
Copy link
Contributor Author

On my laptop, the success of following rotated logs depends on the output rate, not the log rotation rate. I guess it is because most time is spent decoding json :-\

Copy link
Member

@cpuguy83 cpuguy83 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good, minor comment on timeouts in the test.
LGTM

daemon/logger/loggerutils/logfile_test.go Outdated Show resolved Hide resolved
kolyshkin and others added 4 commits September 6, 2018 11:45
This code has many return statements, for some of them the
"end logs" or "end stream" message was not printed, giving
the impression that this "for" loop never ended.

Make sure that "begin logs" is to be followed by "end logs".

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
This test case checks that followLogs() exits once the reader is gone.
Currently it does not (i.e. this test is supposed to fail) due to moby#37391.

[kolyshkin@: test case Brian Goff, changelog and all bugs are by me]
Source: https://gist.github.com/cpuguy83/e538793de18c762608358ee0eaddc197

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
When daemon.ContainerLogs() is called with options.follow=true
(as in "docker logs --follow"), the "loggerutils.followLogs()"
function never returns (even then the logs consumer is gone).
As a result, all the resources associated with it (including
an opened file descriptor for the log file being read, two FDs
for a pipe, and two FDs for inotify watch) are never released.

If this is repeated (such as by running "docker logs --follow"
and pressing Ctrl-C a few times), this results in DoS caused by
either hitting the limit of inotify watches, or the limit of
opened files. The only cure is daemon restart.

Apparently, what happens is:

1. logs producer (a container) is gone, calling (*LogWatcher).Close()
for all its readers (daemon/logger/jsonfilelog/jsonfilelog.go:175).

2. WatchClose() is properly handled by a dedicated goroutine in
followLogs(), cancelling the context.

3. Upon receiving the ctx.Done(), the code in followLogs()
(daemon/logger/loggerutils/logfile.go#L626-L638) keeps to
send messages _synchronously_ (which is OK for now).

4. Logs consumer is gone (Ctrl-C is pressed on a terminal running
"docker logs --follow"). Method (*LogWatcher).Close() is properly
called (see daemon/logs.go:114). Since it was called before and
due to to once.Do(), nothing happens (which is kinda good, as
otherwise it will panic on closing a closed channel).

5. A goroutine (see item 3 above) keeps sending log messages
synchronously to the logWatcher.Msg channel. Since the
channel reader is gone, the channel send operation blocks forever,
and resource cleanup set up in defer statements at the beginning
of followLogs() never happens.

Alas, the fix is somewhat complicated:

1. Distinguish between close from logs producer and logs consumer.
To that effect,
 - yet another channel is added to LogWatcher();
 - {Watch,}Close() are renamed to {Watch,}ProducerGone();
 - {Watch,}ConsumerGone() are added;

*NOTE* that ProducerGone()/WatchProducerGone() pair is ONLY needed
in order to stop ConsumerLogs(follow=true) when a container is stopped;
otherwise we're not interested in it. In other words, we're only
using it in followLogs().

2. Code that was doing (logWatcher*).Close() is modified to either call
ProducerGone() or ConsumerGone(), depending on the context.

3. Code that was waiting for WatchClose() is modified to wait for
either ConsumerGone() or ProducerGone(), or both, depending on the
context.

4. followLogs() are modified accordingly:
 - context cancellation is happening on WatchProducerGone(),
and once it's received the FileWatcher is closed and waitRead()
returns errDone on EOF (i.e. log rotation handling logic is disabled);
 - due to this, code that was writing synchronously to logWatcher.Msg
can be and is removed as the code above it handles this case;
 - function returns once ConsumerGone is received, freeing all the
resources -- this is the bugfix itself.

While at it,

1. Let's also remove the ctx usage to simplify the code a bit.
It was introduced by commit a69a59f ("Decouple removing the
fileWatcher from reading") in order to fix a bug. The bug was actually
a deadlock in fsnotify, and the fix was just a workaround. Since then
the fsnofify bug has been fixed, and a new fsnotify was vendored in.
For more details, please see
moby#27782 (comment)

2. Since `(*filePoller).Close()` is fixed to remove all the files
being watched, there is no need to explicitly call
fileWatcher.Remove(name) anymore, so get rid of the extra code.

Should fix moby#37391

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
This should test that
 - all the messages produced are delivered (i.e. not lost)
 - followLogs() exits

Loosely based on the test having the same name by Brian Goff, see
https://gist.github.com/cpuguy83/e538793de18c762608358ee0eaddc197

Signed-off-by: Kir Kolyshkin <kolyshkin@gmail.com>
@kolyshkin
Copy link
Contributor Author

Test timeouts increased; rebased.

Copy link
Member

@cpuguy83 cpuguy83 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kolyshkin
Copy link
Contributor Author

CI is green 👯‍♂️

Copy link
Member

@vdemeester vdemeester left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🐯

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM, and all green; let's merge 👍

@thaJeztah thaJeztah merged commit 77faf15 into moby:master Sep 10, 2018
likevi-MSFT added a commit to likevi-MSFT/sblogdriver that referenced this pull request Sep 11, 2018
@thaJeztah thaJeztah removed this from backlog in maintainers-session Sep 13, 2018
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Using follow log output causes json-log file descriptors to leak
7 participants