#!/usr/bin/env python3 """In-container receiver for the HAProxy access log. HAProxy sends its access log (`log ... format rfc5424`, hap_header.tpl) as datagrams to a UNIX socket inside this container. This process is the only reader: it writes one line per datagram to a size-capped, rotating file in the haproxy-logs volume (default /var/log/haproxy/access.log). Why it looks the way it does ---------------------------- * UNIX DATAGRAM socket, no network listener. Nothing is exposed on the host or on client-net (the old target was the host's rsyslog on the docker bridge, 172.18.0.1:514, which had no listener on the fleet). * HAProxy never waits for us. Its log sends are non-blocking: a dead receiver (ECONNREFUSED/ENOENT) or a full socket buffer (EAGAIN) drops that line and HAProxy carries on. So a crash, a stall or a full disk here can only lose log lines, never slow or fail a request. That is the intended direction. * Bounded on disk: the live file is rotated at MAX_BYTES and BACKUPS gzip generations are kept, so the worst case (incompressible data) is (1 + BACKUPS) * MAX_BYTES -- 5 * 40 MB = 200 MB by default. Compression runs in a thread so a rotation does not stall reading. * A full disk (ENOSPC) or any write error drops lines and is reported at most once a minute; the file is reopened on the next attempt. * Lines go to the FILE ONLY, never stdout: `docker logs haproxy-manager` is a source of WHP's AI log monitor, and a per-request firehose there would storm it (see the WHP note on audit/firehose sources). Our own status messages are rare and go to stderr. * Control characters inside a datagram are replaced so one datagram is always exactly one line (a crafted header cannot forge extra log lines). Supervised by start-up.sh (restart loop), started before HAProxy. Environment ----------- HAPROXY_LOG_SOCKET socket path (default /var/lib/haproxy/log.sock) HAPROXY_ACCESS_LOG file path (default /var/log/haproxy/access.log) HAPROXY_ACCESS_LOG_MAX_BYTES rotate size (default 41943040 = 40 MiB) HAPROXY_ACCESS_LOG_BACKUPS gzip generations kept (default 4) """ import gzip import os import shutil import socket import sys import threading import time # HAProxy sends at most `len` (2048) bytes per line; leave headroom. MAX_DATAGRAM = 65535 FLUSH_INTERVAL = 1.0 ERROR_REPORT_INTERVAL = 60.0 # Kernel receive buffer to absorb bursts while a write is slow. RCVBUF = 4 * 1024 * 1024 _CTRL = {c: ' ' for c in range(0x20) if c != 0x09} _CTRL[0x7f] = ' ' def _env_int(name, default, minimum): try: value = int(os.environ.get(name, default)) except ValueError: value = default return max(minimum, value) def clean_line(datagram): """One datagram -> one log line (str, no trailing newline). Drops the RFC 5424 `` prefix (constant for our single facility) and replaces any control character so the datagram cannot span lines. """ text = datagram.decode('utf-8', 'replace').rstrip('\r\n\x00') if text.startswith('<'): end = text.find('>', 1, 6) if end > 0 and text[1:end].isdigit(): text = text[end + 1:] return text.translate(_CTRL) class RotatingWriter: """Size-capped writer: live file + `backups` gzip generations.""" def __init__(self, path, max_bytes, backups, report=None): self.path = path self.max_bytes = max_bytes self.backups = backups self.report = report or (lambda msg: None) self.fh = None self.size = 0 self.dirty = False self.dropped = 0 self._compressor = None self._last_error = 0.0 # -- file handling -------------------------------------------------- def _open(self): os.makedirs(os.path.dirname(self.path) or '.', exist_ok=True) self.fh = open(self.path, 'a', encoding='utf-8', buffering=65536) os.chmod(self.path, 0o640) self.size = self.fh.tell() def _error(self, what, exc): self.dropped += 1 try: if self.fh: self.fh.close() except OSError: pass self.fh = None now = time.monotonic() if now - self._last_error >= ERROR_REPORT_INTERVAL: self._last_error = now self.report(f'{what} failed ({exc}); dropping access-log lines ' f'(dropped so far: {self.dropped})') def _gen(self, n): return f'{self.path}.{n}.gz' def _compress(self, src, dst): tmp = dst + '.tmp' try: with open(src, 'rb') as fin, gzip.open(tmp, 'wb', compresslevel=6) as fout: shutil.copyfileobj(fin, fout, 1024 * 1024) os.replace(tmp, dst) os.chmod(dst, 0o640) except OSError as exc: self.report(f'compressing {src} failed ({exc}); removing it to keep the size cap') for leftover in (tmp,): try: os.unlink(leftover) except OSError: pass finally: try: os.unlink(src) except OSError: pass def rotate(self): if self._compressor is not None: self._compressor.join() self._compressor = None if self.fh: try: self.fh.close() except OSError: pass self.fh = None if self.backups <= 0: try: os.unlink(self.path) except OSError: pass else: try: os.unlink(self._gen(self.backups)) except OSError: pass for n in range(self.backups - 1, 0, -1): if os.path.exists(self._gen(n)): os.replace(self._gen(n), self._gen(n + 1)) staged = f'{self.path}.1' if os.path.exists(self.path): os.replace(self.path, staged) self._compressor = threading.Thread( target=self._compress, args=(staged, self._gen(1)), daemon=True) self._compressor.start() self.size = 0 # -- public --------------------------------------------------------- def write(self, line): data = line + '\n' try: if self.fh is None: self._open() if self.size and self.size + len(data) > self.max_bytes: self.rotate() self._open() self.fh.write(data) self.size += len(data.encode('utf-8')) self.dirty = True except OSError as exc: self._error('writing ' + self.path, exc) def flush(self): if self.fh and self.dirty: try: self.fh.flush() self.dirty = False except OSError as exc: self._error('flushing ' + self.path, exc) def close(self): self.flush() if self._compressor is not None: self._compressor.join() if self.fh: try: self.fh.close() except OSError: pass def bind_socket(path): sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) try: sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, RCVBUF) except OSError: pass os.makedirs(os.path.dirname(path) or '.', exist_ok=True) try: os.unlink(path) # stale socket from a previous (crashed) receiver except FileNotFoundError: pass sock.bind(path) # HAProxy's workers run as user haproxy (hap_header.tpl `user haproxy`). os.chmod(path, 0o666) sock.settimeout(FLUSH_INTERVAL) return sock def serve(sock, writer, stop=None): last_flush = time.monotonic() while stop is None or not stop.is_set(): try: datagram = sock.recv(MAX_DATAGRAM) except socket.timeout: datagram = None except OSError: if stop is not None and stop.is_set(): break raise if datagram: writer.write(clean_line(datagram)) now = time.monotonic() if now - last_flush >= FLUSH_INTERVAL: writer.flush() last_flush = now def main(): sock_path = os.environ.get('HAPROXY_LOG_SOCKET', '/var/lib/haproxy/log.sock') log_path = os.environ.get('HAPROXY_ACCESS_LOG', '/var/log/haproxy/access.log') max_bytes = _env_int('HAPROXY_ACCESS_LOG_MAX_BYTES', 40 * 1024 * 1024, 64 * 1024) backups = _env_int('HAPROXY_ACCESS_LOG_BACKUPS', 4, 0) def report(msg): sys.stderr.write(f'haproxy-log-receiver: {msg}\n') sys.stderr.flush() writer = RotatingWriter(log_path, max_bytes, backups, report) sock = bind_socket(sock_path) report(f'listening on {sock_path} -> {log_path} ' f'(rotate at {max_bytes} bytes, keep {backups} gz)') try: serve(sock, writer) finally: writer.close() if __name__ == '__main__': main()