Skip to content

Commit fcc4a4b

Browse files
authored
[MRG] add "sticky builds" functionality (#949)
[MRG] add "sticky builds" functionality
2 parents 31ab3c7 + 34c9eee commit fcc4a4b

File tree

6 files changed

+254
-36
lines changed

6 files changed

+254
-36
lines changed

binderhub/app.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ def _valid_badge_base_url(self, proposal):
196196
config=True,
197197
)
198198

199+
sticky_builds = Bool(
200+
False,
201+
help="""
202+
Attempt to assign builds for the same repository to the same node.
203+
204+
In order to speed up re-builds of a repository all its builds will
205+
be assigned to the same node in the cluster.
206+
207+
Note: This feature only works if you also enable docker-in-docker support.
208+
""",
209+
config=True,
210+
)
211+
199212
use_registry = Bool(
200213
True,
201214
help="""
@@ -556,6 +569,7 @@ def initialize(self, *args, **kwargs):
556569
"build_image": self.build_image,
557570
'build_node_selector': self.build_node_selector,
558571
'build_pool': self.build_pool,
572+
"sticky_builds": self.sticky_builds,
559573
'log_tail_lines': self.log_tail_lines,
560574
'pod_quota': self.pod_quota,
561575
'per_repo_quota': self.per_repo_quota,

binderhub/build.py

Lines changed: 73 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from tornado.ioloop import IOLoop
1313
from tornado.log import app_log
1414

15+
from .utils import rendezvous_rank
16+
1517

1618
class Build:
1719
"""Represents a build of a git repository into a docker image.
@@ -36,7 +38,7 @@ class Build:
3638
"""
3739
def __init__(self, q, api, name, namespace, repo_url, ref, git_credentials, build_image,
3840
image_name, push_secret, memory_limit, docker_host, node_selector,
39-
appendix='', log_tail_lines=100):
41+
appendix='', log_tail_lines=100, sticky_builds=False):
4042
self.q = q
4143
self.api = api
4244
self.repo_url = repo_url
@@ -56,6 +58,10 @@ def __init__(self, q, api, name, namespace, repo_url, ref, git_credentials, buil
5658
self.stop_event = threading.Event()
5759
self.git_credentials = git_credentials
5860

61+
self.sticky_builds = sticky_builds
62+
63+
self._component_label = "binderhub-build"
64+
5965
def get_cmd(self):
6066
"""Get the cmd to run to build the image"""
6167
cmd = [
@@ -144,8 +150,71 @@ def progress(self, kind, obj):
144150
"""Put the current action item into the queue for execution."""
145151
self.main_loop.add_callback(self.q.put, {'kind': kind, 'payload': obj})
146152

153+
def get_affinity(self):
154+
"""Determine the affinity term for the build pod.
155+
156+
There are a two affinity strategies, which one is used depends on how
157+
the BinderHub is configured.
158+
159+
In the default setup the affinity of each build pod is an "anti-affinity"
160+
which causes the pods to prefer to schedule on separate nodes.
161+
162+
In a setup with docker-in-docker enabled pods for a particular
163+
repository prefer to schedule on the same node in order to reuse the
164+
docker layer cache of previous builds.
165+
"""
166+
dind_pods = self.api.list_namespaced_pod(
167+
self.namespace,
168+
label_selector="component=dind,app=binder",
169+
)
170+
171+
if self.sticky_builds and dind_pods:
172+
node_names = [pod.spec.node_name for pod in dind_pods.items]
173+
ranked_nodes = rendezvous_rank(node_names, self.repo_url)
174+
best_node_name = ranked_nodes[0]
175+
176+
affinity = client.V1Affinity(
177+
node_affinity=client.V1NodeAffinity(
178+
preferred_during_scheduling_ignored_during_execution=[
179+
client.V1PreferredSchedulingTerm(
180+
weight=100,
181+
preference=client.V1NodeSelectorTerm(
182+
match_fields=[
183+
client.V1NodeSelectorRequirement(
184+
key="metadata.name",
185+
operator="In",
186+
values=[best_node_name],
187+
)
188+
]
189+
),
190+
)
191+
]
192+
)
193+
)
194+
195+
else:
196+
affinity = client.V1Affinity(
197+
pod_anti_affinity=client.V1PodAntiAffinity(
198+
preferred_during_scheduling_ignored_during_execution=[
199+
client.V1WeightedPodAffinityTerm(
200+
weight=100,
201+
pod_affinity_term=client.V1PodAffinityTerm(
202+
topology_key="kubernetes.io/hostname",
203+
label_selector=client.V1LabelSelector(
204+
match_labels=dict(
205+
component=self._component_label
206+
)
207+
)
208+
)
209+
)
210+
]
211+
)
212+
)
213+
214+
return affinity
215+
147216
def submit(self):
148-
"""Submit a image spec to openshift's s2i and wait for completion """
217+
"""Submit a build pod to create the image for the repository."""
149218
volume_mounts = [
150219
client.V1VolumeMount(mount_path="/var/run/docker.sock", name="docker-socket")
151220
]
@@ -166,13 +235,12 @@ def submit(self):
166235
if self.git_credentials:
167236
env.append(client.V1EnvVar(name='GIT_CREDENTIAL_ENV', value=self.git_credentials))
168237

169-
component_label = "binderhub-build"
170238
self.pod = client.V1Pod(
171239
metadata=client.V1ObjectMeta(
172240
name=self.name,
173241
labels={
174242
"name": self.name,
175-
"component": component_label,
243+
"component": self._component_label,
176244
},
177245
annotations={
178246
"binder-repo": self.repo_url,
@@ -211,23 +279,7 @@ def submit(self):
211279
node_selector=self.node_selector,
212280
volumes=volumes,
213281
restart_policy="Never",
214-
affinity=client.V1Affinity(
215-
pod_anti_affinity=client.V1PodAntiAffinity(
216-
preferred_during_scheduling_ignored_during_execution=[
217-
client.V1WeightedPodAffinityTerm(
218-
weight=100,
219-
pod_affinity_term=client.V1PodAffinityTerm(
220-
topology_key="kubernetes.io/hostname",
221-
label_selector=client.V1LabelSelector(
222-
match_labels=dict(
223-
component=component_label
224-
)
225-
)
226-
)
227-
)
228-
]
229-
)
230-
)
282+
affinity=self.get_affinity()
231283
)
232284
)
233285

binderhub/builder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,8 @@ async def get(self, provider_prefix, _unescaped_spec):
340340
node_selector=self.settings['build_node_selector'],
341341
appendix=appendix,
342342
log_tail_lines=self.settings['log_tail_lines'],
343-
git_credentials=provider.git_credentials
343+
git_credentials=provider.git_credentials,
344+
sticky_builds=self.settings['sticky_builds'],
344345
)
345346

346347
with BUILDS_INPROGRESS.track_inprogress():

binderhub/tests/test_build.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
import json
44
import sys
5+
from collections import namedtuple
56
from unittest import mock
67
from urllib.parse import quote
78

89
import pytest
910
from tornado.httputil import url_concat
1011

12+
from kubernetes import client
13+
1114
from binderhub.build import Build
1215
from .utils import async_requests
1316

@@ -51,6 +54,57 @@ async def test_build(app, needs_build, needs_launch, always_build, slug, pytestc
5154
assert r.url.startswith(final['url'])
5255

5356

57+
def test_default_affinity():
58+
# check that the default affinity is a pod anti-affinity
59+
build = Build(
60+
mock.MagicMock(), api=mock.MagicMock(), name='test_build',
61+
namespace='build_namespace', repo_url=mock.MagicMock(),
62+
ref=mock.MagicMock(), build_image=mock.MagicMock(),
63+
image_name=mock.MagicMock(), push_secret=mock.MagicMock(),
64+
memory_limit=mock.MagicMock(), git_credentials=None,
65+
docker_host='http://mydockerregistry.local',
66+
node_selector=mock.MagicMock())
67+
68+
affinity = build.get_affinity()
69+
70+
assert isinstance(affinity, client.V1Affinity)
71+
assert affinity.node_affinity is None
72+
assert affinity.pod_affinity is None
73+
assert affinity.pod_anti_affinity is not None
74+
75+
76+
def test_sticky_builds_affinity():
77+
# Setup some mock objects for the response from the k8s API
78+
Pod = namedtuple("Pod", "spec")
79+
PodSpec = namedtuple("PodSpec", "node_name")
80+
PodList = namedtuple("PodList", "items")
81+
82+
mock_k8s_api = mock.MagicMock()
83+
mock_k8s_api.list_namespaced_pod.return_value = PodList(
84+
[Pod(PodSpec("node-a")), Pod(PodSpec("node-b"))],
85+
)
86+
87+
build = Build(
88+
mock.MagicMock(), api=mock_k8s_api, name='test_build',
89+
namespace='build_namespace', repo_url=mock.MagicMock(),
90+
ref=mock.MagicMock(), build_image=mock.MagicMock(),
91+
image_name=mock.MagicMock(), push_secret=mock.MagicMock(),
92+
memory_limit=mock.MagicMock(), git_credentials=None,
93+
docker_host='http://mydockerregistry.local',
94+
node_selector=mock.MagicMock(),
95+
sticky_builds=True)
96+
97+
affinity = build.get_affinity()
98+
99+
assert isinstance(affinity, client.V1Affinity)
100+
assert affinity.node_affinity is not None
101+
assert affinity.pod_affinity is None
102+
assert affinity.pod_anti_affinity is None
103+
104+
# One of the two nodes we have in our mock should be the preferred node
105+
assert affinity.node_affinity.preferred_during_scheduling_ignored_during_execution[0].preference.match_fields[0].values[0] in ("node-a", "node-b")
106+
107+
54108
def test_git_credentials_passed_to_podspec_upon_submit():
55109
git_credentials = {
56110
'client_id': 'my_username',

binderhub/tests/test_utils.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
from binderhub import utils
2+
3+
4+
def test_rendezvous_rank():
5+
# check that a key doesn't move if its assigned bucket remains but the
6+
# other buckets are removed
7+
key = "crazy frog is a crazy key"
8+
first_round = utils.rendezvous_rank(["b1", "b2", "b3"], key)
9+
second_round = utils.rendezvous_rank([first_round[0], first_round[1]], key)
10+
11+
assert first_round[0] == second_round[0], key
12+
13+
14+
def test_rendezvous_independence():
15+
# check that the relative ranking of 80 buckets doesn't depend on the
16+
# presence of 20 extra buckets
17+
key = "k1"
18+
eighty_buckets = utils.rendezvous_rank(["b%i" % i for i in range(80)], key)
19+
hundred_buckets = utils.rendezvous_rank(["b%i" % i for i in range(100)], key)
20+
21+
for i in range(80, 100):
22+
hundred_buckets.remove("b%i" % i)
23+
24+
assert eighty_buckets == hundred_buckets
25+
26+
27+
def test_rendezvous_redistribution():
28+
# check that approximately a third of keys move to the new bucket
29+
# when one is added
30+
n_keys = 3000
31+
32+
# count how many keys were moved, which bucket a key started from and
33+
# which bucket a key was moved from (to the new bucket)
34+
n_moved = 0
35+
from_bucket = {"b1": 0, "b2": 0}
36+
start_in = {"b1": 0, "b2": 0}
37+
38+
for i in range(n_keys):
39+
key = f"key-{i}"
40+
two_buckets = utils.rendezvous_rank(["b1", "b2"], key)
41+
start_in[two_buckets[0]] += 1
42+
three_buckets = utils.rendezvous_rank(["b1", "b2", "b3"], key)
43+
44+
if two_buckets[0] != three_buckets[0]:
45+
n_moved += 1
46+
from_bucket[two_buckets[0]] += 1
47+
48+
# should always move to the newly added bucket
49+
assert three_buckets[0] == "b3"
50+
51+
# because of statistical fluctuations we have to leave some room when
52+
# making this comparison
53+
assert 0.31 < n_moved / n_keys < 0.35
54+
# keys should move from the two original buckets with approximately
55+
# equal probability. We pick 30 because it is "about right"
56+
assert abs(from_bucket["b1"] - from_bucket["b2"]) < 30
57+
# the initial distribution of keys should be roughly the same
58+
# We pick 30 because it is "about right"
59+
assert abs(start_in["b1"] - start_in["b2"]) < 30

0 commit comments

Comments
 (0)