-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
6 changed files
with
229 additions
and
137 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,133 +1,135 @@ | ||
using System.Diagnostics; | ||
using System.Net; | ||
using Docker.DotNet; | ||
using Docker.DotNet.Models; | ||
using DockerProxmoxBackup.Options; | ||
using Microsoft.Extensions.Options; | ||
|
||
namespace DockerProxmoxBackup; | ||
|
||
public class Worker(IOptions<ProxmoxOptions> options, ILogger<Worker> logger) : BackgroundService | ||
{ | ||
private readonly ProxmoxOptions options = options.Value; | ||
private readonly DockerClient client = new DockerClientConfiguration().CreateClient(); | ||
|
||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||
{ | ||
logger.LogInformation("Running worker"); | ||
|
||
var containers = await client.Containers.ListContainersAsync(new ContainersListParameters(), stoppingToken); | ||
|
||
var directory = new DirectoryInfo($"/tmp/{DateTime.Now.ToString("O")}"); | ||
|
||
foreach (var container in containers) | ||
{ | ||
logger.LogDebug("Checking {Container}", container.ID); | ||
if (!container.Image.Contains("postgres")) | ||
continue; | ||
|
||
logger.LogInformation("Backing up {Container} {Hostnames}", container.ID, string.Join(", ", container.Names)); | ||
var createDumpResult = await DumpToFile(container, stoppingToken); | ||
logger.LogInformation("Backed up to {File} inside container with exit code {ExitCode}", createDumpResult.fileName, createDumpResult.exitCode); | ||
|
||
logger.LogDebug(createDumpResult.stdout.Trim()); | ||
logger.LogError(createDumpResult.stderr.Trim()); | ||
|
||
if (createDumpResult.exitCode != 0) | ||
{ | ||
logger.LogError("Failed to backup {Container} due to exit code {ExitCode} != 0, terminating", container.ID, createDumpResult.exitCode); | ||
|
||
continue; | ||
} | ||
|
||
logger.LogInformation("Extracting dump from container"); | ||
var getDumpFileResult = await CpDumpFromContainer(container, createDumpResult.fileName, stoppingToken); | ||
|
||
var backupFile = await WriteDumpToFile(directory, container, getDumpFileResult, stoppingToken); | ||
|
||
logger.LogInformation("Added dump {file} to proxmox backup with {Size} <size units>", backupFile.FullName, getDumpFileResult.Stat.Size); | ||
} | ||
|
||
await UploadToProxmox(directory); | ||
directory.Delete(true); | ||
} | ||
|
||
private async Task<FileInfo> WriteDumpToFile(DirectoryInfo directory, ContainerListResponse container, GetArchiveFromContainerResponse dumpFile, CancellationToken stoppingToken) | ||
{ | ||
if (!directory.Exists) | ||
directory.Create(); | ||
|
||
var tmpFile = new FileInfo(Path.Combine(directory.FullName, $"{GetContainerName(container)}.dump")); | ||
await using var tmpStream = File.Create(tmpFile.FullName); | ||
await dumpFile.Stream.CopyToAsync(tmpStream); | ||
|
||
return tmpFile; | ||
} | ||
|
||
private async Task UploadToProxmox(DirectoryInfo directory) | ||
{ | ||
var process = new Process | ||
{ | ||
StartInfo = new ProcessStartInfo("proxmox-backup-client") | ||
{ | ||
|
||
ArgumentList = { "backup", $"dockerProxmoxBackup.pxar:{directory.FullName}", "--repository", options.Repository, "--ns", options.Namespace }, | ||
Environment = { { "PBS_PASSWORD_FILE", options.PasswordFile} }, | ||
RedirectStandardError = true, | ||
RedirectStandardOutput = true | ||
} | ||
}; | ||
|
||
logger.LogInformation("Running proxmox-backup-client {Args} with Env Args {Env}", string.Join(" ", process.StartInfo.ArgumentList), string.Join(", ", process.StartInfo.Environment.Select(kvp => $"{kvp.Key}={kvp.Value}"))); | ||
|
||
process.OutputDataReceived += (_, args) => logger.LogInformation($"[Exec] {args.Data}"); | ||
process.ErrorDataReceived += (_, args) => logger.LogInformation($"[Exec] {args.Data}"); | ||
process.Start(); | ||
await process.WaitForExitAsync(); | ||
|
||
logger.LogInformation("Proxmox backup exited with {ExitCode}", process.ExitCode); | ||
|
||
if (process.ExitCode != 0) | ||
logger.LogError("Proxmox exited in a bad state."); | ||
} | ||
|
||
private async Task<GetArchiveFromContainerResponse> CpDumpFromContainer(ContainerListResponse container, string fileName, CancellationToken stoppingToken) | ||
{ | ||
var getArchiveFromContainerParameters = new GetArchiveFromContainerParameters | ||
{ | ||
Path = fileName | ||
}; | ||
|
||
var getDumpCommand = await client.Containers.GetArchiveFromContainerAsync(container.ID, getArchiveFromContainerParameters, false, stoppingToken); | ||
return getDumpCommand; | ||
} | ||
|
||
private async Task<(string stdout, string stderr, long exitCode, string fileName)> DumpToFile(ContainerListResponse container, CancellationToken stoppingToken) | ||
{ | ||
var fileName = "/postgres.dump"; | ||
|
||
var cmd = await client.Exec.ExecCreateContainerAsync(container.ID, new ContainerExecCreateParameters | ||
{ | ||
AttachStderr = true, | ||
AttachStdout = true, | ||
Cmd = ["pg_dumpall", "--clean", "-U", "postgres", "-f", fileName] | ||
}, stoppingToken); | ||
|
||
using var stream = await client.Exec.StartAndAttachContainerExecAsync(cmd.ID, true, stoppingToken); | ||
var (stdout, stderr) = await stream.ReadOutputToEndAsync(stoppingToken); | ||
var execInspectResponse = await client.Exec.InspectContainerExecAsync(cmd.ID, stoppingToken); | ||
|
||
return (stdout, stderr, execInspectResponse.ExitCode, fileName); | ||
} | ||
|
||
private string GetContainerName(ContainerListResponse container) | ||
{ | ||
if (container.Labels.TryGetValue("backup.name", out var labelName)) | ||
return labelName; | ||
|
||
if (container.Labels.TryGetValue("com.docker.swarm.service.name", out var sarmName)) | ||
return sarmName; | ||
|
||
return container.Names.FirstOrDefault()?.Replace("/", "") ?? container.ID; | ||
} | ||
} | ||
using System.Diagnostics; | ||
using System.Net; | ||
using Docker.DotNet; | ||
using Docker.DotNet.Models; | ||
using DockerProxmoxBackup.Options; | ||
using Microsoft.Extensions.Options; | ||
using Quartz; | ||
|
||
namespace DockerProxmoxBackup.Jobs; | ||
|
||
public class BackupJob(IOptions<ProxmoxOptions> options, ILogger<BackupJob> logger) : IJob | ||
{ | ||
private readonly ProxmoxOptions options = options.Value; | ||
private readonly DockerClient client = new DockerClientConfiguration().CreateClient(); | ||
|
||
public async Task Execute(IJobExecutionContext context) | ||
{ | ||
logger.LogInformation("Running worker"); | ||
|
||
var containers = await client.Containers.ListContainersAsync(new ContainersListParameters(), context.CancellationToken); | ||
|
||
var directory = new DirectoryInfo($"/tmp/{DateTime.Now.ToString("O")}"); | ||
|
||
foreach (var container in containers) | ||
{ | ||
logger.LogDebug("Checking {Container}", container.ID); | ||
if (!container.Image.Contains("postgres")) | ||
continue; | ||
|
||
logger.LogInformation("Backing up {Container} {Hostnames}", container.ID, string.Join(", ", container.Names)); | ||
var createDumpResult = await DumpToFile(container, context.CancellationToken); | ||
logger.LogInformation("Backed up to {File} inside container with exit code {ExitCode}", createDumpResult.fileName, createDumpResult.exitCode); | ||
|
||
logger.LogDebug(createDumpResult.stdout.Trim()); | ||
logger.LogError(createDumpResult.stderr.Trim()); | ||
|
||
if (createDumpResult.exitCode != 0) | ||
{ | ||
logger.LogError("Failed to backup {Container} due to exit code {ExitCode} != 0, terminating", container.ID, createDumpResult.exitCode); | ||
|
||
continue; | ||
} | ||
|
||
logger.LogInformation("Extracting dump from container"); | ||
var getDumpFileResult = await CpDumpFromContainer(container, createDumpResult.fileName, context.CancellationToken); | ||
|
||
var backupFile = await WriteDumpToFile(directory, container, getDumpFileResult, context.CancellationToken); | ||
|
||
logger.LogInformation("Added dump {file} to proxmox backup with {Size} <size units>", backupFile.FullName, getDumpFileResult.Stat.Size); | ||
} | ||
|
||
await UploadToProxmox(directory); | ||
directory.Delete(true); | ||
} | ||
|
||
|
||
private async Task<FileInfo> WriteDumpToFile(DirectoryInfo directory, ContainerListResponse container, GetArchiveFromContainerResponse dumpFile, CancellationToken stoppingToken) | ||
{ | ||
if (!directory.Exists) | ||
directory.Create(); | ||
|
||
var tmpFile = new FileInfo(Path.Combine(directory.FullName, $"{GetContainerName(container)}.dump")); | ||
await using var tmpStream = File.Create(tmpFile.FullName); | ||
await dumpFile.Stream.CopyToAsync(tmpStream, stoppingToken); | ||
|
||
return tmpFile; | ||
} | ||
|
||
private async Task UploadToProxmox(DirectoryInfo directory) | ||
{ | ||
var process = new Process | ||
{ | ||
StartInfo = new ProcessStartInfo("proxmox-backup-client") | ||
{ | ||
|
||
ArgumentList = { "backup", $"dockerProxmoxBackup.pxar:{directory.FullName}", "--repository", options.Repository, "--ns", options.Namespace }, | ||
Environment = { { "PBS_PASSWORD_FILE", options.PasswordFile} }, | ||
RedirectStandardError = true, | ||
RedirectStandardOutput = true | ||
} | ||
}; | ||
|
||
logger.LogInformation("Running proxmox-backup-client {Args} with Env Args {Env}", string.Join(" ", process.StartInfo.ArgumentList), string.Join(", ", process.StartInfo.Environment.Select(kvp => $"{kvp.Key}={kvp.Value}"))); | ||
|
||
process.OutputDataReceived += (_, args) => logger.LogInformation($"[Exec] {args.Data}"); | ||
process.ErrorDataReceived += (_, args) => logger.LogInformation($"[Exec] {args.Data}"); | ||
process.Start(); | ||
await process.WaitForExitAsync(); | ||
|
||
logger.LogInformation("Proxmox backup exited with {ExitCode}", process.ExitCode); | ||
|
||
if (process.ExitCode != 0) | ||
logger.LogError("Proxmox exited in a bad state."); | ||
} | ||
|
||
private async Task<GetArchiveFromContainerResponse> CpDumpFromContainer(ContainerListResponse container, string fileName, CancellationToken stoppingToken) | ||
{ | ||
var getArchiveFromContainerParameters = new GetArchiveFromContainerParameters | ||
{ | ||
Path = fileName | ||
}; | ||
|
||
var getDumpCommand = await client.Containers.GetArchiveFromContainerAsync(container.ID, getArchiveFromContainerParameters, false, stoppingToken); | ||
return getDumpCommand; | ||
} | ||
|
||
private async Task<(string stdout, string stderr, long exitCode, string fileName)> DumpToFile(ContainerListResponse container, CancellationToken stoppingToken) | ||
{ | ||
var fileName = "/postgres.dump"; | ||
|
||
var cmd = await client.Exec.ExecCreateContainerAsync(container.ID, new ContainerExecCreateParameters | ||
{ | ||
AttachStderr = true, | ||
AttachStdout = true, | ||
Cmd = ["pg_dumpall", "--clean", "-U", "postgres", "-f", fileName] | ||
}, stoppingToken); | ||
|
||
using var stream = await client.Exec.StartAndAttachContainerExecAsync(cmd.ID, true, stoppingToken); | ||
var (stdout, stderr) = await stream.ReadOutputToEndAsync(stoppingToken); | ||
var execInspectResponse = await client.Exec.InspectContainerExecAsync(cmd.ID, stoppingToken); | ||
|
||
return (stdout, stderr, execInspectResponse.ExitCode, fileName); | ||
} | ||
|
||
private string GetContainerName(ContainerListResponse container) | ||
{ | ||
if (container.Labels.TryGetValue("backup.name", out var labelName)) | ||
return labelName; | ||
|
||
if (container.Labels.TryGetValue("com.docker.swarm.service.name", out var sarmName)) | ||
return sarmName; | ||
|
||
return container.Names.FirstOrDefault()?.Replace("/", "") ?? container.ID; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,10 +1,29 @@ | ||
using DockerProxmoxBackup; | ||
using DockerProxmoxBackup.Jobs; | ||
using DockerProxmoxBackup.Options; | ||
using Quartz; | ||
|
||
var builder = Host.CreateApplicationBuilder(args); | ||
|
||
builder.Services.Configure<ProxmoxOptions>(builder.Configuration.GetSection("Proxmox")); | ||
builder.Services.AddHostedService<Worker>(); | ||
|
||
builder.Services.AddQuartz(q => | ||
{ | ||
var job = new JobKey("backup"); | ||
q.AddJob<BackupJob>(opts => opts.WithIdentity(job)); | ||
q.AddTrigger(opts => | ||
{ | ||
var cronjob = builder.Configuration.GetSection("Proxmox")["Cronjob"] ?? throw new ArgumentNullException("Proxmox__Cronjob environment variable not set"); | ||
Console.WriteLine($"Running backups with cronjob: {cronjob}"); | ||
opts.ForJob(job) | ||
.WithIdentity("backup-trigger") | ||
.WithCronSchedule(cronjob) | ||
.StartNow(); | ||
}); | ||
}); | ||
|
||
builder.Services.AddQuartzHostedService(opts => { opts.WaitForJobsToComplete = true; } ); | ||
|
||
var host = builder.Build(); | ||
host.Run(); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,69 @@ | ||
# DockerProxmoxBackup | ||
A simple tool to backup docker containers to a proxmox backup server | ||
A simple tool to backup docker containers[1] to a proxmox backup server | ||
|
||
[1] Right now only postgres backup | ||
|
||
--- | ||
|
||
Add the backup container to either a docker compose or docker stack (for docker swarm) file: | ||
|
||
```yml | ||
services: | ||
DockerProxmoxBackup: | ||
# swap this if you're using swarm | ||
# hostname: "{{.Node.Hostname}}" | ||
hostname: devtools | ||
image: ghcr.io/lyze237/dockerproxmoxbackup:main | ||
secrets: | ||
- proxmox_password_secret | ||
volumes: | ||
- /var/run/docker.sock:/var/run/docker.sock:ro | ||
environment: | ||
Proxmox__PasswordFile: "/run/secrets/proxmox_password_secret" | ||
Proxmox__Repository: "bdd@pbs@backups.chirps.cafe:backups" | ||
Proxmox__Namespace: "files" | ||
Proxmox__Cronjob: "0 0 3 * * ?" # https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontrigger.html | ||
``` | ||
The tool uses the following order for the backup file name: | ||
* Label: `backup.name` | ||
* Label: `com.docker.swarm.service.name` | ||
* Hostname | ||
* ID | ||
|
||
```yml | ||
OtherPostgresExample: | ||
image: postgres | ||
labels: | ||
backup.name: other | ||
environment: | ||
POSTGRES_PASSWORD: password | ||
``` | ||
|
||
Try and keep the name to the same across backups, so that proxmox can deduplicate it. | ||
|
||
Here's a full swarm example which deploys the container across all nodes: | ||
|
||
```yml | ||
version: '3.8' | ||
services: | ||
dockerProxmoxBackup: | ||
image: ghcr.io/lyze237/dockerproxmoxbackup:main | ||
hostname: "{{.Node.Hostname}}" | ||
volumes: | ||
- /var/run/docker.sock:/var/run/docker.sock:ro | ||
secrets: | ||
- proxmox_password_secret | ||
environment: | ||
Proxmox__PasswordFile: "/run/secrets/proxmox_password_secret" | ||
Proxmox__Repository: "bdd@pbs@backups.chirps.cafe:backups" | ||
Proxmox__Namespace: "files" | ||
Proxmox__Cronjob: "0 0 3 * * ?" # https://www.quartz-scheduler.net/documentation/quartz-3.x/tutorial/crontrigger.html | ||
deploy: | ||
mode: global | ||
secrets: | ||
proxmox_password_secret: | ||
file: proxmox_password.secret | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters