Skip to content

Commit d019874

Browse files
santiHofmeisterAntotallyzen
authoredMar 19, 2024··
feat(reliability): integrate the ryuk container for better container cleanup (#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” #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 <9199345+HofmeisterAn@users.noreply.github.com> Co-authored-by: Balint Bartha <39852431+totallyzen@users.noreply.github.com>
1 parent 08a6293 commit d019874

File tree

8 files changed

+153
-33
lines changed

8 files changed

+153
-33
lines changed
 

‎INDEX.rst

+15
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

+9
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

+5
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

+72-19
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

+5-14
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

+20
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

+24
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

+3
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,9 @@ ignore = [
197197
"INP001"
198198
]
199199

200+
[tool.ruff.lint.pyupgrade]
201+
keep-runtime-typing = true
202+
200203
[tool.ruff.lint.flake8-type-checking]
201204
strict = true
202205

0 commit comments

Comments
 (0)
Please sign in to comment.