Skip to content

Commit 54b869b

Browse files
committed
chore(controller): add overcommit cpu and ram support
1 parent d21320a commit 54b869b

5 files changed

Lines changed: 110 additions & 49 deletions

File tree

rootfs/api/models/app.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import backoff
22
import base64
3+
import math
34
from collections import OrderedDict
45
from datetime import datetime
56
from docker import auth as docker_auth
@@ -11,7 +12,9 @@
1112
import requests
1213
import string
1314
import time
15+
from itertools import groupby
1416
from urllib.parse import urljoin
17+
from collections import defaultdict
1518

1619
from django.conf import settings
1720
from django.db import models
@@ -1084,6 +1087,46 @@ def _get_private_registry_config(self, image, registry=None):
10841087
})
10851088
return docker_config, name, True
10861089

1090+
@staticmethod
1091+
def _get_cpu_allocation(size):
1092+
cpu_allocation_ratio = settings.KUBERNETES_CPU_ALLOCATION_RATIO
1093+
num, unit = (
1094+
''.join(item[1]) for item in groupby(
1095+
size, key=lambda x: x.isdigit()
1096+
)
1097+
)
1098+
return "{num}{unit}".format(
1099+
num=math.ceil(int(num) / cpu_allocation_ratio),
1100+
unit=unit
1101+
)
1102+
1103+
@staticmethod
1104+
def _get_ram_allocation(size):
1105+
ram_allocation_ratio = settings.KUBERNETES_RAM_ALLOCATION_RATIO
1106+
num, unit = (
1107+
''.join(item[1]) for item in groupby(
1108+
size, key=lambda x: x.isdigit()
1109+
)
1110+
)
1111+
return "{num}{unit}".format(
1112+
num=math.ceil(int(num) / ram_allocation_ratio),
1113+
unit=unit
1114+
)
1115+
1116+
def _get_default_resources(self):
1117+
resources = defaultdict(dict)
1118+
resources.update(
1119+
json.loads(settings.KUBERNETES_POD_DEFAULT_RESOURCES))
1120+
if "cpu" in resources["limits"]:
1121+
if "cpu" not in resources["requests"]:
1122+
resources["requests"]["cpu"] = self._get_cpu_allocation(
1123+
resources["limits"]["cpu"])
1124+
if "memory" in resources["limits"]:
1125+
if "memory" not in resources["requests"]:
1126+
resources["requests"]["memory"] = self._get_ram_allocation(
1127+
resources["limits"]["memory"])
1128+
return resources
1129+
10871130
def _gather_app_settings(self, release, app_settings, process_type, replicas, volumes=None):
10881131
"""
10891132
Gathers all required information needed in one easy place for passing into
@@ -1093,7 +1136,11 @@ def _gather_app_settings(self, release, app_settings, process_type, replicas, vo
10931136
"""
10941137
envs = self._build_env_vars(release)
10951138
config = release.config
1096-
1139+
cpu, ram = {}, {}
1140+
for key, value in config.cpu.items():
1141+
cpu[key] = "%s/%s" % (self._get_cpu_allocation(value), value)
1142+
for key, value in config.memory.items():
1143+
ram[key] = "%s/%s" % (self._get_ram_allocation(value), value)
10971144
# see if the app config has deploy batch preference, otherwise use global
10981145
batches = int(config.values.get('DRYCC_DEPLOY_BATCHES', settings.DRYCC_DEPLOY_BATCHES)) # noqa
10991146

@@ -1108,9 +1155,6 @@ def _gather_app_settings(self, release, app_settings, process_type, replicas, vo
11081155
pod_termination_grace_period_seconds = int(config.values.get(
11091156
'KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS', settings.KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS)) # noqa
11101157

1111-
# get pod default resources
1112-
pod_default_resources = json.loads(settings.KUBERNETES_POD_DEFAULT_RESOURCES)
1113-
11141158
# set the image pull policy that is associated with the application container
11151159
image_pull_policy = config.values.get('IMAGE_PULL_POLICY', settings.IMAGE_PULL_POLICY)
11161160

@@ -1135,15 +1179,15 @@ def _gather_app_settings(self, release, app_settings, process_type, replicas, vo
11351179
} for _ in volumes] if volumes else []
11361180

11371181
return {
1138-
'memory': config.memory,
1139-
'cpu': config.cpu,
1182+
'memory': ram,
1183+
'cpu': cpu,
11401184
'tags': config.tags,
11411185
'envs': envs,
11421186
'registry': config.registry,
11431187
'replicas': replicas,
11441188
'version': 'v{}'.format(release.version),
11451189
'app_type': process_type,
1146-
'resources': pod_default_resources,
1190+
'resources': self._get_default_resources(),
11471191
'build_type': release.build.type,
11481192
'healthcheck': healthcheck,
11491193
'lifecycle_post_start': config.lifecycle_post_start,

rootfs/api/serializers.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@
2424
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z0-9]+(\-[a-z0-9]+)*)$')
2525
PROCTYPE_MISMATCH_MSG = "Process types can only contain lowercase alphanumeric characters"
2626
MEMLIMIT_MATCH = re.compile(
27-
r'^(?P<mem>(([0-9]+(MB|KB|GB|[BKMG])|0)(/([0-9]+(MB|KB|GB|[BKMG])))?))$', re.IGNORECASE)
27+
r'^(?P<mem>([0-9]+(MB|KB|GB|[BKMG])|0))$', re.IGNORECASE)
2828
CPUSHARE_MATCH = re.compile(
29-
r'^(?P<cpu>(([-+]?[0-9]*\.?[0-9]+[m]?)(/([-+]?[0-9]*\.?[0-9]+[m]?))?))$')
29+
r'^(?P<cpu>([-+]?[0-9]*\.?[0-9]+[m]?))$')
3030
TAGVAL_MATCH = re.compile(r'^(?:[a-zA-Z\d][-\.\w]{0,61})?[a-zA-Z\d]$')
3131
CONFIGKEY_MATCH = re.compile(r'^[a-z_]+[a-z0-9_]*$', re.IGNORECASE)
3232
TERMINATION_GRACE_PERIOD_MATCH = re.compile(r'^[0-9]*$')
3333
VOLUME_SIZE_MATCH = re.compile(
34-
r'^(?P<mem>(([0-9]+(MB|KB|GB|[BKMG])|0)(/([0-9]+(MB|KB|GB|[BKMG])))?))$', re.IGNORECASE)
34+
r'^(?P<mem>([0-9]+(MB|KB|GB|[BKMG])|0))$', re.IGNORECASE)
3535
VOLUME_PATH = re.compile(r'^\/(\w+\/?)+$', re.IGNORECASE)
3636

3737
PROBE_SCHEMA = {
@@ -201,7 +201,8 @@ class Meta:
201201
fields = ['owner', 'app', 'image', 'stack', 'sha', 'procfile',
202202
'dockerfile', 'created', 'updated', 'uuid']
203203

204-
def validate_procfile(self, data):
204+
@staticmethod
205+
def validate_procfile(data):
205206
for key, value in data.items():
206207
if value is None or value == "":
207208
raise serializers.ValidationError("Command can't be empty for process type")
@@ -233,7 +234,8 @@ class Meta:
233234
model = models.Config
234235
fields = '__all__'
235236

236-
def validate_values(self, data):
237+
@staticmethod
238+
def validate_values(data):
237239
for key, value in data.items():
238240
if value is None: # use NoneType to unset an item
239241
continue
@@ -278,7 +280,8 @@ def validate_values(self, data):
278280

279281
return data
280282

281-
def validate_memory(self, data):
283+
@staticmethod
284+
def validate_memory(data):
282285
for key, value in data.items():
283286
if value is None: # use NoneType to unset an item
284287
continue
@@ -288,12 +291,13 @@ def validate_memory(self, data):
288291

289292
if not re.match(MEMLIMIT_MATCH, str(value)):
290293
raise serializers.ValidationError(
291-
"Memory limit format: <number><unit> or <number><unit>/<number><unit>, "
294+
"Memory limit format: <number><unit>, "
292295
"where unit = B, K, M or G")
293296

294297
return data
295298

296-
def validate_cpu(self, data):
299+
@staticmethod
300+
def validate_cpu(data):
297301
for key, value in data.items():
298302
if value is None: # use NoneType to unset an item
299303
continue
@@ -304,11 +308,12 @@ def validate_cpu(self, data):
304308
shares = re.match(CPUSHARE_MATCH, str(value))
305309
if not shares:
306310
raise serializers.ValidationError(
307-
"CPU limit format: <value> or <value>/<value>, where value must be a numeric")
311+
"CPU limit format: <value>, where value must be a numeric")
308312

309313
return data
310314

311-
def validate_termination_grace_period(self, data):
315+
@staticmethod
316+
def validate_termination_grace_period(data):
312317
for key, value in data.items():
313318
if value is None: # use NoneType to unset an item
314319
continue
@@ -323,7 +328,8 @@ def validate_termination_grace_period(self, data):
323328

324329
return data
325330

326-
def validate_tags(self, data):
331+
@staticmethod
332+
def validate_tags(data):
327333
for key, value in data.items():
328334
if value is None: # use NoneType to unset an item
329335
continue
@@ -357,7 +363,8 @@ def validate_tags(self, data):
357363

358364
return data
359365

360-
def validate_registry(self, data):
366+
@staticmethod
367+
def validate_registry(data):
361368
for key, value in data.items():
362369
if value is None: # use NoneType to unset an item
363370
continue
@@ -369,7 +376,8 @@ def validate_registry(self, data):
369376

370377
return data
371378

372-
def validate_healthcheck(self, data):
379+
@staticmethod
380+
def validate_healthcheck(data):
373381
for procType, healthcheck in data.items():
374382
if healthcheck is None:
375383
continue
@@ -432,7 +440,8 @@ class Meta:
432440
fields = ['owner', 'created', 'updated', 'app', 'domain']
433441
read_only_fields = ['uuid']
434442

435-
def validate_domain(self, value):
443+
@staticmethod
444+
def validate_domain(value):
436445
"""
437446
Check that the hostname is valid
438447
"""
@@ -496,13 +505,15 @@ class Meta:
496505
fields = ['owner', 'created', 'updated', 'app', 'procfile_type', 'path_pattern']
497506
read_only_fields = ['uuid']
498507

499-
def validate_procfile_type(self, value):
508+
@staticmethod
509+
def validate_procfile_type(value):
500510
if not re.match(PROCTYPE_MATCH, value):
501511
raise serializers.ValidationError(PROCTYPE_MISMATCH_MSG)
502512

503513
return value
504514

505-
def validate_path_pattern(self, value):
515+
@staticmethod
516+
def validate_path_pattern(value):
506517
for pattern in str(value).split(","):
507518
if not pattern.strip():
508519
raise serializers.ValidationError(
@@ -561,7 +572,8 @@ class Meta:
561572
model = models.AppSettings
562573
fields = '__all__'
563574

564-
def validate_whitelist(self, data):
575+
@staticmethod
576+
def validate_whitelist(data):
565577
for address in data:
566578
try:
567579
ipaddress.ip_address(address)
@@ -577,7 +589,8 @@ def validate_whitelist(self, data):
577589

578590
return data
579591

580-
def validate_autoscale(self, data):
592+
@staticmethod
593+
def validate_autoscale(data):
581594
schema = {
582595
"$schema": "http://json-schema.org/schema#",
583596
"type": "object",
@@ -632,14 +645,16 @@ class Meta:
632645
model = models.Volume
633646
fields = '__all__'
634647

635-
def validate_size(self, data):
648+
@staticmethod
649+
def validate_size(data):
636650
if not re.match(VOLUME_SIZE_MATCH, str(data)):
637651
raise serializers.ValidationError(
638652
"Volume size limit format: <number><unit> or <number><unit>/<number><unit>, "
639653
"where unit = B, K, M or G")
640654
return data
641655

642-
def validate_path(self, data):
656+
@staticmethod
657+
def validate_path(data):
643658
for key, value in data.items():
644659
if value is None: # use NoneType to unset an item
645660
continue

rootfs/api/settings/production.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -335,23 +335,25 @@
335335
# How long k8s waits for a pod to finish work after a SIGTERM before sending SIGKILL
336336
KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS = int(os.environ.get('KUBERNETES_POD_TERMINATION_GRACE_PERIOD_SECONDS', 30)) # noqa
337337

338-
# Default pod spec for application
338+
KUBERNETES_CPU_ALLOCATION_RATIO = int(os.environ.get('KUBERNETES_CPU_ALLOCATION_RATIO', '10'))
339+
KUBERNETES_RAM_ALLOCATION_RATIO = int(os.environ.get('KUBERNETES_RAM_ALLOCATION_RATIO', '2'))
340+
# Default pod spec for application.
341+
# Please do not set requests.cpu and requests.memory.
342+
# If set, they will not be dynamically computed when the first resource is allocated;
343+
# unless in the future through `drycc limits:set` manual setting
339344
KUBERNETES_POD_DEFAULT_RESOURCES = os.environ.get(
340345
'KUBERNETES_POD_DEFAULT_RESOURCES',
341346
json.dumps({
342347
"requests": {
343-
"cpu": "200m",
344-
"memory": "256Mi",
345-
"ephemeral-storage": "1Gi"
348+
"ephemeral-storage": "256Mi"
346349
},
347350
"limits": {
348351
"cpu": "500m",
349352
"memory": "512Mi",
350-
"ephemeral-storage": "2Gi"
351-
},
353+
"ephemeral-storage": "1Gi"
354+
}
352355
})
353356
)
354-
355357
# Default quota spec for application namespace
356358
KUBERNETES_NAMESPACE_DEFAULT_QUOTA_SPEC = os.environ.get(
357359
'KUBERNETES_NAMESPACE_DEFAULT_QUOTA_SPEC',

rootfs/api/tests/test_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ def test_response_data_types_converted(self, mock_requests):
204204
response = self.client.post(url, body)
205205
self.assertEqual(response.status_code, 400, response.data)
206206
self.assertIn(
207-
'CPU limit format: <value> or <value>/<value>, where value must be a numeric',
207+
'CPU limit format: <value>, where value must be a numeric',
208208
response.data['cpu'])
209209

210210
def test_config_set_same_key(self, mock_requests):

0 commit comments

Comments
 (0)