Skip to content
Merged
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
8 changes: 7 additions & 1 deletion docs/stack-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -371,4 +371,10 @@ Every service in a deployment is reachable from every other service by its servi
`gitea` service (located in another pod in the same deployment) at the hostname `gitea`.

This works the same way whether the deployment target is Docker Compose or Kubernetes: on Kubernetes each deployment
gets its own namespace, so the unqualified service name resolves within it.
gets its own namespace, so the unqualified service name resolves within it.

Declaring `ports:` is not what makes a service addressable. A service with no `ports:` at all -- a database whose
port nothing outside the deployment should reach, say -- still answers to its own name on both targets; on Kubernetes
it gets a headless Service (no cluster IP, no ports) whose name resolves to the pod. So there is no need to publish
a port merely to obtain a hostname, which on the Compose target would also publish it on the host under
`--map-ports-to-host`.
37 changes: 21 additions & 16 deletions src/stack/deploy/k8s/cluster_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,22 +243,27 @@ def get_services(self):
services = pod["services"]
for service_name in services:
service_info = services[service_name]
if "ports" in service_info:
int_ports = [int(p.split(":")[-1].replace("/udp", "")) for p in service_info["ports"]]
svc_ports = [client.V1ServicePort(port=p, target_port=p, name=f"{service_name}-{p}") for p in int_ports]
service = client.V1Service(
metadata=client.V1ObjectMeta(
name=service_name,
labels={"app": self.app_name, "service": service_name},
),
spec=client.V1ServiceSpec(
type="ClusterIP",
ports=svc_ports,
# TODO: For balancing, we should use some sort of shared tag among pods of the same type
selector={"app": self.app_name, "service": service_name},
),
)
ret.append(service)
int_ports = [int(p.split(":")[-1].replace("/udp", "")) for p in service_info.get("ports", [])]
svc_ports = [client.V1ServicePort(port=p, target_port=p, name=f"{service_name}-{p}") for p in int_ports]
# A service that declares no ports still needs a name: every service
# reaches every other by service name on both targets, and on compose
# that holds whether or not a port is published. A headless Service
# (no cluster IP, no ports) resolves the name to the pod's own IP,
# which is the closest k8s equivalent.
service = client.V1Service(
metadata=client.V1ObjectMeta(
name=service_name,
labels={"app": self.app_name, "service": service_name},
),
spec=client.V1ServiceSpec(
type="ClusterIP",
cluster_ip=None if svc_ports else "None",
ports=svc_ports if svc_ports else None,
# TODO: For balancing, we should use some sort of shared tag among pods of the same type
selector={"app": self.app_name, "service": service_name},
),
)
ret.append(service)
return ret

def get_pvcs(self):
Expand Down
16 changes: 14 additions & 2 deletions tests/unit/test_k8s_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,27 @@ def test_service_strips_udp_suffix_from_port(tmp_path):
assert svc["spec"]["ports"] == [{"name": "dns-53", "port": 53, "targetPort": 53}]


def test_service_omitted_when_no_ports(tmp_path):
def test_service_is_headless_when_no_ports(tmp_path):
"""A service with no ports is still addressable by name (issue #280).

Compose gives every container on the network a DNS name whether or not a
port is published, and the stack file contract says a service reaches every
other by service name. On k8s that costs a headless Service; without one
the name simply does not resolve, and the failure surfaces as a DNS error
inside whichever container tried to connect.
"""
pod = """\
services:
worker:
image: worker:local
"""
cluster_info = make_cluster_info(tmp_path, pod, k8s_spec())

assert cluster_info.get_services() == []
svc = k8s_dict(cluster_info.get_services()[0])
assert svc["metadata"]["name"] == "worker"
assert svc["spec"]["clusterIP"] == "None"
assert svc["spec"].get("ports") is None
assert svc["spec"]["selector"] == {"app": TEST_CLUSTER_ID, "service": "worker"}


# ---------------------------------------------------------------------------
Expand Down