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

Add options to purge big workers #3252

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/source/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,58 @@ The maximum number of simultaneous clients.

This setting only affects the ``gthread``, ``eventlet`` and ``gevent`` worker types.

.. _prune-function:

``prune_function``
~~~~~~~~~~~~~~~~~~

**Command line:** ``--prune-function``

**Default:**

.. code-block:: python

def prune_score(pid):
return 0

A function that is passed a process ID of a worker and returns a
score (such as total memory used). Once every prune seconds, the
worker with the highest score is killed (unless the score is below
the prune floor).

.. _prune-seconds:

``prune_seconds``
~~~~~~~~~~~~~~~~~

**Command line:** ``--prune-seconds INT``

**Default:** ``0``

How many seconds to wait between killing the worker with the highest
score from the prune function. If set to 0 (the default), then no
pruning is done. The actual time waited is a random value between
95% and 105% of this value.

A worker handling an unusually large request can significantly grow
how much memory it is consuming for the rest of its existence. So
rare large requests will tend to eventually make every worker
unnecessarily large. If the large requests are indeed rare, then
you can significantly reduce the total memory used by your service
by periodically pruning the largest worker process.

.. _prune-floor:

``prune_floor``
~~~~~~~~~~~~~~~

**Command line:** ``--prune-floor INT``

**Default:** ``0``

When the score from the prune function is at or below this value, the
worker will not be killed even if it has the highest score.

.. _max-requests:

``max_requests``
Expand Down
33 changes: 33 additions & 0 deletions examples/example_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,39 @@
timeout = 30
keepalive = 2

#
# prune_function
# A function that is passed a process ID of a worker and returns a
# score (such as total memory used). Once every prune seconds, the
# worker with the highest score is killed (unless the score is below
# the prune floor).
#
# prune_seconds
# How many seconds to wait between killing the worker with the highest
# score from the prune function. If set to 0 (the default), then no
# pruning is done. The actual time waited is a random value between
# 90% and 100% of this value.
#
# prune_floor
# When the score from the prune function is at or below this value, the
# worker will not be killed even if it has the highest score.
#

import psutil

def proc_vmsize(pid):
# Return how many MB of virtual memory is being used by a worker process
try:
p = psutil.Process(pid)
mb = p.memory_info().vms/1024/1024
return mb
except psutil.NoSuchProcessError:
return 0

prune_seconds = 5*60 # Prune largest worker every 4.75-5.25m
prune_function = proc_vmsize # Measure worker size in MB of VM
prune_floor = 300 # Don't kill workers using <= 300 MB of VM

#
# spew - Install a trace function that spews every line of Python
# that is executed when running the server. This is the
Expand Down
30 changes: 27 additions & 3 deletions gunicorn/arbiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def __init__(self, app):
self.reexec_pid = 0
self.master_pid = 0
self.master_name = "Master"
self.next_prune = None

cwd = util.getcwd()

Expand Down Expand Up @@ -203,6 +204,13 @@ def run(self):

while True:
self.maybe_promote_master()
if 0 < self.cfg.prune_seconds:
if self.next_prune is None:
self.next_prune = time.monotonic() + self.cfg.prune_seconds
elif self.next_prune <= time.monotonic():
self.prune_worker()
self.next_prune += self.cfg.prune_seconds * (
0.95 + 0.10 * random.random())
Copy link
Contributor

Choose a reason for hiding this comment

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

Does not appear to match documentation: 0.95+0.10 = 1.05 > 100%. But the general idea of averaging out late firing timers seems much more reasonable, so I'd change documentation to clarify that its not the pause between invocations but the average interval.

Comment on lines +207 to +213
Copy link

Choose a reason for hiding this comment

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

I wonder if it would make sense to put all this logic into prune_worker? I mean, something like:

    def prune_worker(self):
        """\
        Kill the worker with highest prune score
        """
        if self.cfg.prune_seconds <= 0:
            return

        if self.next_prune is None:
            self.next_prune = time.monotonic() + self.cfg.prune_seconds

        if time.monotonic() < self.next_prune:
            return
        
        maxi = self.cfg.prune_floor
        victim = 0
        for pid in list(self.WORKERS.keys()):
            score = self.cfg.prune_function(pid)
            if maxi < score:
                maxi = score
                victim = pid
        if victim != 0:
            self.log.info(f"Pruning worker (pid: {victim}) with score {score}")
            self.kill_worker(victim, signal.SIGTERM)

        self.next_prune += self.cfg.prune_seconds * random.uniform(0.95, 1.05)

... and then call self.prune_worker() just after self.maybe_promote_master()? This has the benefit of keeping all worker prune logic at the same place.

I had two other minor suggestions as well (incorporated in the proposed method above):

  • Would random.uniform(0.95, 1.05) work instead of 0.95 + 0.10 * random.random()? I recon it's probably the same, but the former is a little bit shorter.
  • Since we're not interested in the workers themselves but only their PIDs, perhaps calling self.WORKERS.keys() would suffice?


sig = self.SIG_QUEUE.pop(0) if self.SIG_QUEUE else None
if sig is None:
Expand Down Expand Up @@ -486,6 +494,22 @@ def reload(self):
# manage workers
self.manage_workers()

def prune_worker(self):
"""\
Kill the worker with highest prune score
"""
workers = list(self.WORKERS.items())
maxi = self.cfg.prune_floor
victim = 0
for pid, _ in workers:
score = self.cfg.prune_function(pid)
if maxi < score:
maxi = score
victim = pid
if victim != 0:
self.log.info(f"Pruning worker (pid: {victim}) with score {score}")
self.kill_worker(victim, signal.SIGTERM)

def murder_workers(self):
"""\
Kill unused/idle workers
Expand Down Expand Up @@ -586,9 +610,9 @@ def manage_workers(self):

def spawn_worker(self):
self.worker_age += 1
worker = self.worker_class(self.worker_age, self.pid, self.LISTENERS,
self.app, self.timeout / 2.0,
self.cfg, self.log)
worker = self.worker_class(
self.worker_age, self.pid, self.LISTENERS, self.app,
self.timeout / 2.0, self.cfg, self.log)
Comment on lines +613 to +615
Copy link
Contributor

Choose a reason for hiding this comment

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

looks like unrelated whitespace changes

self.cfg.pre_fork(self, worker)
pid = os.fork()
if pid != 0:
Expand Down
55 changes: 55 additions & 0 deletions gunicorn/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,61 @@ class WorkerConnections(Setting):
"""


class PruneFunction(Setting):
name = "prune_function"
section = "Worker Processes"
cli = ["--prune-function"]
validator = validate_callable(1)
type = callable

def prune_score(pid):
return 0
default = staticmethod(prune_score)
desc = """\
A function that is passed a process ID of a worker and returns a
score (such as total memory used). Once every prune seconds, the
worker with the highest score is killed (unless the score is below
the prune floor).
"""


class PruneSeconds(Setting):
name = "prune_seconds"
section = "Worker Processes"
cli = ["--prune-seconds"]
meta = "INT"
validator = validate_pos_int
type = int
default = 0
desc = """\
How many seconds to wait between killing the worker with the highest
score from the prune function. If set to 0 (the default), then no
pruning is done. The actual time waited is a random value between
95% and 105% of this value.

A worker handling an unusually large request can significantly grow
how much memory it is consuming for the rest of its existence. So
rare large requests will tend to eventually make every worker
unnecessarily large. If the large requests are indeed rare, then
you can significantly reduce the total memory used by your service
by periodically pruning the largest worker process.
"""


class PruneFloor(Setting):
name = "prune_floor"
section = "Worker Processes"
cli = ["--prune-floor"]
meta = "INT"
validator = validate_pos_int
type = int
default = 0
desc = """\
When the score from the prune function is at or below this value, the
worker will not be killed even if it has the highest score.
"""


class MaxRequests(Setting):
name = "max_requests"
section = "Worker Processes"
Expand Down