Skip to content

Commit 2549b05

Browse files
author
Matthew Fisher
committed
Merge pull request #1513 from deis/resource-limits
Memory/CPU limits for application containers
2 parents fa68095 + ae6f5ad commit 2549b05

9 files changed

Lines changed: 551 additions & 16 deletions

File tree

api/models.py

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -301,10 +301,15 @@ def _command_announceable(self):
301301
@transition(field=state, source=INITIALIZED, target=CREATED)
302302
def create(self):
303303
image = self.release.image
304+
kwargs = {}
305+
if self.release.config.limit is not None:
306+
kwargs = {'memory': self.release.config.limit.memory,
307+
'cpu': self.release.config.limit.cpu}
304308
self._scheduler.create(name=self._job_id,
305309
image=image,
306310
command=self._command,
307-
use_announcer=self._command_announceable())
311+
use_announcer=self._command_announceable(),
312+
**kwargs)
308313

309314
@close_db_connections
310315
@transition(field=state,
@@ -327,10 +332,15 @@ def deploy(self, release):
327332
new_job_id = self._job_id
328333
image = self.release.image
329334
c_type = self.type
335+
kwargs = {}
336+
if self.release.config.limit is not None:
337+
kwargs = {'memory': self.release.config.limit.memory,
338+
'cpu': self.release.config.limit.cpu}
330339
self._scheduler.create(name=new_job_id,
331340
image=image,
332341
command=self._command.format(**locals()),
333-
use_announcer=self._command_announceable())
342+
use_announcer=self._command_announceable(),
343+
**kwargs)
334344
self._scheduler.start(new_job_id, self._command_announceable())
335345
# destroy old container
336346
self._scheduler.destroy(old_job_id, self._command_announceable())
@@ -416,6 +426,28 @@ class Config(UuidAuditedModel):
416426
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
417427
app = models.ForeignKey('App')
418428
values = JSONField(default='{}', blank=True)
429+
limit = models.ForeignKey('Limit', null=True)
430+
431+
class Meta:
432+
get_latest_by = 'created'
433+
ordering = ['-created']
434+
unique_together = (('app', 'uuid'),)
435+
436+
def __str__(self):
437+
return "{}-{}".format(self.app.id, self.uuid[:7])
438+
439+
440+
@python_2_unicode_compatible
441+
class Limit(UuidAuditedModel):
442+
"""
443+
Set of resource limits applied by the scheduler
444+
during runtime execution of the Application.
445+
"""
446+
447+
owner = models.ForeignKey(settings.AUTH_USER_MODEL)
448+
app = models.ForeignKey('App')
449+
memory = JSONField(default='{}', blank=True)
450+
cpu = JSONField(default='{}', blank=True)
419451

420452
class Meta:
421453
get_latest_by = 'created'
@@ -507,12 +539,14 @@ def previous(self):
507539
prev_release = None
508540
return prev_release
509541

510-
def save(self, *args, **kwargs):
542+
def save(self, *args, **kwargs): # noqa
511543
if not self.summary:
512544
self.summary = ''
513545
prev_release = self.previous()
514546
# compare this build to the previous build
515547
old_build = prev_release.build if prev_release else None
548+
old_config = prev_release.config if prev_release else None
549+
old_limit = prev_release.config.limit if prev_release else None
516550
# if the build changed, log it and who pushed it
517551
if self.version == 1:
518552
self.summary += "{} created initial release".format(self.app.owner)
@@ -521,10 +555,8 @@ def save(self, *args, **kwargs):
521555
self.summary += "{} deployed {}".format(self.build.owner, self.build.sha[:7])
522556
else:
523557
self.summary += "{} deployed {}".format(self.build.owner, self.build.image)
524-
# compare this config to the previous config
525-
old_config = prev_release.config if prev_release else None
526558
# if the config data changed, log the dict diff
527-
if self.config != old_config:
559+
elif self.config != old_config:
528560
dict1 = self.config.values
529561
dict2 = old_config.values if old_config else {}
530562
diff = dict_diff(dict1, dict2)
@@ -540,11 +572,25 @@ def save(self, *args, **kwargs):
540572
if self.summary:
541573
self.summary += ' and '
542574
self.summary += "{} {}".format(self.config.owner, changes)
543-
if not self.summary:
544-
if self.version == 1:
545-
self.summary = "{} created the initial release".format(self.owner)
546-
else:
547-
self.summary = "{} changed nothing".format(self.owner)
575+
# if the limit changes, log the dict diff
576+
elif self.config.limit != old_limit:
577+
changes = []
578+
old_mem = old_limit.memory if old_limit else {}
579+
diff = dict_diff(self.limit.memory, old_mem)
580+
if diff.get('added') or diff.get('changed') or diff.get('deleted'):
581+
changes.append('memory')
582+
old_cpu = old_limit.cpu if old_limit else {}
583+
diff = dict_diff(self.limit.cpu, old_cpu)
584+
if diff.get('added') or diff.get('changed') or diff.get('deleted'):
585+
changes.append('cpu')
586+
if changes:
587+
changes = 'changed limits for '+', '.join(changes)
588+
self.summary += "{} {}".format(self.config.owner, changes)
589+
if not self.summary:
590+
if self.version == 1:
591+
self.summary = "{} created the initial release".format(self.owner)
592+
else:
593+
self.summary = "{} changed nothing".format(self.owner)
548594
super(Release, self).save(*args, **kwargs)
549595

550596

api/serializers.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@
1414
from api import utils
1515

1616

17+
PROCTYPE_MATCH = re.compile(r'^(?P<type>[a-z]+)')
18+
MEMLIMIT_MATCH = re.compile(r'^(?P<mem>[0-9]+[BbKkMmGg])$')
19+
CPUSHARE_MATCH = re.compile(r'^(?P<cpu>[0-9]+)$')
20+
21+
1722
class OwnerSlugRelatedField(serializers.SlugRelatedField):
1823
"""Filter queries by owner as well as slug_field."""
1924

@@ -102,6 +107,51 @@ class Meta:
102107
read_only_fields = ('uuid', 'created', 'updated')
103108

104109

110+
class LimitSerializer(serializers.ModelSerializer):
111+
"""Serialize a :class:`~api.models.Limit` model."""
112+
113+
owner = serializers.Field(source='owner.username')
114+
app = serializers.SlugRelatedField(slug_field='id')
115+
memory = serializers.ModelField(
116+
model_field=models.Limit()._meta.get_field('memory'), required=False)
117+
cpu = serializers.ModelField(
118+
model_field=models.Limit()._meta.get_field('cpu'), required=False)
119+
120+
class Meta:
121+
"""Metadata options for a :class:`LimitSerializer`."""
122+
model = models.Limit
123+
read_only_fields = ('uuid', 'created', 'updated')
124+
125+
def validate_memory(self, attrs, source):
126+
for k, v in attrs.get(source, {}).items():
127+
if v is None: # use NoneType to unset a value
128+
continue
129+
if not re.match(PROCTYPE_MATCH, k):
130+
raise serializers.ValidationError("Process types can only contain [a-z]")
131+
if not re.match(MEMLIMIT_MATCH, str(v)):
132+
raise serializers.ValidationError(
133+
"Limit format: <number><unit>, where unit = B, K, M or G")
134+
return attrs
135+
136+
def validate_cpu(self, attrs, source):
137+
for k, v in attrs.get(source, {}).items():
138+
if v is None: # use NoneType to unset a value
139+
continue
140+
if not re.match(PROCTYPE_MATCH, k):
141+
raise serializers.ValidationError("Process types can only contain [a-z]")
142+
shares = re.match(CPUSHARE_MATCH, str(v))
143+
if not shares:
144+
raise serializers.ValidationError("CPU shares must be an integer")
145+
for v in shares.groupdict().values():
146+
try:
147+
i = int(v)
148+
except ValueError:
149+
raise serializers.ValidationError("CPU shares must be an integer")
150+
if i > 1024 or i < 0:
151+
raise serializers.ValidationError("CPU shares must be between 0 and 1024")
152+
return attrs
153+
154+
105155
class ReleaseSerializer(serializers.ModelSerializer):
106156
"""Serialize a :class:`~api.models.Release` model."""
107157

0 commit comments

Comments
 (0)