Skip to content

Commit 9103f57

Browse files
author
Matthew Fisher
committed
Merge pull request #1513 from deis/resource-limits
Memory/CPU limits for application containers
2 parents d9dd6d7 + 6edf1f9 commit 9103f57

11 files changed

Lines changed: 717 additions & 16 deletions

File tree

client/deis.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
config manage environment variables that define app config
1919
domains manage and assign domain names to your applications
2020
builds manage builds created using `git push`
21+
limits manage resource limits for your application
2122
releases manage releases of an application
2223
2324
keys manage ssh keys used for `git push` deployments
@@ -1345,6 +1346,162 @@ def domains_list(self, args):
13451346
else:
13461347
raise ResponseError(response)
13471348

1349+
def limits(self, args):
1350+
"""
1351+
Valid commands for limits:
1352+
1353+
limits:list list resource limits for an app
1354+
limits:set set resource limits for an app
1355+
limits:unset unset resource limits for an app
1356+
1357+
Use `deis help [command]` to learn more.
1358+
"""
1359+
sys.argv[1] = 'limits:list'
1360+
args = docopt(self.limits_list.__doc__)
1361+
return self.limits_list(args)
1362+
1363+
def limits_list(self, args):
1364+
"""
1365+
Lists resource limits for an application.
1366+
1367+
Usage: deis limits:list [options]
1368+
1369+
Options:
1370+
-a --app=<app>
1371+
the uniquely identifiable name of the application.
1372+
"""
1373+
app = args.get('--app')
1374+
if not app:
1375+
app = self._session.app
1376+
response = self._dispatch('get', "/api/apps/{}/limits".format(app))
1377+
if response.status_code == requests.codes.ok: # @UndefinedVariable
1378+
self._print_limits(app, response.json())
1379+
else:
1380+
raise ResponseError(response)
1381+
1382+
def limits_set(self, args):
1383+
"""
1384+
Sets resource limits for an application.
1385+
1386+
A resource limit is a finite resource within a container which we can apply
1387+
restrictions to either through the scheduler or through the Docker API. This limit
1388+
is applied to each individual container, so setting a memory limit of 1G for an
1389+
application means that each container gets 1G of memory.
1390+
1391+
Usage: deis limits:set [options] <type>=<limit>...
1392+
1393+
Arguments:
1394+
<type>
1395+
the process type as defined in your Procfile, such as 'web' or 'worker'.
1396+
Note that Dockerfile apps have a default 'cmd' process type.
1397+
<limit>
1398+
The limit to apply to the process type. By default, this is set to --memory.
1399+
You can only set one type of limit per call.
1400+
1401+
With --memory, units are represented in Bytes (B), Kilobytes (K), Megabytes
1402+
(M), or Gigabytes (G). For example, `deis limit:set cmd=1G` will restrict all
1403+
"cmd" processes to a maximum of 1 Gigabyte of memory each.
1404+
1405+
With --cpu, units are represented in the number of cpu shares. For example,
1406+
`deis limit:set --cpu cmd=1024` will restrict all "cmd" processes to a
1407+
maximum of 1024 cpu shares.
1408+
1409+
Options:
1410+
-a --app=<app>
1411+
the uniquely identifiable name for the application.
1412+
-c --cpu
1413+
limits cpu shares.
1414+
-m --memory
1415+
limits memory. [default: true]
1416+
"""
1417+
app = args.get('--app')
1418+
if not app:
1419+
app = self._session.app
1420+
body = {}
1421+
# see if cpu shares are being specified, otherwise default to memory
1422+
target = 'cpu' if args.get('--cpu') else 'memory'
1423+
body[target] = json.dumps(dictify(args['<type>=<limit>']))
1424+
sys.stdout.write('Applying limits... ')
1425+
sys.stdout.flush()
1426+
try:
1427+
progress = TextProgress()
1428+
progress.start()
1429+
response = self._dispatch('post', "/api/apps/{}/limits".format(app), json.dumps(body))
1430+
finally:
1431+
progress.cancel()
1432+
progress.join()
1433+
if response.status_code == requests.codes.created: # @UndefinedVariable
1434+
version = response.headers['x-deis-release']
1435+
print("done, v{}\n".format(version))
1436+
1437+
self._print_limits(app, response.json())
1438+
else:
1439+
raise ResponseError(response)
1440+
1441+
def limits_unset(self, args):
1442+
"""
1443+
Unsets resource limits for an application.
1444+
1445+
Usage: deis limits:unset [options] [--memory | --cpu] <type>...
1446+
1447+
Arguments:
1448+
<type>
1449+
the process type as defined in your Procfile, such as 'web' or 'worker'.
1450+
Note that Dockerfile apps have a default 'cmd' process type.
1451+
1452+
Options:
1453+
-a --app=<app>
1454+
the uniquely identifiable name for the application.
1455+
-c --cpu
1456+
limits cpu shares.
1457+
-m --memory
1458+
limits memory. [default: true]
1459+
"""
1460+
app = args.get('--app')
1461+
if not app:
1462+
app = self._session.app
1463+
values = {}
1464+
for k in args.get('<type>'):
1465+
values[k] = None
1466+
body = {}
1467+
# see if cpu shares are being specified, otherwise default to memory
1468+
target = 'cpu' if args.get('--cpu') else 'memory'
1469+
body[target] = json.dumps(values)
1470+
sys.stdout.write('Applying limits... ')
1471+
sys.stdout.flush()
1472+
try:
1473+
progress = TextProgress()
1474+
progress.start()
1475+
response = self._dispatch('post', "/api/apps/{}/limits".format(app), json.dumps(body))
1476+
finally:
1477+
progress.cancel()
1478+
progress.join()
1479+
if response.status_code == requests.codes.created: # @UndefinedVariable
1480+
version = response.headers['x-deis-release']
1481+
print("done, v{}\n".format(version))
1482+
self._print_limits(app, response.json())
1483+
else:
1484+
raise ResponseError(response)
1485+
1486+
def _print_limits(self, app, limit):
1487+
print("=== {} Limits".format(app))
1488+
1489+
def write(d):
1490+
items = d.items()
1491+
if len(items) == 0:
1492+
print('Unlimited')
1493+
return
1494+
keys = sorted(d)
1495+
width = max(map(len, keys)) + 5
1496+
for k in keys:
1497+
v = d[k]
1498+
print(("{k:<" + str(width) + "} {v}").format(**locals()))
1499+
1500+
print("\n--- Memory")
1501+
write(json.loads(limit.get('memory', '{}')))
1502+
print("\n--- CPU")
1503+
write(json.loads(limit.get('cpu', '{}')))
1504+
13481505
def ps(self, args):
13491506
"""
13501507
Valid commands for processes:

controller/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

controller/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)