Skip to content

Commit 9def1d2

Browse files
committed
feat(ctl): add gunicornc control interface
Add a control socket server and CLI client for runtime management of Gunicorn instances, similar to birdc for BIRD routing daemon. Features: - Control socket server running in arbiter process (asyncio/threaded) - gunicornc CLI with interactive and single-command modes - JSON protocol with length-prefixed framing - Commands: show workers/stats/config/listeners/dirty, worker add/remove/kill, dirty add/remove, reload, reopen, shutdown - Stats tracking (uptime, workers spawned/killed, reloads) - Configurable socket path and permissions New config options: - control_socket: Unix socket path (default: gunicorn.ctl) - control_socket_mode: Socket permissions (default: 0o600) - --no-control-socket: Disable control socket
1 parent 3cba17b commit 9def1d2

File tree

14 files changed

+2881
-0
lines changed

14 files changed

+2881
-0
lines changed

gunicorn/arbiter.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ def __init__(self, app):
7474
self.dirty_arbiter = None
7575
self.dirty_pidfile = None # Well-known location for orphan detection
7676

77+
# Control socket server
78+
self._control_server = None
79+
80+
# Stats tracking
81+
self._stats = {
82+
'start_time': None,
83+
'workers_spawned': 0,
84+
'workers_killed': 0,
85+
'reloads': 0,
86+
}
87+
7788
cwd = util.getcwd()
7889

7990
args = sys.argv[:]
@@ -133,6 +144,9 @@ def start(self):
133144
"""
134145
self.log.info("Starting gunicorn %s", __version__)
135146

147+
# Initialize stats tracking
148+
self._stats['start_time'] = time.time()
149+
136150
if 'GUNICORN_PID' in os.environ:
137151
self.master_pid = int(os.environ.get('GUNICORN_PID'))
138152
self.proc_name = self.proc_name + ".2"
@@ -179,6 +193,9 @@ def start(self):
179193
if self.cfg.dirty_workers > 0 and self.cfg.dirty_apps:
180194
self.spawn_dirty_arbiter()
181195

196+
# Start control socket server
197+
self._start_control_server()
198+
182199
self.cfg.when_ready(self)
183200

184201
def init_signals(self):
@@ -351,6 +368,9 @@ def wakeup(self):
351368

352369
def halt(self, reason=None, exit_status=0):
353370
""" halt arbiter """
371+
# Stop control socket server first
372+
self._stop_control_server()
373+
354374
self.stop()
355375

356376
log_func = self.log.info if exit_status == 0 else self.log.error
@@ -477,6 +497,9 @@ def reexec(self):
477497
os.execvpe(self.START_CTX[0], self.START_CTX['args'], environ)
478498

479499
def reload(self):
500+
# Track reload stats
501+
self._stats['reloads'] += 1
502+
480503
old_address = self.cfg.address
481504

482505
# reset old environment
@@ -667,6 +690,7 @@ def spawn_worker(self):
667690
if pid != 0:
668691
worker.pid = pid
669692
self.WORKERS[pid] = worker
693+
self._stats['workers_spawned'] += 1
670694
return pid
671695

672696
# Do not inherit the temporary files of other workers
@@ -737,6 +761,9 @@ def kill_worker(self, pid, sig):
737761
"""
738762
try:
739763
os.kill(pid, sig)
764+
# Track kills only on SIGTERM/SIGKILL (actual termination signals)
765+
if sig in (signal.SIGTERM, signal.SIGKILL):
766+
self._stats['workers_killed'] += 1
740767
except OSError as e:
741768
if e.errno == errno.ESRCH:
742769
try:
@@ -906,3 +933,51 @@ def manage_dirty_arbiter(self):
906933
if self.cfg.dirty_workers > 0 and self.cfg.dirty_apps:
907934
self.log.info("Spawning dirty arbiter...")
908935
self.spawn_dirty_arbiter()
936+
937+
# =========================================================================
938+
# Control Socket Management
939+
# =========================================================================
940+
941+
def _get_control_socket_path(self):
942+
"""Get the control socket path, making relative paths absolute."""
943+
socket_path = self.cfg.control_socket
944+
if not os.path.isabs(socket_path):
945+
socket_path = os.path.join(util.getcwd(), socket_path)
946+
return socket_path
947+
948+
def _start_control_server(self):
949+
"""\
950+
Start the control socket server.
951+
952+
The server runs in a background thread and accepts commands
953+
via Unix socket.
954+
"""
955+
if self.cfg.control_socket_disable:
956+
self.log.debug("Control socket disabled")
957+
return
958+
959+
# Lazy import to avoid circular imports and gevent compatibility
960+
from gunicorn.ctl.server import ControlSocketServer
961+
962+
socket_path = self._get_control_socket_path()
963+
socket_mode = self.cfg.control_socket_mode
964+
965+
try:
966+
self._control_server = ControlSocketServer(
967+
self, socket_path, socket_mode
968+
)
969+
self._control_server.start()
970+
except Exception as e:
971+
self.log.warning("Failed to start control socket: %s", e)
972+
self._control_server = None
973+
974+
def _stop_control_server(self):
975+
"""\
976+
Stop the control socket server.
977+
"""
978+
if self._control_server:
979+
try:
980+
self._control_server.stop()
981+
except Exception as e:
982+
self.log.debug("Error stopping control server: %s", e)
983+
self._control_server = None

gunicorn/config.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3113,3 +3113,63 @@ def dirty_worker_exit(arbiter, worker):
31133113
31143114
.. versionadded:: 25.0.0
31153115
"""
3116+
3117+
3118+
# Control Socket Settings
3119+
3120+
class ControlSocket(Setting):
3121+
name = "control_socket"
3122+
section = "Control"
3123+
cli = ["--control-socket"]
3124+
meta = "PATH"
3125+
validator = validate_string
3126+
default = "gunicorn.ctl"
3127+
desc = """\
3128+
Unix socket path for control interface.
3129+
3130+
The control socket allows runtime management of Gunicorn via the
3131+
``gunicornc`` command-line tool. Commands include viewing worker
3132+
status, adjusting worker count, and graceful reload/shutdown.
3133+
3134+
By default, creates ``gunicorn.ctl`` in the working directory.
3135+
Set an absolute path for a fixed location (e.g., ``/var/run/gunicorn.ctl``).
3136+
3137+
Use ``--no-control-socket`` to disable.
3138+
3139+
.. versionadded:: 25.1.0
3140+
"""
3141+
3142+
3143+
class ControlSocketMode(Setting):
3144+
name = "control_socket_mode"
3145+
section = "Control"
3146+
cli = ["--control-socket-mode"]
3147+
meta = "INT"
3148+
validator = validate_pos_int
3149+
type = auto_int
3150+
default = 0o600
3151+
desc = """\
3152+
Permission mode for control socket.
3153+
3154+
Restricts who can connect to the control socket. Default ``0600``
3155+
allows only the socket owner. Set to ``0660`` to allow group access.
3156+
3157+
.. versionadded:: 25.1.0
3158+
"""
3159+
3160+
3161+
class ControlSocketDisable(Setting):
3162+
name = "control_socket_disable"
3163+
section = "Control"
3164+
cli = ["--no-control-socket"]
3165+
validator = validate_bool
3166+
action = "store_true"
3167+
default = False
3168+
desc = """\
3169+
Disable control socket.
3170+
3171+
When set, no control socket is created and ``gunicornc`` cannot
3172+
connect to this Gunicorn instance.
3173+
3174+
.. versionadded:: 25.1.0
3175+
"""

gunicorn/ctl/__init__.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#
2+
# This file is part of gunicorn released under the MIT license.
3+
# See the NOTICE for more information.
4+
5+
"""
6+
Gunicorn Control Interface
7+
8+
Provides a control socket server for runtime management and
9+
a CLI client (gunicornc) for interacting with running Gunicorn instances.
10+
"""
11+
12+
from gunicorn.ctl.server import ControlSocketServer
13+
from gunicorn.ctl.client import ControlClient
14+
from gunicorn.ctl.protocol import ControlProtocol
15+
16+
__all__ = ['ControlSocketServer', 'ControlClient', 'ControlProtocol']

0 commit comments

Comments
 (0)