1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Daphnie\Collector;
16: use Daphnie\Contracts\MetricProvider;
17: use Daphnie\Metric;
18: use Opcenter\Provisioning\Cgroup as CgroupProvisioning;
19: use Opcenter\SiteConfiguration;
20: use Opcenter\System\Cgroup;
21:
22: /**
23: * Control group interfacing
24: *
25: * @package core
26: */
27: class Cgroup_Module extends Module_Skeleton implements \Module\Skeleton\Contracts\Hookable
28: {
29: const CGROUP_LOCATION = Cgroup::CGROUP_HOME;
30: const DEPENDENCY_MAP = [
31: 'siteinfo'
32: ];
33: const DEFAULT_MEMORY = 512;
34: const DEFAULT_CPU = 10240;
35: /** in MB */
36: const MAX_PROCS = 25;
37:
38: const METRIC_ATTR_CPU_USAGE = [
39: 'c-cpuacct-usage',
40: 'c-cpuacct-system',
41: 'c-cpuacct-user'
42: ];
43:
44: protected $exportedFunctions = [
45: '*' => PRIVILEGE_SITE | PRIVILEGE_USER | PRIVILEGE_ADMIN,
46: 'reset_peak_memory' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
47: 'frozen' => PRIVILEGE_ADMIN,
48: 'thaw' => PRIVILEGE_ADMIN,
49: 'freeze' => PRIVILEGE_ADMIN
50: ];
51:
52: /**
53: * Get controller usage
54: *
55: * @param string $controller
56: * @return array|false usage or controller exposes no data
57: */
58: public function get_usage(string $controller)
59: {
60: if (!IS_CLI && posix_getuid()) {
61: return $this->query('cgroup_get_usage', $controller);
62: }
63: if (!in_array($controller, $this->get_controllers(), true)) {
64: return error("unknown controller `%s'", $controller);
65: }
66:
67: $method = '_get_' . $controller . '_usage';
68: if (!method_exists($this, $method)) {
69: // don't know how to handle cgroup collection
70: return [];
71: }
72:
73: return $this->$method();
74: }
75:
76: /**
77: * Get available system cgroup controllers
78: *
79: * @return string[]
80: */
81: public function get_controllers(): array
82: {
83: return CGROUP_CONTROLLERS;
84: }
85:
86: /**
87: * Get cgroup name
88: *
89: * @return string|null
90: */
91: public function get_cgroup(): ?string
92: {
93: if ($this->permission_level & (PRIVILEGE_SITE | PRIVILEGE_USER)) {
94: return (string)(new Cgroup\Group($this->site));
95: }
96:
97: return null;
98: }
99:
100: /**
101: * Get configured limits
102: *
103: * @return array
104: */
105: public function get_limits(): array
106: {
107: $limits = $this->getServiceValue('cgroup');
108: if (!$limits['enabled']) {
109: return [];
110: }
111:
112: return array_except($limits, ['version', 'enabled']);
113: }
114:
115: /**
116: * Reset max memory usage
117: */
118: public function reset_peak_memory(): void
119: {
120: if (!IS_CLI) {
121: $this->query('cgroup_reset_peak_memory');
122: return;
123: }
124:
125: $group = new \Opcenter\System\Cgroup\Group(
126: $this->site,
127: );
128:
129: $controller = Cgroup\Controllers\Memory::make($group, null);
130: $controller->reset();
131: }
132:
133: /**
134: * cgroups enabled for site
135: *
136: * @return bool
137: */
138: public function enabled(): bool
139: {
140: return (bool)$this->getServiceValue('cgroup', 'enabled');
141: }
142:
143: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
144: {
145: return true;
146: }
147:
148: public function _create()
149: { }
150:
151: public function _delete()
152: { }
153:
154: public function _edit()
155: { }
156:
157: public function _create_user(string $user)
158: {
159: return true;
160: }
161:
162: public function _delete_user(string $user)
163: {
164: return true;
165: }
166:
167: public function _edit_user(string $userold, string $usernew, array $oldpwd)
168: {
169: return true;
170: }
171:
172: public function _housekeeping()
173: {
174: if (!($test = $this->get_controllers()[0] ?? null)) {
175: return;
176: }
177: if (!Cgroup::mounted($test)) {
178: return error("%s not mounted", FILESYSTEM_SHARED . "/cgroup");
179: }
180: $webuser = $this->web_get_sys_user();
181: foreach (\Opcenter\Account\Enumerate::sites() as $site) {
182: if (!Auth::get_admin_from_site_id((int)substr($site, 4))) {
183: continue;
184: }
185: $group = new \Opcenter\System\Cgroup\Group(
186: $site,
187: [
188: 'task' => [
189: 'uid' => 'root',
190: 'gid' => \Auth::get_group_from_site($site),
191: 'fperm' => 0660
192: ]
193: ]
194: );
195: $ctx = null;
196: foreach (Cgroup::getControllers() as $c) {
197: $controller = \Opcenter\System\Cgroup\Controller::make($group, $c, []);
198: if (Cgroup::exists($controller)) {
199: $controller->reset();
200: continue;
201: }
202:
203: if (null === $ctx && null === ($ctx = \Auth::nullableContext(null, $site))) {
204: continue 2;
205: }
206:
207: $controller->import($ctx);
208: if (!$controller->getAttributes()) {
209: continue;
210: }
211: $controller->create();
212: $group->add($controller);
213: }
214:
215: if ($group->getControllers()) {
216: report("Missed controller %(controller)s on %(group)s",
217: ['controller' => implode(', ', array_map(static fn($c) => $c->getName(), $group->getControllers())), 'group' => $group]);
218: // missed a controller, do a full import
219: CgroupProvisioning::createControllerConfiguration(SiteConfiguration::shallow($ctx));
220: }
221: }
222:
223: return true;
224: }
225:
226: /**
227: * Get controller memory usage
228: *
229: * @return array
230: */
231: private function _get_memory_usage(): array
232: {
233: $stats['limit'] = self::DEFAULT_MEMORY;
234: $stats = Cgroup::memory_usage($this->get_cgroup());
235: $sysMemory = \Opcenter\System\Memory::stats();
236: $maxMemory = $sysMemory['memtotal'] * 1024;
237: if ($this->permission_level & PRIVILEGE_ADMIN || $stats['limit'] === null) {
238: $stats['limit'] = $maxMemory;
239: $stats['free'] = $sysMemory['memavailable']*1024;
240: } else {
241: $stats['limit'] = min($stats['limit'], $maxMemory);
242: $stats['free'] = $stats['limit'] - $stats['used'];
243: }
244:
245: return $stats;
246: }
247:
248: /**
249: * Populate cgroup defaults on controller error
250: *
251: * @param array $usage
252: * @param array $defaults
253: * @return array
254: */
255: private function _fillUsage(array $usage, array $defaults): array
256: {
257: foreach ($defaults as $k => $v) {
258: if (!isset($usage[$k])) {
259: $usage[$k] = $v;
260: }
261: }
262:
263: return $usage;
264: }
265:
266: private function _get_cpuacct_usage(): array
267: {
268: return [];
269: }
270:
271: private function _get_pids_usage(): array
272: {
273: // @todo replace CPU maxproc with pids subsystem
274: $maxprocs = self::MAX_PROCS;
275: if ($this->permission_level & PRIVILEGE_ADMIN) {
276: $maxprocs = 999;
277: }
278:
279: return $this->_fillUsage(
280: Cgroup::pid_usage($this->get_cgroup()),
281: [
282: 'max' => $this->getServiceValue('cgroup', 'proclimit', $maxprocs)
283: ]
284: );
285: }
286:
287: private function _get_cpu_usage(): array
288: {
289: $maxcpu = self::DEFAULT_CPU;
290: $maxprocs = self::MAX_PROCS;
291: if ($this->permission_level & PRIVILEGE_ADMIN) {
292: $maxcpu = NPROC * 86400;
293: $maxprocs = 999;
294: }
295:
296: $usage = Cgroup::cpu_usage($this->get_cgroup());
297: if (($this->permission_level & PRIVILEGE_SITE) && TELEMETRY_ENABLED) {
298: $sum = $this->telemetry_range(self::METRIC_ATTR_CPU_USAGE, time()-86400, null, $this->site_id, true);
299: /**
300: * > .usage is measuring the wall clock nanoseconds whereas .stat is measuring the cpu cycles consumed.
301: * http://mail-archives.apache.org/mod_mbox/mesos-dev/201302.mbox/%3C20130214015558.21380.50889@reviews.apache.org%3E
302: */
303: // convert centiseconds to seconds
304: $cumusage = ($sum['c-cpuacct-usage'] ?? 0)/100;
305: $usage['cumusage'] = $cumusage ?: $usage['used'];
306: $usage['used'] = $cumusage;
307: $usage['cumuser'] = $usage['user'];
308: $usage['cumsystem'] = $usage['system'];
309: $usage['system'] = ($sum['c-cpuacct-system'] ?? 0)/100 ;
310: $usage['user'] = ($sum['c-cpuacct-user'] ?? 0)/ 100;
311: } else {
312: // note: poor approximation for uniform usage
313: debug("Telemetry disabled. Approximating CPU usage for %s", $this->site);
314: $ctime = filectime(Cgroup\Controllers\Cpuacct::make(
315: new Cgroup\Group($this->site), null
316: )->getPath());
317: $usage['used'] = $usage['used']/(microtime(true) - $ctime) * 86400;
318: }
319: $cpuLimit = $this->getServiceValue('cgroup', 'cpu', $maxcpu);
320: return $this->_fillUsage(
321: $usage,
322: [
323: 'limit' => $cpuLimit,
324: 'maxprocs' => $this->getServiceValue('cgroup', 'proclimit', $maxprocs),
325: 'cumusage' => $usage['used'],
326: 'free' => $cpuLimit - $usage['used']
327: ]
328: );
329: }
330:
331: private function _get_io_usage(): array
332: {
333: return $this->_get_blkio_usage();
334: }
335:
336: private function _get_blkio_usage(): array
337: {
338: return $this->_fillUsage(
339: Cgroup::io_usage($this->get_cgroup()),
340: [
341: 'iops-read' => $this->getServiceValue('cgroup', 'readiops', 100),
342: 'iops-write' => $this->getServiceValue('cgroup', 'writeiops', 100),
343: 'bw-read' => $this->getServiceValue('cgroup', 'readbw', 100),
344: 'bw-write' => $this->getServiceValue('cgroup', 'writebw', 100)
345: ]
346: );
347: }
348:
349: /**
350: * Convert site from thawed to frozen state
351: *
352: * @param string $spec site, site id, domain, invoice or any matchable identifier
353: * @return bool
354: */
355: public function freeze(string $spec): bool
356: {
357: if (!IS_CLI) {
358: return $this->query('cgroup_freeze', $spec);
359: }
360:
361: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
362: return error("%s cgroup must be enabled", 'freezer');
363: }
364:
365: $sites = (array)Auth::get_site_id_from_anything($spec);
366: if (!$sites) {
367: return error("Unknown site spec `%s'", $spec);
368: }
369:
370: $frozen = true;
371: foreach ($sites as $siteid) {
372: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
373: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
374: null
375: );
376:
377: if (!$controller->exists()) {
378: warn("Controller `%(controller)s' missing for `%(site)s'", ['controller' => 'freezer', 'site' => "site{$siteid}"]);
379: $frozen &= 0;
380: }
381:
382: $frozen &= $controller->createAttribute('state', Cgroup\Attributes\Freezer\State::STATE_FROZEN)->activate();
383: }
384:
385: return (bool)$frozen;
386: }
387:
388: /**
389: * Convert site from frozen to thawed state
390: *
391: * @param string $spec site, site id, domain, invoice or any matchable identifier
392: * @return bool
393: */
394: public function thaw(string $spec): bool
395: {
396: if (!IS_CLI) {
397: return $this->query('cgroup_thaw', $spec);
398: }
399:
400: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
401: return error("%s cgroup must be enabled", 'freezer');
402: }
403:
404: $sites = (array)Auth::get_site_id_from_anything($spec);
405: if (!$sites) {
406: return error("Unknown site spec `%s'", $spec);
407: }
408:
409: $thawed = true;
410: foreach ($sites as $siteid) {
411: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
412: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
413: null
414: );
415:
416: if (!$controller->exists()) {
417: continue;
418: }
419:
420: $thawed &= $controller->createAttribute('state',
421: Cgroup\Attributes\Freezer\State::STATE_THAWED)->activate();
422: }
423:
424: return (bool)$thawed;
425: }
426:
427: /**
428: * Site is in cgroup frozen state
429: *
430: * @param string $spec site, site id, domain, invoice or any matchable identifier
431: * @return bool
432: */
433: public function frozen(string $spec): bool
434: {
435: if (!IS_CLI) {
436: return $this->query('cgroup_frozen', $spec);
437: }
438:
439: if (Cgroup::version() === 1 && !in_array('freezer', Cgroup::getControllers(), true)) {
440: return error("%s cgroup must be enabled", 'freezer');
441: }
442:
443: $sites = (array)Auth::get_site_id_from_anything($spec);
444: if (!$sites) {
445: return error("Unknown site spec `%s'", $spec);
446: }
447: if (count($sites) > 1) {
448: warn("Multiple sites queried with site spec `%s'", $spec);
449: }
450:
451:
452: $frozen = true;
453: foreach ($sites as $siteid) {
454: $controller = Opcenter\System\Cgroup\Controllers\Freezer::make(
455: new \Opcenter\System\Cgroup\Group("site{$siteid}"),
456: null
457: );
458:
459: if (!$controller->exists()) {
460: $frozen = false;
461: continue;
462: }
463:
464: $frozen &= $controller->createAttribute('state', null)->frozen();
465: }
466:
467: return (bool)$frozen;
468: }
469:
470: public function version(): int
471: {
472: return (int)Cgroup::version();
473: }
474:
475: public function _cron(Cronus $cron) {
476:
477: if (CGROUP_RESET_PEAK > 0) {
478: $cron->schedule(CGROUP_RESET_PEAK, 'reset.max-memory', static function () {
479: $db = PostgreSQL::pdo();
480: $sites = (new \Opcenter\Database\PostgreSQL\Opcenter($db))->readSitesFromSiteinfo();
481: foreach (array_keys($sites) as $s) {
482: $s = "site{$s}";
483: $group = new Cgroup\Group($s);
484: $controller = Cgroup\Controller::make($group, 'memory');
485: if ($controller->exists()) {
486: $controller->reset();
487: }
488: }
489: });
490: }
491:
492: if (!TELEMETRY_ENABLED) {
493: return;
494: }
495:
496: $db = PostgreSQL::pdo();
497: $collector = new Collector($db);
498: // read from siteinfo table to guard protect against failed foreign key checks
499: $sites = (new \Opcenter\Database\PostgreSQL\Opcenter($db))->readSitesFromSiteinfo();
500: $sites[] = null; // system controller
501: $controllers = $this->get_controllers();
502: foreach (array_keys($sites) as $s) {
503: $s = "site{$s}";
504: $siteId = (int)substr($s, 4) ?: null;
505: $ts = time();
506: /**
507: * Approx 32k controllers/sec on testing VM (~5500 backend req/sec)
508: * This method should be fine with minimal performance degradation,
509: * may wish to switch to less OO approach in the future if bottlenecks appear
510: *
511: * Takes ~5ms to log all metrics for a site
512: */
513: $group = new Cgroup\Group($s);
514: $counters = [];
515: foreach ($controllers as $c) {
516: $controller = Cgroup\Controller::make($group, $c);
517: $attrs = (new Cgroup\MetricsLogging($controller))->getLoggableAttributes();
518: $counters[$c] = $controller->readMetrics(array_keys($attrs));
519: $reset = false;
520: foreach ($counters[$c] as $k => $v) {
521: if (!$reset && ((int)$v > Metric::FIELD_MAX_VALUE &&
522: $controller->getMetricDataType($k) === MetricProvider::TYPE_MONOTONIC)) {
523: // reconsitute metric type from "anonymous" metric via db. Registration method
524: // would hold onto a controller instance assigned to a specific site indefinitely
525: $reset = true;
526: }
527: $collector->add($attrs[$k], $siteId, (int)$v, $ts);
528: }
529:
530: if ($reset) {
531: $controller->reset();
532: }
533: }
534: }
535: $collector = null;
536:
537: return true;
538: }
539: }