Skip to content

Commit fdadee4

Browse files
santiHofmeisterAntotallyzen
authored andcommitted
feat(reliability): integrate the ryuk container for better container cleanup (testcontainers#314)
> [!NOTE] > Editor's note from @totallyzen: > After a thorough discussion between @santi @alexanderankin, @kiview and @totallyzen on the [slack](https://testcontainers.slack.com/archives/C04SRG5AXNU/p1710156743640249) we've decided this isn't a breaking change in api, but a modification in behaviour. Therefore not worth a 5.0 but we'll release it under 4.x > If this did end up breaking your workflow, come talk to us about your use-case! **What are you trying to do?** Use Ryuk as the default resource cleanup strategy. **Why should it be done this way?** The current implementation of tc-python does not handle container lifecycle management the same way as other TC implementations. In this PR I introduce Ryuk to be the default resource cleanup strategy, in order to be better aligned with other TC implementations. Ryuk is enabled by default, but can be disabled with the `TESTCONTAINERS_RYUK_DISABLED` env variable (which follows the behavior of other TC implementations). Ryuk behavior can further be altered with the `TESTCONTAINERS_RYUK_PRIVILEGED`, `RYUK_CONTAINER_IMAGE` and `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` (also following the same conventions from other implementations). Documentation of these env variables is added to the README in the root of the repo. The implementation uses a singleton container that starts an instance of Ryuk and stores it on class / module level, so that it is reused within a process. This follows the same pattern as in other languages, and Ryuk behaviour itself is implemented the exact same way as other implementations. **BREAKING CHANGE** `__del__` support is now removed, in order to better align with other TC implementations. From the comments in the `__del__` implementation, it seems like this was added as a final attempt to cleanup resources on exit/garbage collection. This leaves three ways to cleanup resources, which has better defined behaviors and follows modern patterns for resource management in Python: Method 1: Context Manager Init/cleanup through a context manager (__enter__/__exit__): ``` with DockerContainer("postgres") as container: # some logic here # end of context: container is killed by `__exit__` method ``` Method 2: Manual start() and stop() ``` container = DockerContainer("postgres").start() # some logic here container.stop() ``` Method 3: Ryuk ``` container = DockerContainer("postgres").start() # some logic here # You forget to .stop() the container, but Ryuk kills it for you 10 seconds after your process exits. ``` _Why remove `__del__`?_ According to the previous maintainer of the repo, it has been causing “[a bunch of issues](https://github.com/testcontainers/testcontainers-python/pull/314#discussion_r1185321083)”, which I have personally experienced while using TC in a Django app, due to the automatic GC behavior when no longer referencing the container with a variable. E.g. if you instantiate the container in a method, only returning the connection string, the Python garbage collector will automatically call `__del__` on the instance at the end of the function, thus killing your container. This leads to clunky workarounds like having to store a reference to the container in a module-level variable, or always having to return a reference to the container from the function creating the container. In addition, the gc behaviour is not consistent across Python implementations, making the reliance on `__del__` flaky at best. Also, having the __del__ method cleanup your container prevents us from implementing `with_reuse()` (which is implemented in other TC implementations) in the future, as a process exit would always instantly kill the container, preventing us to use it in another process before Ryuk reaps it. **Next steps** Once this PR is accepted, my plan is to implement the `with_reuse()` functionality seen in other implementations, to enable faster / instant usage of existing containers. This is very useful in simple testing scenarios or local development workflows using hot reload behaviour. The `with_reuse()` API requires the removal of `__del__` cleanup, as otherwise the container would not be available for reuse due to the GC reaping the container as soon as the process exits. **Other changes** - Adds “x-tc-sid=SESSION_ID” header to the underlying Docker API client with the value of the current session ID (created on module init), in order to enable Testcontainers Cloud to operate in “Turbo mode” testcontainers#314 (comment) - Adds labels `org.testcontainers.lang=python` and `org.testcontainers.session-id=SESSION_ID`to the containers created by TC - As mentioned above, the env variables TESTCONTAINERS_RYUK_DISABLED, TESTCONTAINERS_RYUK_PRIVILEGED, RYUK_CONTAINER_IMAGE and TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE are now used for customizing tc-python behavior. --------- Co-authored-by: Andre Hofmeister <[email protected]> Co-authored-by: Balint Bartha <[email protected]>
1 parent ae7b163 commit fdadee4

File tree

8 files changed

+153
-33
lines changed

8 files changed

+153
-33
lines changed

INDEX.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,21 @@ When trying to launch a testcontainer from within a Docker container, e.g., in c
9292
1. The container has to provide a docker client installation. Either use an image that has docker pre-installed (e.g. the `official docker images <https://hub.docker.com/_/docker>`_) or install the client from within the `Dockerfile` specification.
9393
2. The container has to have access to the docker daemon which can be achieved by mounting `/var/run/docker.sock` or setting the `DOCKER_HOST` environment variable as part of your `docker run` command.
9494

95+
Configuration
96+
-------------
97+
98+
+-------------------------------------------+-------------------------------+------------------------------------------+
99+
| Env Variable | Example | Description |
100+
+===========================================+===============================+==========================================+
101+
| ``TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE`` | ``/var/run/docker.sock`` | Path to Docker's socket used by ryuk |
102+
+-------------------------------------------+-------------------------------+------------------------------------------+
103+
| ``TESTCONTAINERS_RYUK_PRIVILEGED`` | ``false`` | Run ryuk as a privileged container |
104+
+-------------------------------------------+-------------------------------+------------------------------------------+
105+
| ``TESTCONTAINERS_RYUK_DISABLED`` | ``false`` | Disable ryuk |
106+
+-------------------------------------------+-------------------------------+------------------------------------------+
107+
| ``RYUK_CONTAINER_IMAGE`` | ``testcontainers/ryuk:0.5.1`` | Custom image for ryuk |
108+
+-------------------------------------------+-------------------------------+------------------------------------------+
109+
95110
Development and Contributing
96111
----------------------------
97112

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@ For more information, see [the docs][readthedocs].
2222
```
2323

2424
The snippet above will spin up a postgres database in a container. The `get_connection_url()` convenience method returns a `sqlalchemy` compatible url we use to connect to the database and retrieve the database version.
25+
26+
## Configuration
27+
28+
| Env Variable | Example | Description |
29+
| ----------------------------------------- | ----------------------------- | ---------------------------------------- |
30+
| `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE` | `/var/run/docker.sock` | Path to Docker's socket used by ryuk |
31+
| `TESTCONTAINERS_RYUK_PRIVILEGED` | `false` | Run ryuk as a privileged container |
32+
| `TESTCONTAINERS_RYUK_DISABLED` | `false` | Disable ryuk |
33+
| `RYUK_CONTAINER_IMAGE` | `testcontainers/ryuk:0.5.1` | Custom image for ryuk |

core/testcontainers/core/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,8 @@
33
MAX_TRIES = int(environ.get("TC_MAX_TRIES", 120))
44
SLEEP_TIME = int(environ.get("TC_POOLING_INTERVAL", 1))
55
TIMEOUT = MAX_TRIES * SLEEP_TIME
6+
7+
RYUK_IMAGE: str = environ.get("RYUK_CONTAINER_IMAGE", "testcontainers/ryuk:0.5.1")
8+
RYUK_PRIVILEGED: bool = environ.get("TESTCONTAINERS_RYUK_PRIVILEGED", "false") == "true"
9+
RYUK_DISABLED: bool = environ.get("TESTCONTAINERS_RYUK_DISABLED", "false") == "true"
10+
RYUK_DOCKER_SOCKET: str = environ.get("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE", "/var/run/docker.sock")

core/testcontainers/core/container.py

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1-
import contextlib
21
from platform import system
3-
from typing import Optional
4-
5-
from docker.models.containers import Container
2+
from socket import socket
3+
from typing import TYPE_CHECKING, Optional
64

5+
from testcontainers.core.config import RYUK_DISABLED, RYUK_DOCKER_SOCKET, RYUK_IMAGE, RYUK_PRIVILEGED
76
from testcontainers.core.docker_client import DockerClient
87
from testcontainers.core.exceptions import ContainerStartException
8+
from testcontainers.core.labels import LABEL_SESSION_ID, SESSION_ID
99
from testcontainers.core.utils import inside_container, is_arm, setup_logger
10-
from testcontainers.core.waiting_utils import wait_container_is_ready
10+
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
11+
12+
if TYPE_CHECKING:
13+
from docker.models.containers import Container
1114

1215
logger = setup_logger(__name__)
1316

@@ -25,7 +28,12 @@ class DockerContainer:
2528
... delay = wait_for_logs(container, "Hello from Docker!")
2629
"""
2730

28-
def __init__(self, image: str, docker_client_kw: Optional[dict] = None, **kwargs) -> None:
31+
def __init__(
32+
self,
33+
image: str,
34+
docker_client_kw: Optional[dict] = None,
35+
**kwargs,
36+
) -> None:
2937
self.env = {}
3038
self.ports = {}
3139
self.volumes = {}
@@ -58,7 +66,10 @@ def maybe_emulate_amd64(self) -> "DockerContainer":
5866
return self.with_kwargs(platform="linux/amd64")
5967
return self
6068

61-
def start(self) -> "DockerContainer":
69+
def start(self):
70+
if not RYUK_DISABLED and self.image != RYUK_IMAGE:
71+
logger.debug("Creating Ryuk container")
72+
Reaper.get_instance()
6273
logger.info("Pulling image %s", self.image)
6374
docker_client = self.get_docker_client()
6475
self._container = docker_client.run(
@@ -69,7 +80,7 @@ def start(self) -> "DockerContainer":
6980
ports=self.ports,
7081
name=self._name,
7182
volumes=self.volumes,
72-
**self._kwargs
83+
**self._kwargs,
7384
)
7485
logger.info("Container started: %s", self._container.short_id)
7586
return self
@@ -78,21 +89,12 @@ def stop(self, force=True, delete_volume=True) -> None:
7889
self._container.remove(force=force, v=delete_volume)
7990
self.get_docker_client().client.close()
8091

81-
def __enter__(self) -> "DockerContainer":
92+
def __enter__(self):
8293
return self.start()
8394

8495
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
8596
self.stop()
8697

87-
def __del__(self) -> None:
88-
"""
89-
__del__ runs when Python attempts to garbage collect the object.
90-
In case of leaky test design, we still attempt to clean up the container.
91-
"""
92-
with contextlib.suppress(Exception):
93-
if self._container is not None:
94-
self.stop()
95-
9698
def get_container_host_ip(self) -> str:
9799
# infer from docker host
98100
host = self.get_docker_client().host()
@@ -140,7 +142,7 @@ def with_volume_mapping(self, host: str, container: str, mode: str = "ro") -> "D
140142
self.volumes[host] = mapping
141143
return self
142144

143-
def get_wrapped_container(self) -> Container:
145+
def get_wrapped_container(self) -> "Container":
144146
return self._container
145147

146148
def get_docker_client(self) -> DockerClient:
@@ -155,3 +157,54 @@ def exec(self, command) -> tuple[int, str]:
155157
if not self._container:
156158
raise ContainerStartException("Container should be started before executing a command")
157159
return self._container.exec_run(command)
160+
161+
162+
class Reaper:
163+
_instance: "Optional[Reaper]" = None
164+
_container: Optional[DockerContainer] = None
165+
_socket: Optional[socket] = None
166+
167+
@classmethod
168+
def get_instance(cls) -> "Reaper":
169+
if not Reaper._instance:
170+
Reaper._instance = Reaper._create_instance()
171+
172+
return Reaper._instance
173+
174+
@classmethod
175+
def delete_instance(cls) -> None:
176+
if Reaper._socket is not None:
177+
Reaper._socket.close()
178+
Reaper._socket = None
179+
180+
if Reaper._container is not None:
181+
Reaper._container.stop()
182+
Reaper._container = None
183+
184+
if Reaper._instance is not None:
185+
Reaper._instance = None
186+
187+
@classmethod
188+
def _create_instance(cls) -> "Reaper":
189+
logger.debug(f"Creating new Reaper for session: {SESSION_ID}")
190+
191+
Reaper._container = (
192+
DockerContainer(RYUK_IMAGE)
193+
.with_name(f"testcontainers-ryuk-{SESSION_ID}")
194+
.with_exposed_ports(8080)
195+
.with_volume_mapping(RYUK_DOCKER_SOCKET, "/var/run/docker.sock", "rw")
196+
.with_kwargs(privileged=RYUK_PRIVILEGED)
197+
.start()
198+
)
199+
wait_for_logs(Reaper._container, r".* Started!")
200+
201+
container_host = Reaper._container.get_container_host_ip()
202+
container_port = int(Reaper._container.get_exposed_port(8080))
203+
204+
Reaper._socket = socket()
205+
Reaper._socket.connect((container_host, container_port))
206+
Reaper._socket.send(f"label={LABEL_SESSION_ID}={SESSION_ID}\r\n".encode())
207+
208+
Reaper._instance = Reaper()
209+
210+
return Reaper._instance

core/testcontainers/core/docker_client.py

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
1111
# License for the specific language governing permissions and limitations
1212
# under the License.
13-
import atexit
1413
import functools as ft
1514
import os
1615
import urllib
@@ -19,10 +18,10 @@
1918
from typing import Optional, Union
2019

2120
import docker
22-
from docker.errors import NotFound
2321
from docker.models.containers import Container, ContainerCollection
2422

25-
from .utils import default_gateway_ip, inside_container, setup_logger
23+
from testcontainers.core.labels import SESSION_ID, create_labels
24+
from testcontainers.core.utils import default_gateway_ip, inside_container, setup_logger
2625

2726
LOGGER = setup_logger(__name__)
2827
TC_FILE = ".testcontainers.properties"
@@ -42,6 +41,7 @@ def __init__(self, **kwargs) -> None:
4241
self.client = docker.DockerClient(base_url=docker_host)
4342
else:
4443
self.client = docker.from_env(**kwargs)
44+
self.client.api.headers["x-tc-sid"] = SESSION_ID
4545

4646
@ft.wraps(ContainerCollection.run)
4747
def run(
@@ -50,6 +50,7 @@ def run(
5050
command: Optional[Union[str, list[str]]] = None,
5151
environment: Optional[dict] = None,
5252
ports: Optional[dict] = None,
53+
labels: Optional[dict[str, str]] = None,
5354
detach: bool = False,
5455
stdout: bool = True,
5556
stderr: bool = False,
@@ -65,10 +66,9 @@ def run(
6566
detach=detach,
6667
environment=environment,
6768
ports=ports,
69+
labels=create_labels(image, labels),
6870
**kwargs,
6971
)
70-
if detach:
71-
atexit.register(_stop_container, container)
7272
return container
7373

7474
def port(self, container_id: str, port: int) -> int:
@@ -145,12 +145,3 @@ def read_tc_properties() -> dict[str, str]:
145145
tuples = [line.split("=") for line in contents.readlines() if "=" in line]
146146
settings = {**settings, **{item[0].strip(): item[1].strip() for item in tuples}}
147147
return settings
148-
149-
150-
def _stop_container(container: Container) -> None:
151-
try:
152-
container.stop()
153-
except NotFound:
154-
pass
155-
except Exception as ex:
156-
LOGGER.warning("failed to shut down container %s with image %s: %s", container.id, container.image, ex)

core/testcontainers/core/labels.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import Optional
2+
from uuid import uuid4
3+
4+
from testcontainers.core.config import RYUK_IMAGE
5+
6+
SESSION_ID: str = str(uuid4())
7+
LABEL_SESSION_ID = "org.testcontainers.session-id"
8+
LABEL_LANG = "org.testcontainers.lang"
9+
10+
11+
def create_labels(image: str, labels: Optional[dict[str, str]]) -> dict[str, str]:
12+
if labels is None:
13+
labels = {}
14+
labels[LABEL_LANG] = "python"
15+
16+
if image == RYUK_IMAGE:
17+
return labels
18+
19+
labels[LABEL_SESSION_ID] = SESSION_ID
20+
return labels

core/tests/test_ryuk.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from testcontainers.core import container
2+
from testcontainers.core.container import Reaper
3+
from testcontainers.core.container import DockerContainer
4+
from testcontainers.core.waiting_utils import wait_for_logs
5+
6+
7+
def test_wait_for_reaper():
8+
container = DockerContainer("hello-world").start()
9+
wait_for_logs(container, "Hello from Docker!")
10+
11+
assert Reaper._socket is not None
12+
Reaper._socket.close()
13+
14+
assert Reaper._container is not None
15+
wait_for_logs(Reaper._container, r".* Removed \d .*", timeout=30)
16+
17+
Reaper.delete_instance()
18+
19+
20+
def test_container_without_ryuk(monkeypatch):
21+
monkeypatch.setattr(container, "RYUK_DISABLED", True)
22+
with DockerContainer("hello-world") as cont:
23+
wait_for_logs(cont, "Hello from Docker!")
24+
assert Reaper._instance is None

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,9 @@ ignore = [
199199
"INP001"
200200
]
201201

202+
[tool.ruff.lint.pyupgrade]
203+
keep-runtime-typing = true
204+
202205
[tool.ruff.lint.flake8-type-checking]
203206
strict = true
204207

0 commit comments

Comments
 (0)