185 lines
6.1 KiB
PHP
185 lines
6.1 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Redis;
|
|
|
|
/**
|
|
* Health check every few minutes, reported to healthchecks.io.
|
|
*
|
|
* On 2026-10-07 MariaDB was killed by the kernel's OOM killer at 02:09 and the
|
|
* site served errors for 14 hours before anyone noticed: nothing watched
|
|
* whether it was up. This checks the services the site needs and reports the
|
|
* outcome to a healthchecks.io check:
|
|
*
|
|
* - all fine: a plain ping;
|
|
* - something failed: a ping to /fail with what failed, which alerts at once;
|
|
* - no ping at all (server down, scheduler broken): healthchecks.io alerts
|
|
* once the check's grace period has passed.
|
|
*
|
|
* Every check catches its own errors and has a short timeout, so one dead
|
|
* service is reported rather than stopping the run. Failures also go to the
|
|
* Laravel log. Scheduled in app/Console/Kernel.php; config in
|
|
* `health_monitor.*` (config/timebank_cc.php).
|
|
*/
|
|
class HealthCheck extends Command
|
|
{
|
|
protected $signature = 'health:check
|
|
{--dry-run : Print the results without pinging healthchecks.io}';
|
|
|
|
protected $description = 'Check database, Redis, Elasticsearch, the public site and disk space, and report to healthchecks.io.';
|
|
|
|
public function handle(): int
|
|
{
|
|
if (! timebank_config('health_monitor.enabled', true)) {
|
|
$this->info('Health monitor is disabled (health_monitor.enabled=false).');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$checks = [
|
|
'database' => fn () => $this->checkDatabase(),
|
|
'redis' => fn () => $this->checkRedis(),
|
|
'elasticsearch' => fn () => $this->checkElasticsearch(),
|
|
'website' => fn () => $this->checkWebsite(),
|
|
'disk' => fn () => $this->checkDisk(),
|
|
];
|
|
|
|
$failures = [];
|
|
|
|
foreach ($checks as $name => $check) {
|
|
try {
|
|
$problem = $check();
|
|
} catch (\Throwable $e) {
|
|
$problem = $e->getMessage();
|
|
}
|
|
|
|
if ($problem === null) {
|
|
$this->line("OK: {$name}");
|
|
} else {
|
|
$failures[] = "{$name}: {$problem}";
|
|
$this->error("FAIL: {$name}: {$problem}");
|
|
}
|
|
}
|
|
|
|
foreach ($failures as $line) {
|
|
Log::error('[health-monitor] ' . $line);
|
|
}
|
|
|
|
$this->report($failures);
|
|
|
|
return empty($failures) ? self::SUCCESS : self::FAILURE;
|
|
}
|
|
|
|
/** null when healthy, otherwise what is wrong. */
|
|
private function checkDatabase(): ?string
|
|
{
|
|
DB::select('SELECT 1');
|
|
|
|
return null;
|
|
}
|
|
|
|
private function checkRedis(): ?string
|
|
{
|
|
$reply = Redis::connection()->ping();
|
|
|
|
// phpredis answers true (or '+PONG'), predis a Status object.
|
|
return ($reply === true || stripos((string) $reply, 'PONG') !== false)
|
|
? null
|
|
: 'unexpected reply to PING: ' . var_export((string) $reply, true);
|
|
}
|
|
|
|
private function checkElasticsearch(): ?string
|
|
{
|
|
$host = config('elasticsearch.host');
|
|
|
|
if (! $host) {
|
|
// Not every installation runs search.
|
|
return null;
|
|
}
|
|
|
|
$url = (str_contains($host, '://') ? $host : 'http://' . $host) . '/_cluster/health';
|
|
$request = Http::timeout(10);
|
|
|
|
if (config('elasticsearch.user')) {
|
|
$request = $request->withBasicAuth(config('elasticsearch.user'), (string) config('elasticsearch.password'));
|
|
}
|
|
|
|
$response = $request->get($url);
|
|
|
|
if (! $response->successful()) {
|
|
return 'HTTP ' . $response->status();
|
|
}
|
|
|
|
// Yellow is normal on a single node (replicas cannot be placed); red
|
|
// means data is unavailable.
|
|
$status = $response->json('status');
|
|
|
|
return $status === 'red' ? 'cluster status red' : null;
|
|
}
|
|
|
|
private function checkWebsite(): ?string
|
|
{
|
|
$url = timebank_config('health_monitor.website_url') ?: config('app.url');
|
|
|
|
// Over the public URL on purpose: this goes through Apache, PHP, the
|
|
// database and the TLS certificate, as a visitor's request does.
|
|
$response = Http::timeout(20)->get($url);
|
|
|
|
return $response->status() === 200 ? null : "{$url} answered HTTP {$response->status()}";
|
|
}
|
|
|
|
private function checkDisk(): ?string
|
|
{
|
|
$path = base_path();
|
|
$total = @disk_total_space($path);
|
|
$free = @disk_free_space($path);
|
|
|
|
if (! $total || $free === false) {
|
|
return "cannot read disk space for {$path}";
|
|
}
|
|
|
|
$used = (int) round(100 * (1 - $free / $total));
|
|
$limit = (int) timebank_config('health_monitor.disk_max_percent', 90);
|
|
|
|
// Above 90% Elasticsearch also stops allocating shards.
|
|
return $used >= $limit ? "{$used}% used (limit {$limit}%)" : null;
|
|
}
|
|
|
|
/** Report to healthchecks.io: a ping when healthy, /fail with the reasons otherwise. */
|
|
private function report(array $failures): void
|
|
{
|
|
$pingUrl = timebank_config('health_monitor.healthcheck_url');
|
|
|
|
if (! $pingUrl) {
|
|
$this->comment('No health_monitor.healthcheck_url set - nothing reported.');
|
|
|
|
return;
|
|
}
|
|
|
|
if ($this->option('dry-run')) {
|
|
$this->comment('Dry run - not reported to healthchecks.io.');
|
|
|
|
return;
|
|
}
|
|
|
|
$url = rtrim($pingUrl, '/') . (empty($failures) ? '' : '/fail');
|
|
$body = empty($failures) ? 'All checks passed.' : implode("\n", $failures);
|
|
|
|
try {
|
|
// healthchecks.io shows the body in its alert, so the email says
|
|
// what failed.
|
|
Http::timeout(10)->withBody($body, 'text/plain')->post($url);
|
|
} catch (\Throwable $e) {
|
|
// Not reaching healthchecks.io ends as a missed ping, which it
|
|
// alerts on itself after the grace period.
|
|
Log::error('[health-monitor] Could not reach healthchecks.io: ' . $e->getMessage());
|
|
$this->error('Could not reach healthchecks.io: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|