-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix: sam local invoke leaks Docker container and temp directory on OOM #9184
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
base: develop
Are you sure you want to change the base?
Changes from all commits
5950ac4
3f23ceb
67676eb
41f7ea8
c298d1b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -356,10 +356,22 @@ def _on_invoke_done(self, container): | |
| container: Container | ||
| The current running container | ||
| """ | ||
| if container: | ||
| self._check_exit_state(container) | ||
| self._container_manager.stop(container) | ||
| self._clean_decompressed_paths() | ||
| try: | ||
| if container: | ||
| self._check_exit_state(container) | ||
| finally: | ||
| # Best-effort cleanup: a failure here should not replace an in-flight exception | ||
| # from _check_exit_state() above (e.g. ContainerFailureError on OOM) with a raw | ||
| # Docker/OS error, and each step must run independently of the other's success. | ||
| if container: | ||
| try: | ||
| self._container_manager.stop(container) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [RESOURCE_MANAGEMENT] # samcli/local/docker/manager.py:111
def stop(self, container: Container) -> None:
if self.do_shutdown_event:
container.stop()
container.delete()
Guaranteeing removal requires def stop(self, container: Container) -> None:
try:
if self.do_shutdown_event:
container.stop()
finally:
container.delete() |
||
| except Exception: | ||
| LOG.warning("Failed to stop/remove container during cleanup", exc_info=True) | ||
| try: | ||
| self._clean_decompressed_paths() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [RESOURCE_MANAGEMENT] Swallowing the exception from # samcli/local/lambdafn/runtime.py:480
def cleandecompressed_paths(self):
LOG.debug("Cleaning all decompressed code dirs")
with self._lock:
for decompressed_dir in self._temp_uncompressed_paths_to_be_cleaned:
shutil.rmtree(decompressed_dir)
self._temp_uncompressed_paths_to_be_cleaned = []If Making the loop itself per-path resilient fixes the root cause and makes the outer def cleandecompressed_paths(self):
LOG.debug("Cleaning all decompressed code dirs")
with self._lock:
paths_to_clean = self._temp_uncompressed_paths_to_be_cleaned
self._temp_uncompressed_paths_to_be_cleaned = []
for decompressed_dir in paths_to_clean:
try:
shutil.rmtree(decompressed_dir)
except OSError:
LOG.warning("Failed to remove temporary directory %s", decompressed_dir, exc_info=True) |
||
| except Exception: | ||
| LOG.warning("Failed to clean decompressed code directories during cleanup", exc_info=True) | ||
|
|
||
| def _check_exit_state(self, container: Container): | ||
| """ | ||
|
|
@@ -450,7 +462,12 @@ def _get_code_dir(self, code_path: str) -> str: | |
|
|
||
| if code_path and os.path.isfile(code_path) and code_path.endswith(self.SUPPORTED_ARCHIVE_EXTENSIONS): | ||
| decompressed_dir: str = _unzip_file(code_path, mount_symlinks=self._mount_symlinks) | ||
| self._temp_uncompressed_paths_to_be_cleaned += [decompressed_dir] | ||
| # Must hold the same lock _clean_decompressed_paths() uses to swap this list out -- | ||
| # `start-api`/`start-lambda` run threaded, so a request thread appending here can | ||
| # race with a concurrent cleanup's snapshot-and-clear, either losing this entry | ||
| # entirely or (worse) having it removed by rmtree while still in use. | ||
| with self._lock: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CONCURRENCY] Taking self._lock around the append does fix the torn-list/lost-entry race, but it does not prevent the second scenario the new comment claims it does — "having it removed by rmtree while still in use". That race is caused by the list being process-global rather than per-invocation, and it survives this change: # thread A (request 1): create() -> getcode_dir() appends dirA, container A starts with dirA bind-mounted
# thread B (request 2): invoke finishes -> oninvoke_done() -> cleandecompressed_paths()
# snapshot = [dirA, dirB] -> rmtree(dirA) while container A is still running_clean_decompressed_paths() always removes every queued path, and _on_invoke_done() calls it on each individual invoke, so any concurrent in-flight invocation's decompressed directory is deleted underneath it. samcli/local/services/base_local_service.py:62 runs Flask multi-threaded whenever not debugging, and start-api/start-lambda use plain LambdaRuntime (not WarmLambdaRuntime) unless --warm-containers is passed, so two concurrent requests against .jar/.zip CodeUris hit this. The result is a container reading from a deleted host directory — a harder-to-diagnose failure than the leak being fixed. Fixing it properly means scoping the paths to the invocation (e.g. have create()/_get_code_dir() return the paths it unzipped and pass only those to _on_invoke_done() for cleanup). If that is out of scope for this PR, please drop the "removed by rmtree while still in use" claim from the comment so it doesn't read as a guarantee the code does not provide. |
||
| self._temp_uncompressed_paths_to_be_cleaned.append(decompressed_dir) | ||
| return decompressed_dir | ||
|
|
||
| LOG.debug("Code %s is not a zip/jar file", code_path) | ||
|
|
@@ -482,10 +499,27 @@ def _clean_decompressed_paths(self): | |
| Clean the temporary decompressed code dirs | ||
| """ | ||
| LOG.debug("Cleaning all decompressed code dirs") | ||
| # Snapshot and clear the list up front, under the lock, so that a failure removing one | ||
| # directory can't abort the loop and leave every entry (including ones already removed | ||
| # or added after this call started) stuck in the list forever -- since this list is only | ||
| # ever appended to, a stuck entry would otherwise block cleanup of every directory added | ||
| # in subsequent invokes for the lifetime of this LambdaRuntime instance. | ||
| with self._lock: | ||
| for decompressed_dir in self._temp_uncompressed_paths_to_be_cleaned: | ||
| shutil.rmtree(decompressed_dir) | ||
| paths_to_clean = self._temp_uncompressed_paths_to_be_cleaned | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [CONCURRENCY] The snapshot taken under the lock does not actually isolate # samcli/local/lambdafn/runtime.py (_get_code_dir, unchanged by this PR)
self._temp_uncompressed_paths_to_be_cleaned += [decompressed_dir]
This is reachable in practice: Guarding the producer side makes the snapshot meaningful: # in getcode_dir()
with self._lock:
self._temp_uncompressed_paths_to_be_cleaned.append(decompressed_dir) |
||
| self._temp_uncompressed_paths_to_be_cleaned = [] | ||
| failed_paths = [] | ||
| for decompressed_dir in paths_to_clean: | ||
| try: | ||
| shutil.rmtree(decompressed_dir) | ||
| except OSError: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [RESOURCE_MANAGEMENT] A path whose This matters most for the transient failures that dominate this call site: Re-queueing only the failed paths keeps the "one bad entry can't block the others" property from this change while preserving a retry: failed_paths = []
for decompressed_dir in paths_to_clean:
try:
shutil.rmtree(decompressed_dir)
except OSError:
LOG.warning("Failed to remove temporary directory %s", decompressed_dir, exc_info=True)
failed_paths.append(decompressed_dir)
if failed_paths:
with self._lock:
self._temp_uncompressed_paths_to_be_cleaned += failed_pathsNote on prior review threads: the two cleanup steps now running independently, the in-flight exception no longer being replaced by a cleanup error, and |
||
| LOG.warning("Failed to remove temporary directory %s", decompressed_dir, exc_info=True) | ||
| failed_paths.append(decompressed_dir) | ||
| if failed_paths: | ||
| # Removal failures at this call site are commonly transient (e.g. a directory that | ||
| # was just bind-mounted into a just-stopped container isn't released yet), so retry | ||
| # them on the next cleanup pass instead of dropping them permanently. | ||
| with self._lock: | ||
| self._temp_uncompressed_paths_to_be_cleaned += failed_paths | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [RESOURCE_MANAGEMENT] Requeuing failed paths only helps a process that will call _clean_decompressed_paths() again. For sam local invoke — the headline case in the PR description — there is no later pass, so a requeued path is still a permanent leak with only a warning:
Since the added comment states these rmtree failures are "commonly transient (e.g. a directory that was just bind-mounted into a just-stopped container isn't released yet)", the single-shot invoke path — where the container was stopped microseconds earlier — is exactly where a transient failure is most likely and where the retry never happens. A last best-effort attempt at teardown closes the gap, e.g. calling self._clean_decompressed_paths() from LambdaRuntime.clean_runtime_containers(), which InvokeContext.exit already invokes for every container mode: def clean_runtime_containers(self):
...
# existing container cleanup
...
# Final best-effort pass so paths requeued by earlier failures get one more attempt
# before the process exits.
self._clean_decompressed_paths() |
||
|
|
||
| def get_or_create_emulator_container(self): | ||
| """ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[ERROR_HANDLING] Cleanup now runs in
finallyblocks, so an exception from cleanup replaces the in-flight exception instead of propagating it. Concretely, on an OOM'd invoke where_check_exit_state()has raisedContainerFailureError, ifself._container_manager.stop(container)orself._clean_decompressed_paths()then raises, the OOM error is discarded (kept only as__context__) and the cleanup error surfaces instead.This is reachable with the existing code:
ContainerManager.stop()callsContainer.stop()andContainer.delete(), both of which re-raisedocker.errors.APIErrorunless the message matches the "removal of container ... is already in progress" special case (samcli/local/docker/container.py).Container.delete()additionally doesshutil.rmtree(self._host_tmp_dir)._clean_decompressed_paths()callsshutil.rmtree()with noignore_errors.The user-visible impact is a regression in error reporting:
ContainerFailureErroris aUserException, so it produces the friendly "Container invocation failed due to maximum memory usage" message and exit code 1. A rawdocker.errors.APIError/OSErroris not, so the actual OOM cause is hidden behind an unhandled-exception trace. The PR's owntest_on_invoke_done_cleans_paths_when_both_check_exit_state_and_stop_raiseencodes this behavior by assertingRuntimeErrorpropagates rather thanContainerFailureError.Since these are best-effort cleanup steps whose failures should not determine the invoke result, log them instead of letting them escape:
This still guarantees both cleanup steps run (the point of the fix) while preserving the original error. If you prefer cleanup failures to remain fatal when there is no in-flight exception, the alternative is to re-raise the original exception explicitly when one exists; either way, the OOM error should not be swallowed. The third test's assertion would need updating to match whichever behavior you choose.