Skip to content

Commit 4863830

Browse files
committed
respect the memory and CPU limits that apply to the process
1 parent 673098c commit 4863830

9 files changed

Lines changed: 341 additions & 34 deletions

File tree

‎docs/guides/scaling_crawlers.mdx‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,9 @@ The `desired_concurrency` option in the <ApiLink to="class/ConcurrencySettings">
4747
## Autoscaled pool
4848

4949
The <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink> manages a pool of asynchronous, resource-intensive tasks that run in parallel. It keeps `min_concurrency` tasks running even while the system is overloaded, and starts additional tasks only when there is enough free CPU and memory. To monitor system resources, it leverages the <ApiLink to="class/Snapshotter">`Snapshotter`</ApiLink> and <ApiLink to="class/SystemStatus">`SystemStatus`</ApiLink> classes. If any task raises an exception, the error is propagated, and the pool is stopped. Every crawler uses an <ApiLink to="class/AutoscaledPool">`AutoscaledPool`</ApiLink> under the hood.
50+
51+
## Running under a resource limit
52+
53+
A crawler often gets less than the host machine has. A Docker container, a Kubernetes pod, a systemd slice and a Windows job object can each carry a limit of their own. Crawlee reads the limit that applies to the process and scales against it, with nothing to configure. The memory budget comes from the memory limit, the CPU load is measured against the cores the process may use, and the tightest limit wins when several of them apply. Without a limit, Crawlee falls back to the resources of the host machine.
54+
55+
The budget is `available_memory_ratio` of the limit, 25% by default, and the crawler throttles once it uses `max_used_memory_ratio` of that budget, 90% by default. A container limited to 2 GB therefore throttles at around 460 MB. Both options live in the <ApiLink to="class/Configuration">`Configuration`</ApiLink>, together with `memory_mbytes` for sizing the budget in absolute terms. Whatever the budget, the crawler also throttles once the memory charged against the limit goes above 97% of it, which includes memory used by other processes under the same limit.

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"colorama>=0.4.0",
4040
"impit>=0.13.2",
4141
"more-itertools>=10.2.0",
42+
"proclimits>=0.2.0",
4243
"protego>=0.5.0",
4344
"psutil>=6.0.0",
4445
"pydantic-settings>=2.12.0",

‎src/crawlee/_autoscaling/_types.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ class MemorySnapshot:
9797
"""Memory usage of the current Python process and its children."""
9898

9999
system_wide_used_size: ByteSize | None
100-
"""Memory usage of all processes, system-wide."""
100+
"""Memory usage of all processes, within the scope `system_wide_memory_size` covers."""
101101

102102
max_memory_size: ByteSize
103103
"""The maximum memory that can be used by `AutoscaledPool`."""
104104

105105
system_wide_memory_size: ByteSize | None
106-
"""Total memory available in the whole system."""
106+
"""Total memory available to this process, which is the memory limit where one applies."""
107107

108108
max_used_memory_ratio: float
109109
"""The maximum acceptable ratio of `current_size` to `max_memory_size`."""

‎src/crawlee/_utils/system.py‎

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from logging import WARNING, getLogger
77
from typing import TYPE_CHECKING, Annotated
88

9+
import proclimits
910
import psutil
1011
from pydantic import BaseModel, ConfigDict, Field, PlainSerializer, PlainValidator
1112

@@ -19,6 +20,9 @@
1920
# psutil re-raises `FileNotFoundError` as is when a `/proc` entry is missing for a process that is still alive.
2021
_METRIC_ERRORS = (psutil.Error, OSError)
2122

23+
_CPU_SAMPLE_INTERVAL_SECS = 0.1
24+
"""How long a blocking CPU measurement lasts. A window shorter than 0.01 seconds is refused by the sensor."""
25+
2226

2327
class _PssAvailability:
2428
"""Process-wide latch for whether the PSS memory metric exists on this system at all.
@@ -185,35 +189,83 @@ class MemoryInfo(MemoryUsageInfo):
185189
total_size: Annotated[
186190
ByteSize, PlainValidator(ByteSize.validate), PlainSerializer(lambda size: size.bytes), Field(alias='totalSize')
187191
]
188-
"""Total memory available in the system."""
192+
"""Total memory available to this process.
193+
194+
Under a container limit this is the limit rather than the memory of the host machine.
195+
"""
189196

190197
system_wide_used_size: Annotated[
191198
ByteSize,
192199
PlainValidator(ByteSize.validate),
193200
PlainSerializer(lambda size: size.bytes),
194201
Field(alias='systemWideUsedSize'),
195202
]
196-
"""Total memory used by all processes system-wide (including non-crawlee processes)."""
203+
"""Total memory used within the scope `total_size` covers, including memory used by non-crawlee processes.
204+
205+
Under a container limit this is the memory charged against that limit.
206+
"""
207+
208+
209+
class _ResourceLimits:
210+
"""Process-wide latch keeping the limits report to one line per process, rather than one per sample."""
211+
212+
is_pending = True
213+
214+
215+
def _log_resource_limits() -> None:
216+
"""Report the limits applying to this process, at most once per process and only where any apply."""
217+
# The latch is consumed before the reading, so a sensor that raises costs one snapshot rather than every one.
218+
if not _ResourceLimits.is_pending:
219+
return
220+
_ResourceLimits.is_pending = False
221+
222+
limits = proclimits.snapshot()
223+
cores = limits.cpu_limit
197224

225+
if limits.memory_budget is None and cores is None:
226+
return
198227

199-
def get_cpu_info() -> CpuInfo:
228+
memory = str(ByteSize(limits.memory_budget.limit)) if limits.memory_budget else 'unrestricted'
229+
cpu = f'{cores:g} core{"" if cores == 1 else "s"}' if cores is not None else 'unrestricted'
230+
logger.info(f'Resource limits applying to this process: memory {memory}, CPU {cpu}.')
231+
232+
233+
def get_cpu_info(cpu_load: proclimits.CpuLoad) -> CpuInfo:
200234
"""Retrieve the current CPU usage.
201235
202-
It utilizes the `psutil` library. Function `psutil.cpu_percent()` returns a float representing the current
203-
system-wide CPU utilization as a percentage.
236+
Under a container limit the load is measured against the cores this process may use. The sampler measures across
237+
the gap between calls, and a call it has no reading for, such as the first, falls back to a short measurement of
238+
its own. Without a limit the process competes for the whole machine, and `psutil.cpu_percent()` answers instead.
239+
240+
Args:
241+
cpu_load: The sampler owned by the caller. Two callers sharing one would measure each other's windows.
204242
"""
205243
logger.debug('Calling get_cpu_info()...')
206-
cpu_percent = psutil.cpu_percent(interval=0.1)
207-
return CpuInfo(used_ratio=cpu_percent / 100)
244+
245+
# Read on every sample rather than latched, because a limit can be resized while the process runs.
246+
if proclimits.get_cpu_limit() is None:
247+
return CpuInfo(used_ratio=psutil.cpu_percent(interval=_CPU_SAMPLE_INTERVAL_SECS) / 100)
248+
249+
used_ratio = cpu_load.sample()
250+
251+
if used_ratio is None:
252+
used_ratio = proclimits.get_cpu_used_ratio(_CPU_SAMPLE_INTERVAL_SECS)
253+
254+
if used_ratio is None:
255+
used_ratio = psutil.cpu_percent(interval=_CPU_SAMPLE_INTERVAL_SECS) / 100
256+
257+
return CpuInfo(used_ratio=used_ratio)
208258

209259

210260
def get_memory_info() -> MemoryInfo:
211261
"""Retrieve the current memory usage of the process and its children.
212262
213263
It utilizes the `psutil` library. The reported `current_size` is best-effort - processes that cannot be inspected
214-
are left out of the sum, and PSS may be substituted by RSS for some or all of the processes.
264+
are left out of the sum, and PSS may be substituted by RSS for some or all of the processes. The system-wide
265+
figures come from the limit applying to this process whenever one restricts how much memory it may use.
215266
"""
216267
logger.debug('Calling get_memory_info()...')
268+
_log_resource_limits()
217269
current_process = psutil.Process(os.getpid())
218270

219271
# Retrieve estimated memory usage of the current process. Deliberately not guarded - a process can always read
@@ -236,10 +288,18 @@ def get_memory_info() -> MemoryInfo:
236288
for child in children:
237289
current_size_bytes += _get_child_used_memory(child)
238290

239-
vm = psutil.virtual_memory()
291+
budget = proclimits.get_memory_budget()
292+
293+
if budget is None:
294+
vm = psutil.virtual_memory()
295+
total_size_bytes, system_wide_used_size_bytes = vm.total, vm.total - vm.available
296+
else:
297+
# Not clamped to the memory of the machine: a Windows job limits commit, so that would pair a commit charge
298+
# with a physical ceiling.
299+
total_size_bytes, system_wide_used_size_bytes = budget.limit, budget.used
240300

241301
return MemoryInfo(
242-
total_size=ByteSize(vm.total),
302+
total_size=ByteSize(total_size_bytes),
243303
current_size=ByteSize(current_size_bytes),
244-
system_wide_used_size=ByteSize(vm.total - vm.available),
304+
system_wide_used_size=ByteSize(system_wide_used_size_bytes),
245305
)

‎src/crawlee/configuration.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,9 +186,9 @@ class Configuration(BaseSettings):
186186
le=1.0,
187187
),
188188
] = 0.25
189-
"""The maximum proportion of system memory to use. If `memory_mbytes` is not provided, this ratio is used to
190-
calculate the maximum memory. This option is utilized by the `Snapshotter` and supports the dynamic system memory
191-
scaling."""
189+
"""The maximum proportion of the memory available to this process to use, which is the memory limit where one
190+
applies. If `memory_mbytes` is not provided, this ratio is used to calculate the maximum memory. This option is
191+
utilized by the `Snapshotter` and supports the dynamic system memory scaling."""
192192

193193
storage_dir: Annotated[
194194
str,

‎src/crawlee/events/_local_event_manager.py‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from logging import getLogger
66
from typing import TYPE_CHECKING
77

8+
import proclimits
9+
810
from crawlee._utils.docs import docs_group
911
from crawlee._utils.recurring_task import RecurringTask
1012
from crawlee._utils.system import get_cpu_info, get_memory_info
@@ -48,6 +50,9 @@ def __init__(
4850
self._system_info_interval = system_info_interval
4951
"""Interval between the emitted `SystemInfo` events."""
5052

53+
self._cpu_load = proclimits.CpuLoad()
54+
"""CPU sampler of this event manager, measuring across the gap between its emissions."""
55+
5156
self._emit_system_info_event_rec_task = RecurringTask(
5257
func=self._emit_system_info_event,
5358
delay=self._system_info_interval,
@@ -76,6 +81,8 @@ async def __aenter__(self) -> Self:
7681
await super().__aenter__()
7782

7883
if self._active_ref_count == 1:
84+
# A reading kept from a previous session would report the average load over the idle gap since then.
85+
self._cpu_load = proclimits.CpuLoad()
7986
self._emit_system_info_event_rec_task.start()
8087

8188
return self
@@ -98,10 +105,10 @@ async def __aexit__(
98105

99106
async def _emit_system_info_event(self) -> None:
100107
"""Emit a system info event with the current CPU and memory usage."""
101-
# Both readings block the thread they run in - `get_cpu_info` even samples the CPU utilization over a short
108+
# Both readings block the thread they run in - `get_cpu_info` may sample the CPU utilization over a short
102109
# interval - so run them concurrently instead of one after the other.
103110
cpu_info, memory_info = await asyncio.gather(
104-
asyncio.to_thread(get_cpu_info),
111+
asyncio.to_thread(get_cpu_info, self._cpu_load),
105112
asyncio.to_thread(get_memory_info),
106113
)
107114

0 commit comments

Comments
 (0)