From 26327e1e8b1d37faa764ec586f5bee0e1560eea2 Mon Sep 17 00:00:00 2001 From: Darrell Bishop Date: Thu, 21 Jan 2016 11:18:18 -0800 Subject: [PATCH] Allow IPv6 addresses/hostnames in StatsD target The log_statsd_host value can now be an IPv6 address or a hostname which only resolves to an IPv6 address. In both cases, the new behavior is to use an AF_INET6 socket on which .sendto() is called with the originally-configured hostname (or IP). This means the Swift process is not caching a DNS resolution for the lifetime of the process (a good thing). If a hostname resolves to both an IPv6 or IPv4 address, an AF_INET socket is used (i.e. only the IPv4 address will receive the UDP packet). The old behavior is preserved: any invalid IP address literals and failures in DNS resolution or actual StatsD packet sending do not halt the process or bubble up; they are caught, logged, and otherwise ignored. Change-Id: Ibddddcf140e2e69b08edf3feed3e9a5fa17307cf --- doc/manpages/proxy-server.conf.5 | 4 +- doc/source/admin_guide.rst | 6 ++- doc/source/deployment_guide.rst | 6 ++- swift/common/utils.py | 38 +++++++++++++- test/unit/common/test_utils.py | 89 ++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 5 deletions(-) diff --git a/doc/manpages/proxy-server.conf.5 b/doc/manpages/proxy-server.conf.5 index fe63165f56..b5ceb684bc 100644 --- a/doc/manpages/proxy-server.conf.5 +++ b/doc/manpages/proxy-server.conf.5 @@ -119,7 +119,9 @@ If set, log_udp_host will override log_address. .IP "\fBlog_udp_port\fR UDP log port, the default is 514. .IP \fBlog_statsd_host\fR = localhost -log_statsd_* enable StatsD logging. +log_statsd_* enable StatsD logging. IPv4/IPv6 addresses and hostnames are +supported. If a hostname resolves to an IPv4 and IPv6 address, the IPv4 +address will be used. .IP \fBlog_statsd_port\fR The default is 8125. .IP \fBlog_statsd_default_sample_rate\fR diff --git a/doc/source/admin_guide.rst b/doc/source/admin_guide.rst index cb6532b4be..6dacb2a24f 100644 --- a/doc/source/admin_guide.rst +++ b/doc/source/admin_guide.rst @@ -624,7 +624,11 @@ configuration entries (see the sample configuration files):: log_statsd_metric_prefix = [empty-string] If `log_statsd_host` is not set, this feature is disabled. The default values -for the other settings are given above. +for the other settings are given above. The `log_statsd_host` can be a +hostname, an IPv4 address, or an IPv6 address (not surrounded with brackets, as +this is unnecessary since the port is specified separately). If a hostname +resolves to an IPv4 address, an IPv4 socket will be used to send StatsD UDP +packets, even if the hostname would also resolve to an IPv6 address. .. _StatsD: http://codeascraft.etsy.com/2011/02/15/measure-anything-measure-everything/ .. _Graphite: http://graphite.wikidot.com/ diff --git a/doc/source/deployment_guide.rst b/doc/source/deployment_guide.rst index a0250fcabe..21a1ca12e8 100644 --- a/doc/source/deployment_guide.rst +++ b/doc/source/deployment_guide.rst @@ -478,7 +478,11 @@ log_custom_handlers None Comma-separated list of functions t to setup custom log handlers. log_udp_host Override log_address log_udp_port 514 UDP log port -log_statsd_host localhost StatsD logging +log_statsd_host localhost StatsD logging; IPv4/IPv6 + address or a hostname. If a + hostname resolves to an IPv4 and IPv6 + address, the IPv4 address will be + used. log_statsd_port 8125 log_statsd_default_sample_rate 1.0 log_statsd_sample_rate_factor 1.0 diff --git a/swift/common/utils.py b/swift/common/utils.py index a7615220a2..5eb7113f48 100644 --- a/swift/common/utils.py +++ b/swift/common/utils.py @@ -1141,10 +1141,44 @@ class StatsdClient(object): self.set_prefix(tail_prefix) self._default_sample_rate = default_sample_rate self._sample_rate_factor = sample_rate_factor - self._target = (self._host, self._port) self.random = random self.logger = logger + # Determine if host is IPv4 or IPv6 + addr_info = None + try: + addr_info = socket.getaddrinfo(host, port, socket.AF_INET) + self._sock_family = socket.AF_INET + except socket.gaierror: + try: + addr_info = socket.getaddrinfo(host, port, socket.AF_INET6) + self._sock_family = socket.AF_INET6 + except socket.gaierror: + # Don't keep the server from starting from what could be a + # transient DNS failure. Any hostname will get re-resolved as + # necessary in the .sendto() calls. + # However, we don't know if we're IPv4 or IPv6 in this case, so + # we assume legacy IPv4. + self._sock_family = socket.AF_INET + + # NOTE: we use the original host value, not the DNS-resolved one + # because if host is a hostname, we don't want to cache the DNS + # resolution for the entire lifetime of this process. Let standard + # name resolution caching take effect. This should help operators use + # DNS trickery if they want. + if addr_info is not None: + # addr_info is a list of 5-tuples with the following structure: + # (family, socktype, proto, canonname, sockaddr) + # where sockaddr is the only thing of interest to us, and we only + # use the first result. We want to use the originally supplied + # host (see note above) and the remainder of the variable-length + # sockaddr: IPv4 has (address, port) while IPv6 has (address, + # port, flow info, scope id). + sockaddr = addr_info[0][-1] + self._target = (host,) + (sockaddr[1:]) + else: + self._target = (host, port) + def set_prefix(self, new_prefix): if new_prefix and self._base_prefix: self._prefix = '.'.join([self._base_prefix, new_prefix, '']) @@ -1179,7 +1213,7 @@ class StatsdClient(object): self._target, err) def _open_socket(self): - return socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + return socket.socket(self._sock_family, socket.SOCK_DGRAM) def update_stats(self, m_name, m_value, sample_rate=None): return self._send(m_name, m_value, 'c', sample_rate) diff --git a/test/unit/common/test_utils.py b/test/unit/common/test_utils.py index 8f5f82eff0..3a1ba2914f 100644 --- a/test/unit/common/test_utils.py +++ b/test/unit/common/test_utils.py @@ -3625,6 +3625,95 @@ class TestStatsdLogging(unittest.TestCase): self.assertEqual(logger.logger.statsd_client._sample_rate_factor, 0.81) + def test_ipv4_or_ipv6_hostname_defaults_to_ipv4(self): + def stub_getaddrinfo_both_ipv4_and_ipv6(host, port, family, *rest): + if family == socket.AF_INET: + return [(socket.AF_INET, 'blah', 'blah', 'blah', + ('127.0.0.1', int(port)))] + elif family == socket.AF_INET6: + # Implemented so an incorrectly ordered implementation (IPv6 + # then IPv4) would realistically fail. + return [(socket.AF_INET6, 'blah', 'blah', 'blah', + ('::1', int(port), 0, 0))] + + with mock.patch.object(utils.socket, 'getaddrinfo', + new=stub_getaddrinfo_both_ipv4_and_ipv6): + logger = utils.get_logger({ + 'log_statsd_host': 'localhost', + 'log_statsd_port': '9876', + }, 'some-name', log_route='some-route') + statsd_client = logger.logger.statsd_client + + self.assertEqual(statsd_client._sock_family, socket.AF_INET) + self.assertEqual(statsd_client._target, ('localhost', 9876)) + + got_sock = statsd_client._open_socket() + self.assertEqual(got_sock.family, socket.AF_INET) + + def test_ipv4_instantiation_and_socket_creation(self): + logger = utils.get_logger({ + 'log_statsd_host': '127.0.0.1', + 'log_statsd_port': '9876', + }, 'some-name', log_route='some-route') + statsd_client = logger.logger.statsd_client + + self.assertEqual(statsd_client._sock_family, socket.AF_INET) + self.assertEqual(statsd_client._target, ('127.0.0.1', 9876)) + + got_sock = statsd_client._open_socket() + self.assertEqual(got_sock.family, socket.AF_INET) + + def test_ipv6_instantiation_and_socket_creation(self): + # We have to check the given hostname or IP for IPv4/IPv6 on logger + # instantiation so we don't call getaddrinfo() too often and don't have + # to call bind() on our socket to detect IPv4/IPv6 on every send. + logger = utils.get_logger({ + 'log_statsd_host': '::1', + 'log_statsd_port': '9876', + }, 'some-name', log_route='some-route') + statsd_client = logger.logger.statsd_client + + self.assertEqual(statsd_client._sock_family, socket.AF_INET6) + self.assertEqual(statsd_client._target, ('::1', 9876, 0, 0)) + + got_sock = statsd_client._open_socket() + self.assertEqual(got_sock.family, socket.AF_INET6) + + def test_bad_hostname_instantiation(self): + logger = utils.get_logger({ + 'log_statsd_host': 'i-am-not-a-hostname-or-ip', + 'log_statsd_port': '9876', + }, 'some-name', log_route='some-route') + statsd_client = logger.logger.statsd_client + + self.assertEqual(statsd_client._sock_family, socket.AF_INET) + self.assertEqual(statsd_client._target, + ('i-am-not-a-hostname-or-ip', 9876)) + + got_sock = statsd_client._open_socket() + self.assertEqual(got_sock.family, socket.AF_INET) + # Maybe the DNS server gets fixed in a bit and it starts working... or + # maybe the DNS record hadn't propagated yet. In any case, failed + # statsd sends will warn in the logs until the DNS failure or invalid + # IP address in the configuration is fixed. + + def test_sending_ipv6(self): + logger = utils.get_logger({ + 'log_statsd_host': '::1', + 'log_statsd_port': '9876', + }, 'some-name', log_route='some-route') + statsd_client = logger.logger.statsd_client + + fl = FakeLogger() + statsd_client.logger = fl + mock_socket = MockUdpSocket() + + statsd_client._open_socket = lambda *_: mock_socket + logger.increment('tunafish') + self.assertEqual(fl.get_lines_for_level('warning'), []) + self.assertEqual(mock_socket.sent, + [(b'some-name.tunafish:1|c', ('::1', 9876, 0, 0))]) + def test_no_exception_when_cant_send_udp_packet(self): logger = utils.get_logger({'log_statsd_host': 'some.host.com'}) statsd_client = logger.logger.statsd_client