feat(fed): add ural

This commit is contained in:
vl.arkhangelskii
2026-09-21 06:29:08 +03:00
parent 946b77540f
commit 1c1d3ff4bc
11 changed files with 194 additions and 30 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ ASP.NET Core сервер векторных тайлов для клиенто
- .NET 8 SDK - .NET 8 SDK
- **Java 21+** в `PATH` (или путь в `TileServer:JavaPath`) — без Java тайлы не соберутся, уже готовые MBTiles сервер всё равно отдаст - **Java 21+** в `PATH` (или путь в `TileServer:JavaPath`) — без Java тайлы не соберутся, уже готовые MBTiles сервер всё равно отдаст
- Диск: 5–10× размер PBF. ЦФО до **z15** — ориентир несколько ГБ готового MBTiles плюс столько же на staging во время сборки - Диск: 5–10× размер PBF. ЦФО до **z15** — ориентир несколько ГБ готового MBTiles плюс столько же на staging во время сборки
- RAM: ориентир `TileServer:JvmMaxHeap` ≈ 0.5× размер PBF (по умолчанию `8g`) - RAM: контейнер ~12g. `TileServer:JvmMaxHeap` — только Java heap, держите **~⅓ лимита** (по умолчанию `4g`). Остальное — mmap/файлы Planetiler и Kestrel. Код **137** = OOM killer, не переполнение `-Xmx`.
Тайлы собираются через OpenMapTiles. Схема рассчитана на z0–z14 (дефолт MapTiler/Planetiler). Жёсткий потолок **planetiler-openmaptiles 3.16 — z15**; z16+ этот JAR не умеет. Ближе z15 MapLibre overzoom’ит те же тайлы до z18. Тайлы собираются через OpenMapTiles. Схема рассчитана на z0–z14 (дефолт MapTiler/Planetiler). Жёсткий потолок **planetiler-openmaptiles 3.16 — z15**; z16+ этот JAR не умеет. Ближе z15 MapLibre overzoom’ит те же тайлы до z18.
@@ -0,0 +1,9 @@
# 2026-09-21 — СЗФО Failed, Planetiler 137
Баннер «ещё не готовы (ошибка)» — не «ещё качается». В логе:
`Planetiler exited with code 137` на `northwestern-fed-district` при z15 ~91% (38M tiles / 3.6G, heap 4.7G/8.5G). 137 = SIGKILL от cgroup OOM killer.
Причина: контейнер `mem_limit: 12g` без swap, JVM `-Xmx8g`. Heap + mmap `feature.db` (~6G) + staging mbtiles + Kestrel > 12g.
Фикс: heap `4g`, `--threads=8`, `--mmap_temp=false`, повтор Failed и один retry на 137, баннер показывает `lastError`. После деплоя: `docker compose up -d --build`, затем `POST /api/v1/sync/northwestern-fed-district` если RunOnStartup уже прошёл.
+5 -2
View File
@@ -13,7 +13,8 @@
# docker run --rm -v "$(pwd)/data:/from:ro" -v tile-server-data:/to alpine \ # docker run --rm -v "$(pwd)/data:/from:ro" -v tile-server-data:/to alpine \
# sh -c "cp -a /from/. /to/" # sh -c "cp -a /from/. /to/"
# #
# Tune TileServer__JvmMaxHeap / mem_limit to the machine. # Heap must stay well below mem_limit: Planetiler maps temp files outside -Xmx.
# Exit 137 = cgroup OOM killer. 8g heap in a 12g box dies on NW FO z15.
# After raising MaxZoom (z14→z15) the next startup rebuilds MBTiles; keep the volume. # After raising MaxZoom (z14→z15) the next startup rebuilds MBTiles; keep the volume.
services: services:
@@ -57,7 +58,9 @@ services:
TZ: UTC TZ: UTC
TileServer__DataDirectory: /data TileServer__DataDirectory: /data
TileServer__JavaPath: /opt/java/openjdk/bin/java TileServer__JavaPath: /opt/java/openjdk/bin/java
TileServer__JvmMaxHeap: 8g TileServer__JvmMaxHeap: 4g
TileServer__PlanetilerThreads: "8"
TileServer__PlanetilerMmapTemp: "false"
TileServer__Sync__RunOnStartup: "true" TileServer__Sync__RunOnStartup: "true"
TileServer__Sync__Cron: "0 3 * * *" TileServer__Sync__Cron: "0 3 * * *"
TileServer__Sync__TimeZone: UTC TileServer__Sync__TimeZone: UTC
+12 -1
View File
@@ -12,7 +12,9 @@
"TileServer": { "TileServer": {
"DataDirectory": "../../data", "DataDirectory": "../../data",
"JavaPath": "java", "JavaPath": "java",
"JvmMaxHeap": "8g", "JvmMaxHeap": "4g",
"PlanetilerThreads": 8,
"PlanetilerMmapTemp": false,
"PlanetilerJarUrl": "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar", "PlanetilerJarUrl": "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar",
"PlanetilerHttpTimeout": "15m", "PlanetilerHttpTimeout": "15m",
"DownloadStallTimeoutSeconds": 45, "DownloadStallTimeoutSeconds": 45,
@@ -65,6 +67,15 @@
"MinZoom": 0, "MinZoom": 0,
"MaxZoom": 15, "MaxZoom": 15,
"Enabled": true "Enabled": true
},
{
"Id": "ural-fed-district",
"Name": "Уральский федеральный округ",
"Url": "https://download.geofabrik.de/russia/ural-fed-district-latest.osm.pbf",
"Center": [60.0, 55.0],
"MinZoom": 0,
"MaxZoom": 15,
"Enabled": true
} }
] ]
} }
@@ -8,7 +8,20 @@ public sealed class TileServerOptions
public string JavaPath { get; set; } = "java"; public string JavaPath { get; set; } = "java";
public string JvmMaxHeap { get; set; } = "8g"; /// <summary>
/// JVM heap only. Planetiler memory-maps temp features outside this budget.
/// Keep around one third of the container mem_limit (4g in a 12g box).
/// Exit 137 is the OOM killer, not a Java heap dump.
/// </summary>
public string JvmMaxHeap { get; set; } = "4g";
/// <summary>0 = Planetiler default (all CPUs). Cap on small containers to limit RSS.</summary>
public int PlanetilerThreads { get; set; } = 8;
/// <summary>
/// mmap of feature.db inflates RSS on large z15 extracts. Off by default for 12g hosts.
/// </summary>
public bool PlanetilerMmapTemp { get; set; }
public string PlanetilerJarUrl { get; set; } = public string PlanetilerJarUrl { get; set; } =
"https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar"; "https://github.com/openmaptiles/planetiler-openmaptiles/releases/download/v3.16/planetiler-openmaptiles.jar";
@@ -129,6 +129,7 @@ public sealed class MapSyncService(
{ {
var state = await stateStore.GetAsync(extract.Id, ct).ConfigureAwait(false); var state = await stateStore.GetAsync(extract.Id, ct).ConfigureAwait(false);
var pbfPath = dataPaths.OsmPbf(extract.Id); var pbfPath = dataPaths.OsmPbf(extract.Id);
var previousStatus = state.Status;
try try
{ {
@@ -150,6 +151,7 @@ public sealed class MapSyncService(
var metadata = tileStore.GetMetadata(extract.Id); var metadata = tileStore.GetMetadata(extract.Id);
var zoomRaised = metadata is not null && extract.MaxZoom > metadata.MaxZoom; var zoomRaised = metadata is not null && extract.MaxZoom > metadata.MaxZoom;
var shouldBuild = download.Changed var shouldBuild = download.Changed
|| previousStatus == ExtractSyncState.Failed
|| (tilesMissing && options.Value.Sync.RebuildIfTilesMissing) || (tilesMissing && options.Value.Sync.RebuildIfTilesMissing)
|| zoomRaised; || zoomRaised;
if (!shouldBuild) if (!shouldBuild)
@@ -168,6 +170,10 @@ public sealed class MapSyncService(
extract.MaxZoom, extract.MaxZoom,
metadata!.MaxZoom); metadata!.MaxZoom);
} }
else if (previousStatus == ExtractSyncState.Failed)
{
logger.LogInformation("Rebuilding {ExtractId}: previous run failed.", extract.Id);
}
if (!planetiler.IsJavaAvailable()) if (!planetiler.IsJavaAvailable())
{ {
@@ -0,0 +1,22 @@
namespace TileServer.Infrastructure.Tiles;
public static class PlanetilerExit
{
/// <summary>128 + SIGKILL. Docker/cgroup OOM killer, not a Java heap dump.</summary>
public const int LinuxOomKill = 137;
public static bool IsLikelyOom(int exitCode) => exitCode is LinuxOomKill or 9;
public static string Describe(int exitCode, string extractId)
{
if (IsLikelyOom(exitCode))
{
return
$"Planetiler was killed (exit {exitCode}, typically the Linux OOM killer) while building '{extractId}'. " +
"JVM -Xmx is only the heap; Planetiler also maps temp features. " +
"Keep TileServer:JvmMaxHeap around 1/3 of the container mem_limit (4g in 12g) and PlanetilerMmapTemp=false.";
}
return $"Planetiler exited with code {exitCode} for extract '{extractId}'.";
}
}
@@ -15,6 +15,8 @@ public sealed class PlanetilerTileBuilder(
IOptions<TileServerOptions> options, IOptions<TileServerOptions> options,
ILogger<PlanetilerTileBuilder> logger) : ITileBuilder ILogger<PlanetilerTileBuilder> logger) : ITileBuilder
{ {
private const int OomRetryAttempts = 2;
public async Task BuildAsync(Extract extract, string osmPbfPath, string outputMbtilesPath, CancellationToken ct) public async Task BuildAsync(Extract extract, string osmPbfPath, string outputMbtilesPath, CancellationToken ct)
{ {
if (!File.Exists(osmPbfPath)) if (!File.Exists(osmPbfPath))
@@ -24,16 +26,64 @@ public sealed class PlanetilerTileBuilder(
var jar = await bootstrapper.EnsureJarAsync(ct).ConfigureAwait(false); var jar = await bootstrapper.EnsureJarAsync(ct).ConfigureAwait(false);
Directory.CreateDirectory(Path.GetDirectoryName(outputMbtilesPath)!); Directory.CreateDirectory(Path.GetDirectoryName(outputMbtilesPath)!);
Directory.CreateDirectory(dataPaths.Tmp);
var cfg = options.Value;
Exception? lastFailure = null;
for (var attempt = 1; attempt <= OomRetryAttempts; attempt++)
{
if (File.Exists(outputMbtilesPath)) if (File.Exists(outputMbtilesPath))
{ {
File.Delete(outputMbtilesPath); File.Delete(outputMbtilesPath);
} }
var cfg = options.Value; var psi = CreateStartInfo(cfg, jar, extract, osmPbfPath, outputMbtilesPath);
var java = cfg.JavaPath; logger.LogInformation(
"Starting Planetiler for {ExtractId} (attempt {Attempt}/{Max}): {File} {Args}",
extract.Id,
attempt,
OomRetryAttempts,
cfg.JavaPath,
string.Join(' ', psi.ArgumentList));
var exitCode = await RunAsync(psi, extract.Id, ct).ConfigureAwait(false);
ClearTmp();
if (exitCode == 0)
{
if (!File.Exists(outputMbtilesPath))
{
throw new InvalidOperationException($"Planetiler finished but '{outputMbtilesPath}' was not created.");
}
return;
}
lastFailure = new InvalidOperationException(PlanetilerExit.Describe(exitCode, extract.Id));
if (!PlanetilerExit.IsLikelyOom(exitCode) || attempt == OomRetryAttempts)
{
throw lastFailure;
}
logger.LogWarning(
lastFailure,
"Planetiler OOM for {ExtractId}; retrying after clearing tmp.",
extract.Id);
}
throw lastFailure ?? new InvalidOperationException($"Planetiler failed for extract '{extract.Id}'.");
}
private ProcessStartInfo CreateStartInfo(
TileServerOptions cfg,
string jar,
Extract extract,
string osmPbfPath,
string outputMbtilesPath)
{
var psi = new ProcessStartInfo var psi = new ProcessStartInfo
{ {
FileName = java, FileName = cfg.JavaPath,
RedirectStandardOutput = true, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false, UseShellExecute = false,
@@ -59,28 +109,36 @@ public sealed class PlanetilerTileBuilder(
psi.ArgumentList.Add($"--natural_earth_url={cfg.NaturalEarthUrl}"); psi.ArgumentList.Add($"--natural_earth_url={cfg.NaturalEarthUrl}");
psi.ArgumentList.Add($"--lake_centerlines_url={cfg.LakeCenterlinesUrl}"); psi.ArgumentList.Add($"--lake_centerlines_url={cfg.LakeCenterlinesUrl}");
psi.ArgumentList.Add($"--water_polygons_url={cfg.WaterPolygonsUrl}"); psi.ArgumentList.Add($"--water_polygons_url={cfg.WaterPolygonsUrl}");
psi.ArgumentList.Add($"--mmap_temp={cfg.PlanetilerMmapTemp.ToString().ToLowerInvariant()}");
if (cfg.PlanetilerThreads > 0)
{
psi.ArgumentList.Add($"--threads={cfg.PlanetilerThreads}");
}
logger.LogInformation("Starting Planetiler for {ExtractId}: {File} {Args}", extract.Id, java, string.Join(' ', psi.ArgumentList)); return psi;
}
private async Task<int> RunAsync(ProcessStartInfo psi, string extractId, CancellationToken ct)
{
using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
process.OutputDataReceived += (_, e) => process.OutputDataReceived += (_, e) =>
{ {
if (!string.IsNullOrWhiteSpace(e.Data)) if (!string.IsNullOrWhiteSpace(e.Data))
{ {
logger.LogInformation("[planetiler {ExtractId}] {Line}", extract.Id, e.Data); logger.LogInformation("[planetiler {ExtractId}] {Line}", extractId, e.Data);
} }
}; };
process.ErrorDataReceived += (_, e) => process.ErrorDataReceived += (_, e) =>
{ {
if (!string.IsNullOrWhiteSpace(e.Data)) if (!string.IsNullOrWhiteSpace(e.Data))
{ {
logger.LogInformation("[planetiler {ExtractId}] {Line}", extract.Id, e.Data); logger.LogInformation("[planetiler {ExtractId}] {Line}", extractId, e.Data);
} }
}; };
if (!process.Start()) if (!process.Start())
{ {
throw new InvalidOperationException($"Failed to start '{java}'."); throw new InvalidOperationException($"Failed to start '{psi.FileName}'.");
} }
process.BeginOutputReadLine(); process.BeginOutputReadLine();
@@ -96,14 +154,31 @@ public sealed class PlanetilerTileBuilder(
throw; throw;
} }
if (process.ExitCode != 0) return process.ExitCode;
{
throw new InvalidOperationException($"Planetiler exited with code {process.ExitCode} for extract '{extract.Id}'.");
} }
if (!File.Exists(outputMbtilesPath)) private void ClearTmp()
{ {
throw new InvalidOperationException($"Planetiler finished but '{outputMbtilesPath}' was not created."); try
{
if (!Directory.Exists(dataPaths.Tmp))
{
return;
}
foreach (var file in Directory.EnumerateFiles(dataPaths.Tmp))
{
File.Delete(file);
}
foreach (var dir in Directory.EnumerateDirectories(dataPaths.Tmp))
{
Directory.Delete(dir, recursive: true);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not clear Planetiler tmp {Tmp}", dataPaths.Tmp);
} }
} }
@@ -0,0 +1,23 @@
using TileServer.Infrastructure.Tiles;
namespace TileServer.UnitTests;
public sealed class PlanetilerExitTests
{
[Fact]
public void IsLikelyOom_DetectsLinuxOomKiller()
{
Assert.True(PlanetilerExit.IsLikelyOom(137));
Assert.True(PlanetilerExit.IsLikelyOom(9));
Assert.False(PlanetilerExit.IsLikelyOom(1));
}
[Fact]
public void Describe_Oom_MentionsHeapVersusRss()
{
var text = PlanetilerExit.Describe(137, "northwestern-fed-district");
Assert.Contains("northwestern-fed-district", text, StringComparison.Ordinal);
Assert.Contains("OOM", text, StringComparison.Ordinal);
Assert.Contains("JvmMaxHeap", text, StringComparison.Ordinal);
}
}
+9 -7
View File
@@ -23,8 +23,14 @@ const STATUS_LABELS: Record<string, string> = {
Failed: "ошибка" Failed: "ошибка"
}; };
function formatStatus(value?: string): string { function sourceBanner(item: SourceItem): string {
return STATUS_LABELS[value ?? ""] ?? value ?? "—"; if (item.status === "Failed") {
const detail = item.lastError?.trim();
return detail
? `Сборка «${item.name}» не удалась: ${detail}`
: `Сборка «${item.name}» не удалась. Повтор — рестарт сервиса или POST /api/v1/sync/${item.id}.`;
}
return `Тайлы источника «${item.name}» ещё не готовы (${formatStatus(item.status)}). Первый прогон может занять часы.`;
} }
function formatDate(value?: string | null): string { function formatDate(value?: string | null): string {
@@ -179,11 +185,7 @@ export function App() {
styleKeyRef.current = styleKey; styleKeyRef.current = styleKey;
const pending = selectedSources.find((item) => item.status !== "Ready"); const pending = selectedSources.find((item) => item.status !== "Ready");
setBanner( setBanner(pending ? sourceBanner(pending) : "");
pending
? `Тайлы источника «${pending.name}» ещё не готовы (${formatStatus(pending.status)}). Первый прогон может занять часы.`
: ""
);
}, [access, personal, selectedSourceIds, sources, source, styleName, syncGotoFromMap, updateHud]); }, [access, personal, selectedSourceIds, sources, source, styleName, syncGotoFromMap, updateHud]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
+2 -2
View File
@@ -444,8 +444,8 @@ dd {
.banner { .banner {
position: absolute; position: absolute;
z-index: var(--hud-z); z-index: var(--hud-z);
left: var(--space); right: var(--space);
top: 5.4rem; top: 10em;
max-width: min(34rem, calc(100vw - 24px)); max-width: min(34rem, calc(100vw - 24px));
background: rgba(42, 31, 18, 0.92); background: rgba(42, 31, 18, 0.92);
color: #ffe0c2; color: #ffe0c2;