Use GitOps workflow for building a production grade on-premise Kubernetes cluster on cheap VPS provider, with complete CI/CD 🎉
This is the Part VIII of more global topic tutorial. Back to guide summary for intro.
Real DB App sample
Let’s add some DB usage to our sample app. We’ll use the classical Articles<->Authors<->Comments relationships. First create docker-compose.yml file in root of demo project:
version: "3"
services: db: image: postgres:15 environment: POSTGRES_USER: main POSTGRES_PASSWORD: main POSTGRES_DB: main ports: - 5432:5432Launch it with docker compose up -d and check database running with docker ps.
Time to create basic code that list plenty of articles from an API endpoint. Go back to kuberocks-demo and create a new separate project dedicated to app logic:
dotnet new classlib -o src/KubeRocks.Applicationdotnet sln add src/KubeRocks.Applicationdotnet add src/KubeRocks.WebApi reference src/KubeRocks.Application
dotnet add src/KubeRocks.Application package Microsoft.EntityFrameworkCoredotnet add src/KubeRocks.Application package Npgsql.EntityFrameworkCore.PostgreSQLdotnet add src/KubeRocks.WebApi package Microsoft.EntityFrameworkCore.DesignNote
This is not a DDD course ! We will keep it simple and focus on Kubernetes part.
Define the entities
using System.ComponentModel.DataAnnotations;
namespace KubeRocks.Application.Entities;
public class Article{ public int Id { get; set; }
public required User Author { get; set; }
[MaxLength(255)] public required string Title { get; set; } [MaxLength(255)] public required string Slug { get; set; } public required string Description { get; set; } public required string Body { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public ICollection<Comment> Comments { get; } = new List<Comment>();}namespace KubeRocks.Application.Entities;
public class Comment{ public int Id { get; set; }
public required Article Article { get; set; } public required User Author { get; set; }
public required string Body { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;}using System.ComponentModel.DataAnnotations;
namespace KubeRocks.Application.Entities;
public class User{ public int Id { get; set; }
[MaxLength(255)] public required string Name { get; set; }
[MaxLength(255)] public required string Email { get; set; }
public ICollection<Article> Articles { get; } = new List<Article>(); public ICollection<Comment> Comments { get; } = new List<Comment>();}namespace KubeRocks.Application.Contexts;
using KubeRocks.Application.Entities;using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext{ public DbSet<User> Users => Set<User>(); public DbSet<Article> Articles => Set<Article>(); public DbSet<Comment> Comments => Set<Comment>();
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder);
modelBuilder.Entity<User>() .HasIndex(u => u.Email).IsUnique() ;
modelBuilder.Entity<Article>() .HasIndex(u => u.Slug).IsUnique() ; }}using KubeRocks.Application.Contexts;using Microsoft.EntityFrameworkCore;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;
namespace KubeRocks.Application.Extensions;
public static class ServiceExtensions{ public static IServiceCollection AddKubeRocksServices(this IServiceCollection services, IConfiguration configuration) { return services.AddDbContext<AppDbContext>((options) => { options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")); }); }}using KubeRocks.Application.Extensions;
//...
// Add services to the container.builder.Services.AddKubeRocksServices(builder.Configuration);
//...{ //... "ConnectionStrings": { "DefaultConnection": "Host=localhost;Username=main;Password=main;Database=main;" }}Now as all models are created, we can generate migrations and update database accordingly:
dotnet new tool-manifestdotnet tool install dotnet-ef
dotnet dotnet-ef -p src/KubeRocks.Application -s src/KubeRocks.WebApi migrations add InitialCreatedotnet dotnet-ef -p src/KubeRocks.Application -s src/KubeRocks.WebApi database updateInject some dummy data
We’ll use Bogus on a separate console project:
dotnet new console -o src/KubeRocks.Consoledotnet sln add src/KubeRocks.Consoledotnet add src/KubeRocks.WebApi reference src/KubeRocks.Applicationdotnet add src/KubeRocks.Console package Bogusdotnet add src/KubeRocks.Console package ConsoleAppFrameworkdotnet add src/KubeRocks.Console package Respawn{ "ConnectionStrings": { "DefaultConnection": "Host=localhost;Username=main;Password=main;Database=main;" }}<Project Sdk="Microsoft.NET.Sdk">
<!-- ... -->
<PropertyGroup> <!-- ... --> <RunWorkingDirectory>$(MSBuildProjectDirectory)</RunWorkingDirectory> </PropertyGroup>
<ItemGroup> <None Update="appsettings.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>
</Project>using Bogus;using KubeRocks.Application.Contexts;using KubeRocks.Application.Entities;using Microsoft.EntityFrameworkCore;using Npgsql;using Respawn;using Respawn.Graph;
namespace KubeRocks.Console.Commands;
[Command("db")]public class DbCommand : ConsoleAppBase{ private readonly AppDbContext _context;
public DbCommand(AppDbContext context) { _context = context; }
[Command("migrate", "Migrate database")] public async Task Migrate() { await _context.Database.MigrateAsync(); }
[Command("fresh", "Wipe data")] public async Task FreshData() { await Migrate();
using var conn = new NpgsqlConnection(_context.Database.GetConnectionString());
await conn.OpenAsync();
var respawner = await Respawner.CreateAsync(conn, new RespawnerOptions { TablesToIgnore = new Table[] { "__EFMigrationsHistory" }, DbAdapter = DbAdapter.Postgres });
await respawner.ResetAsync(conn); }
[Command("seed", "Fake data")] public async Task SeedData() { await Migrate(); await FreshData();
var users = new Faker<User>() .RuleFor(m => m.Name, f => f.Person.FullName) .RuleFor(m => m.Email, f => f.Person.Email) .Generate(50);
await _context.Users.AddRangeAsync(users); await _context.SaveChangesAsync();
var articles = new Faker<Article>() .RuleFor(a => a.Title, f => f.Lorem.Sentence().TrimEnd('.')) .RuleFor(a => a.Description, f => f.Lorem.Paragraphs(1)) .RuleFor(a => a.Body, f => f.Lorem.Paragraphs(5)) .RuleFor(a => a.Author, f => f.PickRandom(users)) .RuleFor(a => a.CreatedAt, f => f.Date.Recent(90).ToUniversalTime()) .RuleFor(a => a.Slug, (f, a) => a.Title.Replace(" ", "-").ToLowerInvariant()) .Generate(500) .Select(a => { new Faker<Comment>() .RuleFor(a => a.Body, f => f.Lorem.Paragraphs(2)) .RuleFor(a => a.Author, f => f.PickRandom(users)) .RuleFor(a => a.CreatedAt, f => f.Date.Recent(7).ToUniversalTime()) .Generate(new Faker().Random.Number(10)) .ForEach(c => a.Comments.Add(c));
return a; });
await _context.Articles.AddRangeAsync(articles); await _context.SaveChangesAsync(); }}using KubeRocks.Application.Extensions;using KubeRocks.Console.Commands;
var builder = ConsoleApp.CreateBuilder(args);
builder.ConfigureServices((ctx, services) =>{ services.AddKubeRocksServices(ctx.Configuration);});
var app = builder.Build();
app.AddSubCommands<DbCommand>();
app.Run();Then launch the command:
dotnet run --project src/KubeRocks.Console db seedEnsure with your favorite DB client that data is correctly inserted.
Define endpoint access
All that’s left is to create the endpoint. Let’s define all DTO first:
dotnet add src/KubeRocks.WebApi package Mapsternamespace KubeRocks.WebApi.Models;
public class ArticleListDto{ public required string Title { get; set; }
public required string Slug { get; set; }
public required string Description { get; set; }
public required string Body { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public required AuthorDto Author { get; set; }}namespace KubeRocks.WebApi.Models;
public class ArticleDto : ArticleListDto{ public List<CommentDto> Comments { get; set; } = new();}namespace KubeRocks.WebApi.Models;
public class AuthorDto{ public required string Name { get; set; }}namespace KubeRocks.WebApi.Models;
public class CommentDto{ public required string Body { get; set; }
public DateTime CreatedAt { get; set; }
public required AuthorDto Author { get; set; }}And finally the controller:
using KubeRocks.Application.Contexts;using KubeRocks.WebApi.Models;using Mapster;using Microsoft.AspNetCore.Mvc;using Microsoft.EntityFrameworkCore;
namespace KubeRocks.WebApi.Controllers;
[ApiController][Route("[controller]")]public class ArticlesController{ private readonly AppDbContext _context;
public record ArticlesResponse(IEnumerable<ArticleListDto> Articles, int ArticlesCount);
public ArticlesController(AppDbContext context) { _context = context; }
[HttpGet(Name = "GetArticles")] public async Task<ArticlesResponse> Get([FromQuery] int page = 1, [FromQuery] int size = 10) { var articles = await _context.Articles .OrderByDescending(a => a.Id) .Skip((page - 1) * size) .Take(size) .ProjectToType<ArticleListDto>() .ToListAsync();
var articlesCount = await _context.Articles.CountAsync();
return new ArticlesResponse(articles, articlesCount); }
[HttpGet("{slug}", Name = "GetArticleBySlug")] public async Task<ActionResult<ArticleDto>> GetBySlug(string slug) { var article = await _context.Articles .Include(a => a.Author) .Include(a => a.Comments.OrderByDescending(c => c.Id)) .ThenInclude(c => c.Author) .FirstOrDefaultAsync(a => a.Slug == slug);
if (article is null) { return new NotFoundResult(); }
return article.Adapt<ArticleDto>(); }}Launch the app and check that /Articles and /Articles/{slug} endpoints are working as expected.
Deployment with database
Database connection
It’s time to connect our app to the production database. Create a demo DB & user through pgAdmin and create the appropriate secret:
apiVersion: v1kind: Secretmetadata: name: demo-dbtype: Opaquedata: password: ZGVtbw==Generate the according sealed secret like previously chapters with kubeseal under sealed-secret-demo-db.yaml file and delete secret-demo-db.yaml.
cat clusters/demo/kuberocks/secret-demo.yaml | kubeseal --format=yaml --cert=pub-sealed-secrets.pem > clusters/demo/kuberocks/sealed-secret-demo.yamlrm clusters/demo/kuberocks/secret-demo.yamlLet’s inject the appropriate connection string as environment variable:
# ...spec: # ... template: # ... spec: # ... containers: - name: api # ... env: - name: DB_PASSWORD valueFrom: secretKeyRef: name: demo-db key: password - name: ConnectionStrings__DefaultConnection value: Host=postgresql-primary.postgres;Username=demo;Password='$(DB_PASSWORD)';Database=demo;#...Database migration
The DB connection should be done, but the database isn’t migrated yet, the easiest is to add a migration step directly in startup app:
// ...var app = builder.Build();
using var scope = app.Services.CreateScope();await using var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();await dbContext.Database.MigrateAsync();
// ...The database should be migrated on first app launch on next deploy. Go to https://demo.kube.rocks/Articles to confirm all is ok. It should return next empty response:
{ articles: [] articlesCount: 0}Note
Don’t hesitate to abuse of klo -n kuberocks deploy/demo to debug any troubleshooting when pod is on error state.
Database seeding
We’ll try to seed the database directly from local. Change temporarily the connection string in appsettings.json to point to the production database:
{ "ConnectionStrings": { "DefaultConnection": "Host=localhost:54321;Username=demo;Password='xxx';Database=demo;" }}Then:
# forward the production database port to localkpf svc/postgresql -n postgres 54321:tcp-postgresql# launch the seeding commanddotnet run --project src/KubeRocks.Console db seedNote
We may obviously never do this on real production database, but as it’s only for seeding, it will never concern them.
Return to https://demo.kube.rocks/Articles to confirm articles are correctly returned.
8th check ✅
We now have a little more realistic app. Go next part, we’ll talk about further monitoring integration and tracing with OpenTelemetry.