Docker

Змінні оточення та конфігурація

Передача конфігурації в Docker-контейнери — ENV, env files, secrets, 12-Factor App

Змінні оточення та конфігурація

Проблема хардкоду конфігурації

Уявіть, що ви створили ASP.NET Core Web API з підключенням до PostgreSQL. У файлі appsettings.json ви вказали рядок підключення:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=myapp;Username=postgres;Password=mysecretpassword"
  }
}

Ви зібрали Docker-образ з цією конфігурацією. Все працює локально. Але тепер виникають проблеми:

Проблема 1: Різні середовища

  • Development: База даних на localhost:5432
  • Staging: База даних на staging-db.internal:5432
  • Production: База даних на prod-db-cluster.aws.com:5432

Для кожного середовища потрібен окремий образ з різними appsettings.json. Це означає три різні збірки, три різні образи, три різні тести. Якщо ви знайшли баг у production-образі, ви не можете бути впевнені, що той самий код працює у development-образі — це різні артефакти.

Проблема 2: Безпека

Пароль mysecretpassword захардкоджений у образі. Будь-хто, хто має доступ до образу, може витягти appsettings.json та побачити пароль:

# Витягти файл з образу
docker create --name temp myapp:latest
docker cp temp:/app/appsettings.json .
docker rm temp

# Прочитати пароль
cat appsettings.json | grep Password

Якщо ви публікуєте образ у Docker Hub (навіть у приватний registry), секрети стають доступними всім, хто має доступ до registry.

Проблема 3: Динамічна конфігурація

Ви хочете змінити рівень логування з Information на Debug для діагностики проблеми у production. З хардкодженою конфігурацією вам потрібно:

  1. Змінити appsettings.json
  2. Пересобрати образ
  3. Задеплоїти новий образ
  4. Перезапустити контейнери

Це займає 10-15 хвилин. А якщо проблема критична і кожна хвилина downtime коштує грошей?

Рішення: Винести конфігурацію за межі образу та передавати її через змінні оточення (environment variables) при запуску контейнера. Один образ — багато конфігурацій.

Ця стаття передбачає розуміння Docker basics (контейнери, образи, Dockerfile) з попередніх статей. Тут ми зосередимося на конфігурації застосунків у контейнерах.

12-Factor App: Store Config in the Environment

Філософія 12-Factor App

12-Factor App — це методологія розробки cloud-native застосунків, створена інженерами Heroku у 2011 році. Один з ключових принципів — Factor III: Config.

Принцип: "Store config in the environment"

Що таке config?

Конфігурація — це все, що змінюється між deployments (development, staging, production), але не є кодом:

  • Рядки підключення до баз даних
  • Credentials для зовнішніх сервісів (AWS, SendGrid, Stripe)
  • Hostname для API endpoints
  • Порти, на яких слухає застосунок
  • Рівні логування
  • Feature flags

Що НЕ є config:

  • Бізнес-логіка
  • Маршрути (routes)
  • Dependency injection налаштування
  • Константи, що не змінюються між середовищами

Чому не файли конфігурації?

Традиційний підхід: appsettings.Development.json, appsettings.Production.json

Проблеми:

  1. Файли потрапляють у Git — секрети у version control (навіть якщо .gitignore, хтось може випадково закомітити)
  2. Різні файли для різних середовищ — складно підтримувати синхронізацію
  3. Файли "прив'язані" до образу — потрібен rebuild для зміни конфігурації
  4. Немає централізованого управління — кожен сервіс має свої файли

Підхід 12-Factor: Змінні оточення

Переваги:

  1. Не потрапляють у Git — передаються при deployment
  2. Один образ — багато конфігурацій — той самий образ у dev, staging, production
  3. Динамічна зміна — перезапустити контейнер з новими змінними (секунди, не хвилини)
  4. Централізоване управління — через orchestration tools (Kubernetes ConfigMaps, Docker Swarm secrets)
Best Practice: Використовуйте змінні оточення для всього, що змінюється між середовищами. Файли конфігурації (appsettings.json) мають містити лише значення за замовчуванням та структуру, але не секрети.

Передача змінних оточення в Docker

Спосіб 1: Прапорець -e при запуску

Синтаксис:

docker run -e VARIABLE_NAME=value image_name

Приклад:

docker run -d \
  --name myapp \
  -e ASPNETCORE_ENVIRONMENT=Production \
  -e ConnectionStrings__DefaultConnection="Host=prod-db;Database=myapp;Username=postgres;Password=secret" \
  myapp:latest

Пояснення:

  • -e ASPNETCORE_ENVIRONMENT=Production — встановлює змінну ASPNETCORE_ENVIRONMENT зі значенням Production
  • ConnectionStrings__DefaultConnection — ASP.NET Core використовує __ (подвійне підкреслення) як роздільник для вкладених секцій у appsettings.json

Як це працює в ASP.NET Core:

appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=myapp"
  }
}

Змінна оточення ConnectionStrings__DefaultConnection перезаписує значення з appsettings.json.

Переваги:

  • Простота — не потрібні додаткові файли
  • Явність — всі змінні видно у команді docker run

Недоліки:

  • Багато змінних = довга команда
  • Змінні видно у docker inspect та docker ps (небезпечно для секретів)
  • Важко керувати для складних застосунків

Спосіб 2: Файл змінних --env-file

Синтаксис:

docker run --env-file path/to/file.env image_name

Приклад:

Створити файл .env:

# .env
ASPNETCORE_ENVIRONMENT=Production
ASPNETCORE_URLS=http://0.0.0.0:5000

# Database
ConnectionStrings__DefaultConnection=Host=prod-db;Database=myapp;Username=postgres;Password=secret

# Logging
Logging__LogLevel__Default=Information
Logging__LogLevel__Microsoft.AspNetCore=Warning

# Feature Flags
Features__EnableNewUI=true
Features__EnableBetaFeatures=false

Запустити контейнер:

docker run -d \
  --name myapp \
  --env-file .env \
  -p 5000:5000 \
  myapp:latest

Переваги:

  • Організованість — всі змінні в одному файлі
  • Повторне використання — той самий файл для кількох контейнерів
  • Коментарі — можна додавати пояснення у файлі
  • Version control — можна зберігати .env.example у Git (без секретів)

Недоліки:

  • Файл все ще містить секрети у plain text
  • Потрібно керувати файлами для різних середовищ (.env.dev, .env.prod)
Безпека: Ніколи не комітьте .env файли з секретами у Git! Додайте .env до .gitignore. Для команди створіть .env.example з placeholder-значеннями:
# .env.example
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__DefaultConnection=Host=localhost;Database=myapp;Username=postgres;Password=YOUR_PASSWORD_HERE

Спосіб 3: Змінні з хоста

Docker автоматично передає змінні з хоста, якщо вказати ім'я без значення:

export DATABASE_PASSWORD=secret123

docker run -d \
  --name myapp \
  -e DATABASE_PASSWORD \
  myapp:latest

Docker візьме значення DATABASE_PASSWORD з оточення хоста.

Use case: CI/CD pipelines, де секрети зберігаються у secrets manager (GitHub Secrets, GitLab CI Variables).

Приклад у GitHub Actions:

name: Deploy to Production

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Build Docker image
        run: docker build -t myapp:latest .

      - name: Run container with secrets
        env:
          DATABASE_PASSWORD: ${{ secrets.DB_PASSWORD }}
          API_KEY: ${{ secrets.API_KEY }}
        run: |
          docker run -d \
            --name myapp \
            -e DATABASE_PASSWORD \
            -e API_KEY \
            -p 5000:5000 \
            myapp:latest

Що відбувається:

  1. GitHub Actions встановлює змінні оточення з секретів
  2. Docker читає ці змінні з оточення хоста
  3. Секрети не потрапляють у логи GitHub Actions (автоматично маскуються)

Приклад у GitLab CI:

deploy:
  stage: deploy
  script:
    - docker build -t myapp:latest .
    - docker run -d
        --name myapp
        -e DATABASE_PASSWORD=$DB_PASSWORD
        -e API_KEY=$API_KEY
        -p 5000:5000
        myapp:latest
  variables:
    DB_PASSWORD: $CI_DB_PASSWORD  # З GitLab CI Variables
    API_KEY: $CI_API_KEY

Переваги:

  • Секрети не зберігаються у файлах
  • Інтеграція з CI/CD secrets managers
  • Автоматичне маскування у логах

Недоліки:

  • Потрібно налаштовувати змінні на хості
  • Складніше для локальної розробки

ENV у Dockerfile: значення за замовчуванням

Інструкція ENV

Синтаксис:

ENV VARIABLE_NAME=value

Приклад:

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app

# Змінні за замовчуванням
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://0.0.0.0:5000
ENV Logging__LogLevel__Default=Information

COPY . .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Що відбувається:

  1. Змінні ENV встановлюються при побудові образу
  2. Ці значення стають за замовчуванням для всіх контейнерів з цього образу
  3. Змінні можна перезаписати через -e або --env-file при запуску

Приклад перезапису:

# Використати значення за замовчуванням (Production)
docker run myapp:latest

# Перезаписати на Development
docker run -e ASPNETCORE_ENVIRONMENT=Development myapp:latest

Коли використовувати ENV у Dockerfile:

  • Значення, що рідко змінюються — порт за замовчуванням, рівень логування
  • Документація — показати, які змінні підтримує застосунок
  • Не для секретів! — паролі, API keys ніколи не мають бути у ENV
Best Practice: Використовуйте ENV для non-sensitive значень за замовчуванням. Секрети передавайте через -e або --env-file при запуску.

Це перша частина статті 15.environment-and-configuration.md. Я створив:

  1. Вступ — проблема хардкоду конфігурації (різні середовища, безпека, динамічна зміна)
  2. 12-Factor App — філософія "Store config in the environment"
  3. Передача змінних — три способи: -e, --env-file, змінні з хоста
  4. ENV у Dockerfile — значення за замовчуванням

Продовжити з наступними розділами?

  • ARG vs ENV (build-time vs runtime)
  • Конфігурація .NET через змінні оточення
  • Docker Secrets (вступне знайомство)
  • Практичні приклади для C# API
  • Практичні завдання

ARG vs ENV: Build-time vs Runtime

Різниця між ARG та ENV

ARG та ENV — це дві інструкції Dockerfile для роботи зі змінними, але вони працюють у різний час та мають різне призначення.

АспектARGENV
Час життяBuild-time (під час docker build)Runtime (під час docker run)
ДоступністьЛише у DockerfileУ Dockerfile та у контейнері
Передача значенняdocker build --build-arg NAME=valuedocker run -e NAME=value
ПерезаписНе можна змінити після buildМожна змінити при кожному run
Use caseПараметризація збірки (версії, URLs)Конфігурація застосунку
БезпекаЗначення зберігаються у image historyЗначення не зберігаються у образі

ARG: Build-time змінні

ARG використовується для параметризації процесу збірки. Значення доступні лише під час виконання інструкцій Dockerfile.

Приклад:

# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build

# Оголосити ARG
ARG BUILD_CONFIGURATION=Release
ARG DOTNET_VERSION=8.0

WORKDIR /src
COPY ["MyApp.csproj", "./"]
RUN dotnet restore

COPY . .
# Використати ARG у команді
RUN dotnet build -c $BUILD_CONFIGURATION -o /app/build

FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}
WORKDIR /app
COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Побудова з різними значеннями:

# Використати значення за замовчуванням (Release)
docker build -t myapp:latest .

# Перезаписати на Debug
docker build --build-arg BUILD_CONFIGURATION=Debug -t myapp:debug .

# Змінити версію .NET
docker build --build-arg DOTNET_VERSION=8.0-alpine -t myapp:alpine .

Що відбувається:

  1. ARG BUILD_CONFIGURATION=Release — оголошує змінну зі значенням за замовчуванням
  2. --build-arg BUILD_CONFIGURATION=Debug — перезаписує значення при побудові
  3. $BUILD_CONFIGURATION — використовується у RUN команді
  4. Після завершення docker build змінна зникає — вона не доступна у контейнері

Коли використовувати ARG:

  • Версії залежностей (Node.js version, .NET SDK version)
  • Build configuration (Debug vs Release)
  • URLs для завантаження артефактів під час збірки
  • Параметри компіляції

ENV: Runtime змінні

ENV встановлює змінні, що доступні під час виконання контейнера.

Приклад:

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app

# Встановити ENV
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://0.0.0.0:5000

COPY . .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Запуск:

# Використати значення за замовчуванням
docker run myapp:latest
# ASPNETCORE_ENVIRONMENT=Production

# Перезаписати при запуску
docker run -e ASPNETCORE_ENVIRONMENT=Development myapp:latest
# ASPNETCORE_ENVIRONMENT=Development

Що відбувається:

  1. ENV встановлює змінну у образі
  2. Змінна доступна у всіх контейнерах з цього образу
  3. Змінну можна перезаписати через -e при кожному docker run

Комбінування ARG та ENV

Іноді потрібно передати значення з build-time у runtime. Для цього використовують ARG → ENV:

# Оголосити ARG
ARG APP_VERSION=1.0.0
ARG BUILD_DATE

# Передати значення у ENV
ENV APP_VERSION=${APP_VERSION}
ENV BUILD_DATE=${BUILD_DATE}

# Тепер APP_VERSION доступна у контейнері
LABEL version="${APP_VERSION}"
LABEL build-date="${BUILD_DATE}"

Побудова:

docker build \
  --build-arg APP_VERSION=1.2.3 \
  --build-arg BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
  -t myapp:1.2.3 .

Перевірка у контейнері:

docker run myapp:1.2.3 printenv APP_VERSION
# Вивід: 1.2.3
Безпека ARG: Значення ARG зберігаються у image history та доступні через docker history. Ніколи не передавайте секрети через ARG!
# Погано: секрет у ARG
docker build --build-arg API_KEY=secret123 .

# Перевірити history
docker history myapp:latest
# Вивід покаже: ARG API_KEY=secret123
Для секретів використовуйте BuildKit secrets (просунута тема) або передавайте через ENV при runtime.

Конфігурація .NET через змінні оточення

Як ASP.NET Core читає змінні оточення

ASP.NET Core має вбудовану підтримку змінних оточення через Configuration API. Змінні автоматично читаються та об'єднуються з appsettings.json.

Порядок пріоритету (від найнижчого до найвищого):

  1. appsettings.json
  2. appsettings.{Environment}.json (наприклад, appsettings.Production.json)
  3. User Secrets (лише у Development)
  4. Змінні оточення ← найвищий пріоритет
  5. Command-line arguments

Це означає: Змінна оточення завжди перезаписує значення з appsettings.json.

Синтаксис для вкладених секцій

Проблема: JSON має вкладену структуру, а змінні оточення — плоскі рядки.

appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Database=myapp"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Features": {
    "EnableNewUI": false
  }
}

Як перезаписати через змінні оточення:

ASP.NET Core використовує подвійне підкреслення __ як роздільник для вкладених секцій:

docker run -d \
  -e ConnectionStrings__DefaultConnection="Host=prod-db;Database=myapp;Username=postgres;Password=secret" \
  -e Logging__LogLevel__Default="Debug" \
  -e Logging__LogLevel__Microsoft.AspNetCore="Information" \
  -e Features__EnableNewUI="true" \
  myapp:latest

Правило: Section__Subsection__Key{ "Section": { "Subsection": { "Key": "value" } } }

Приклад: Конфігурація PostgreSQL

appsettings.json (значення за замовчуванням для Development):

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=myapp_dev;Username=postgres;Password=dev"
  }
}

Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app

# Не встановлюємо ConnectionString у ENV — це секрет!
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://0.0.0.0:5000

COPY . .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Запуск у Production:

docker run -d \
  --name myapp \
  -e ConnectionStrings__DefaultConnection="Host=prod-db.aws.com;Port=5432;Database=myapp_prod;Username=app_user;Password=${DB_PASSWORD}" \
  -p 5000:5000 \
  myapp:latest

Що відбулося:

  1. Застосунок читає appsettings.jsonHost=localhost
  2. Застосунок читає змінну оточення ConnectionStrings__DefaultConnectionперезаписує на Host=prod-db.aws.com
  3. Фінальне значення: Host=prod-db.aws.com;Port=5432;Database=myapp_prod;...

Читання конфігурації у C# коді

Program.cs (ASP.NET Core 8):

var builder = WebApplication.CreateBuilder(args);

// Конфігурація автоматично читається з appsettings.json + змінних оточення
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
Console.WriteLine($"Using database: {connectionString}");

// Читання інших секцій
var logLevel = builder.Configuration["Logging:LogLevel:Default"];
var enableNewUI = builder.Configuration.GetValue<bool>("Features:EnableNewUI");

// Strongly-typed конфігурація через Options pattern
builder.Services.Configure<FeaturesOptions>(
    builder.Configuration.GetSection("Features")
);

var app = builder.Build();
app.Run();

FeaturesOptions.cs:

public class FeaturesOptions
{
    public bool EnableNewUI { get; set; }
    public bool EnableBetaFeatures { get; set; }
}

Використання у контролері:

[ApiController]
[Route("api/[controller]")]
public class ConfigController : ControllerBase
{
    private readonly IConfiguration _configuration;
    private readonly IOptions<FeaturesOptions> _features;

    public ConfigController(
        IConfiguration configuration,
        IOptions<FeaturesOptions> features)
    {
        _configuration = configuration;
        _features = features;
    }

    [HttpGet("info")]
    public IActionResult GetInfo()
    {
        return Ok(new
        {
            Environment = _configuration["ASPNETCORE_ENVIRONMENT"],
            Database = _configuration.GetConnectionString("DefaultConnection")?.Split(';')[0], // Лише Host
            EnableNewUI = _features.Value.EnableNewUI
        });
    }
}

Перевірка:

curl http://localhost:5000/api/config/info

# Вивід:
# {
#   "environment": "Production",
#   "database": "Host=prod-db.aws.com",
#   "enableNewUI": true
# }

Options Pattern: strongly-typed конфігурація

Options Pattern — це рекомендований спосіб роботи з конфігурацією у .NET. Замість прямого читання через IConfiguration["Key"], ви створюєте POCO класи, що представляють секції конфігурації.

Переваги:

  1. Type safety — compile-time перевірка типів
  2. IntelliSense — автодоповнення у IDE
  3. Валідація — можна додати Data Annotations
  4. Тестування — легко створити mock об'єкти
  5. Рефакторинг — зміна імені властивості оновлює всі використання

Приклад з валідацією:

using System.ComponentModel.DataAnnotations;

public class DatabaseOptions
{
    public const string SectionName = "Database";

    [Required]
    [MinLength(1)]
    public string Host { get; set; } = "localhost";

    [Range(1, 65535)]
    public int Port { get; set; } = 5432;

    [Required]
    public string Database { get; set; } = string.Empty;

    [Required]
    public string Username { get; set; } = string.Empty;

    [Required]
    public string Password { get; set; } = string.Empty;

    public string GetConnectionString()
    {
        return $"Host={Host};Port={Port};Database={Database};Username={Username};Password={Password}";
    }
}

Реєстрація з валідацією:

builder.Services.AddOptions<DatabaseOptions>()
    .Bind(builder.Configuration.GetSection(DatabaseOptions.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart(); // Валідація при старті застосунку

appsettings.json:

{
  "Database": {
    "Host": "localhost",
    "Port": 5432,
    "Database": "myapp",
    "Username": "postgres",
    "Password": "dev"
  }
}

Перезапис через змінні оточення:

docker run -d \
  -e Database__Host=prod-db.aws.com \
  -e Database__Port=5432 \
  -e Database__Database=myapp_prod \
  -e Database__Username=app_user \
  -e Database__Password=secret123 \
  myapp:latest

Використання у сервісі:

public class DatabaseService
{
    private readonly DatabaseOptions _options;

    public DatabaseService(IOptions<DatabaseOptions> options)
    {
        _options = options.Value;
    }

    public async Task ConnectAsync()
    {
        var connectionString = _options.GetConnectionString();
        // Підключитися до бази даних
    }
}

IOptions vs IOptionsSnapshot vs IOptionsMonitor:

ІнтерфейсLifetimeReloadUse Case
IOptions<T>Singleton❌ НіКонфігурація, що не змінюється
IOptionsSnapshot<T>Scoped✅ Так (per request)Web API, конфігурація може змінюватися
IOptionsMonitor<T>Singleton✅ Так (real-time)Background services, real-time reload

Приклад з IOptionsSnapshot:

[ApiController]
[Route("api/[controller]")]
public class ConfigController : ControllerBase
{
    private readonly IOptionsSnapshot<FeaturesOptions> _features;

    public ConfigController(IOptionsSnapshot<FeaturesOptions> features)
    {
        _features = features;
    }

    [HttpGet("features")]
    public IActionResult GetFeatures()
    {
        // Значення оновлюються при кожному request
        return Ok(_features.Value);
    }
}
Best Practice: Використовуйте Options pattern для strongly-typed конфігурації замість прямого читання через IConfiguration["Key"]. Це дає IntelliSense, compile-time перевірку, валідацію та легше тестування.

Docker Secrets: безпечне управління секретами

Проблема секретів у змінних оточення

Змінні оточення, передані через -e, видимі у кількох місцях:

  1. docker inspect — показує всі змінні контейнера
  2. docker ps (іноді) — може показувати команду з -e
  3. Логи — якщо застосунок логує змінні
  4. Process listps aux на хості може показати змінні

Демонстрація проблеми:

# Запустити з секретом у змінній
docker run -d \
  --name myapp \
  -e DATABASE_PASSWORD=supersecret123 \
  myapp:latest

# Подивитися секрет через inspect
docker inspect myapp | grep DATABASE_PASSWORD
# Вивід: "DATABASE_PASSWORD=supersecret123"

Будь-хто з доступом до Docker daemon може побачити секрет.

Рішення: Docker Secrets (Docker Swarm)

Docker Secrets — це механізм для безпечного зберігання секретів у Docker Swarm (orchestration platform). Секрети:

  • Зберігаються зашифровано у Swarm
  • Передаються у контейнер як файли у /run/secrets/
  • Не видимі через docker inspect
  • Доступні лише контейнерам, яким явно надано доступ

Обмеження: Docker Secrets працює лише у Docker Swarm mode. Для standalone Docker (без Swarm) є альтернативи.

Альтернатива для standalone Docker: Secrets як файли

Ми можемо імітувати Docker Secrets через монтування файлів:

Крок 1: Створити файл з секретом

echo "supersecret123" > db_password.txt
chmod 600 db_password.txt  # Лише власник може читати

Крок 2: Змонтувати файл у контейнер

docker run -d \
  --name myapp \
  -v $(pwd)/db_password.txt:/run/secrets/db_password:ro \
  myapp:latest

Крок 3: Читати секрет з файлу у C# коді

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Читати секрет з файлу
        var dbPasswordPath = "/run/secrets/db_password";
        string dbPassword = null;

        if (File.Exists(dbPasswordPath))
        {
            dbPassword = File.ReadAllText(dbPasswordPath).Trim();
            Console.WriteLine("Database password loaded from secret file");
        }
        else
        {
            // Fallback на змінну оточення (для Development)
            dbPassword = builder.Configuration["DATABASE_PASSWORD"];
            Console.WriteLine("Database password loaded from environment variable");
        }

        // Побудувати connection string
        var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
        connectionString = connectionString.Replace("{PASSWORD}", dbPassword);

        builder.Services.AddDbContext<AppDbContext>(options =>
            options.UseNpgsql(connectionString)
        );

        var app = builder.Build();
        app.Run();
    }
}

appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=prod-db;Database=myapp;Username=postgres;Password={PASSWORD}"
  }
}

Переваги:

  • Секрет не видимий через docker inspect
  • Файл може мати обмежені permissions (600)
  • Легко інтегрується з secrets managers (AWS Secrets Manager, HashiCorp Vault)

Недоліки:

  • Потрібен додатковий код для читання файлів
  • Файл все ще на диску хоста (хоч і з обмеженими permissions)
Production рекомендація: Використовуйте secrets managers (AWS Secrets Manager, Azure Key Vault, HashiCorp Vault) замість файлів на диску. Ці сервіси надають шифрування, ротацію секретів, audit logs та централізоване управління.

Docker Swarm Secrets: повноцінне рішення

Якщо ви використовуєте Docker Swarm, ви можете скористатися вбудованим механізмом секретів.

Крок 1: Ініціалізувати Swarm

docker swarm init

Крок 2: Створити секрет

# З файлу
echo "supersecret123" | docker secret create db_password -

# Або з stdin
docker secret create db_password db_password.txt

Крок 3: Створити сервіс з секретом

docker service create \
  --name myapp \
  --secret db_password \
  -e ASPNETCORE_ENVIRONMENT=Production \
  -p 5000:5000 \
  myapp:latest

Що відбувається:

  1. Секрет зберігається зашифровано у Swarm Raft store
  2. Секрет монтується у контейнер як файл /run/secrets/db_password
  3. Лише контейнери з явним доступом можуть читати секрет
  4. Секрет не видимий через docker inspect

Читання у C# коді:

var dbPasswordPath = "/run/secrets/db_password";
if (File.Exists(dbPasswordPath))
{
    var dbPassword = File.ReadAllText(dbPasswordPath).Trim();
    // Використати пароль
}

Переваги Docker Swarm Secrets:

  • Шифрування at-rest та in-transit
  • Централізоване управління
  • Audit logs
  • Ротація секретів без rebuild образу

Недоліки:

  • Потрібен Docker Swarm (не працює у standalone mode)
  • Додаткова складність для простих проєктів

Практичний приклад: Конфігурування C# API

Архітектура проєкту

Створимо ASP.NET Core Web API з підключенням до PostgreSQL, що коректно конфігурується для різних середовищ через змінні оточення.

Структура проєкту:

MyApp/
├── MyApp.csproj
├── Program.cs
├── appsettings.json
├── appsettings.Development.json
├── Controllers/
│   └── WeatherController.cs
├── Data/
│   └── AppDbContext.cs
├── Dockerfile
├── .dockerignore
├── .env.example
└── docker-compose.yml

appsettings.json — значення за замовчуванням

{
  "ConnectionStrings": {
    "DefaultConnection": "Host=localhost;Port=5432;Database=myapp_dev;Username=postgres;Password=dev"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Information"
    }
  },
  "Features": {
    "EnableSwagger": true,
    "EnableDetailedErrors": false,
    "MaxItemsPerPage": 50
  },
  "AllowedHosts": "*"
}

Принцип: Файл містить безпечні значення за замовчуванням для Development. Секрети та production-конфігурація передаються через змінні оточення.

Program.cs — читання конфігурації

using Microsoft.EntityFrameworkCore;
using MyApp.Data;

var builder = WebApplication.CreateBuilder(args);

// Читання connection string (може бути перезаписаний через змінну оточення)
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

// Додати DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(connectionString));

// Strongly-typed конфігурація
builder.Services.Configure<FeaturesOptions>(
    builder.Configuration.GetSection("Features"));

// Додати контролери
builder.Services.AddControllers();

// Swagger (умовно, залежно від Features:EnableSwagger)
var enableSwagger = builder.Configuration.GetValue<bool>("Features:EnableSwagger");
if (enableSwagger)
{
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
}

var app = builder.Build();

// Middleware
if (app.Environment.IsDevelopment() || enableSwagger)
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

// Логування конфігурації при старті (без секретів!)
var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName);
logger.LogInformation("Database Host: {Host}", 
    connectionString?.Split(';').FirstOrDefault(s => s.StartsWith("Host="))?.Split('=')[1]);
logger.LogInformation("Swagger Enabled: {Swagger}", enableSwagger);

app.Run();

FeaturesOptions.cs:

namespace MyApp;

public class FeaturesOptions
{
    public bool EnableSwagger { get; set; }
    public bool EnableDetailedErrors { get; set; }
    public int MaxItemsPerPage { get; set; }
}

Dockerfile — production-ready

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src

# Копіювати .csproj та restore (окремий шар для кешування)
COPY ["MyApp.csproj", "./"]
RUN dotnet restore

# Копіювати весь код та зібрати
COPY . .
RUN dotnet build -c Release -o /app/build

# Publish
RUN dotnet publish -c Release -o /app/publish --no-restore

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:8.0-alpine
WORKDIR /app

# Створити non-root користувача
RUN addgroup -g 1000 appuser && \
    adduser -D -u 1000 -G appuser appuser

# Копіювати артефакти
COPY --from=build /app/publish .

# Змінити власника
RUN chown -R appuser:appuser /app

# Перемкнутися на non-root
USER appuser

# Змінні за замовчуванням (НЕ секрети!)
ENV ASPNETCORE_ENVIRONMENT=Production
ENV ASPNETCORE_URLS=http://0.0.0.0:5000

EXPOSE 5000

ENTRYPOINT ["dotnet", "MyApp.dll"]

.env.example — шаблон для команди

# Environment
ASPNETCORE_ENVIRONMENT=Development

# Database (замініть на реальні значення)
ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=myapp;Username=postgres;Password=YOUR_PASSWORD_HERE

# Logging
Logging__LogLevel__Default=Information
Logging__LogLevel__Microsoft.AspNetCore=Warning

# Features
Features__EnableSwagger=true
Features__EnableDetailedErrors=false
Features__MaxItemsPerPage=50

docker-compose.yml — локальна розробка

version: '3.8'

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: dev
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  api:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "5000:5000"
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ConnectionStrings__DefaultConnection=Host=db;Port=5432;Database=myapp;Username=postgres;Password=dev
      - Features__EnableSwagger=true
      - Features__EnableDetailedErrors=true
    depends_on:
      db:
        condition: service_healthy

volumes:
  postgres-data:

Запуск у різних середовищах

Development (локально з docker-compose):

# Запустити всі сервіси
docker compose up -d

# Перевірити логи
docker compose logs api

# Відкрити Swagger
open http://localhost:5000/swagger

Staging (окремий сервер):

# Створити .env файл для staging
cat > .env.staging << 'ENVFILE'
ASPNETCORE_ENVIRONMENT=Staging
ConnectionStrings__DefaultConnection=Host=staging-db.internal;Port=5432;Database=myapp_staging;Username=app_user;Password=staging_password_123
Logging__LogLevel__Default=Debug
Features__EnableSwagger=true
Features__EnableDetailedErrors=true
ENVFILE

# Запустити контейнер
docker run -d \
  --name myapp-staging \
  --env-file .env.staging \
  -p 5000:5000 \
  myapp:latest

Production (AWS/Azure):

# Секрети передаються через змінні оточення з secrets manager
docker run -d \
  --name myapp-prod \
  -e ASPNETCORE_ENVIRONMENT=Production \
  -e ConnectionStrings__DefaultConnection="Host=${DB_HOST};Port=5432;Database=myapp_prod;Username=${DB_USER};Password=${DB_PASSWORD}" \
  -e Logging__LogLevel__Default=Warning \
  -e Features__EnableSwagger=false \
  -e Features__EnableDetailedErrors=false \
  -p 5000:5000 \
  myapp:latest

Де ${DB_HOST}, ${DB_USER}, ${DB_PASSWORD} — змінні з AWS Secrets Manager або Azure Key Vault.


Best Practices

1. Ніколи не хардкодьте секрети

Погано:

ENV DATABASE_PASSWORD=supersecret123
{
  "ConnectionStrings": {
    "DefaultConnection": "Host=prod-db;Password=supersecret123"
  }
}

Добре:

docker run -e DATABASE_PASSWORD=${DB_PASSWORD} myapp:latest

2. Використовуйте .env.example для документації

Створіть .env.example з усіма необхідними змінними (без реальних значень):

# .env.example
ASPNETCORE_ENVIRONMENT=Development
ConnectionStrings__DefaultConnection=Host=localhost;Database=myapp;Username=postgres;Password=YOUR_PASSWORD
API_KEY=YOUR_API_KEY_HERE

Додайте .env до .gitignore:

.env
.env.local
.env.*.local

3. Валідуйте конфігурацію при старті

var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");

if (string.IsNullOrEmpty(connectionString))
{
    throw new InvalidOperationException(
        "ConnectionStrings:DefaultConnection is not configured. " +
        "Set ConnectionStrings__DefaultConnection environment variable.");
}

4. Логуйте конфігурацію (без секретів!)

var logger = app.Services.GetRequiredService<ILogger<Program>>();
logger.LogInformation("Environment: {Env}", app.Environment.EnvironmentName);
logger.LogInformation("Database: {Host}", GetDatabaseHost(connectionString));
// НЕ логуйте паролі, API keys, tokens!

5. Використовуйте Options Pattern

// Замість
var maxItems = builder.Configuration.GetValue<int>("Features:MaxItemsPerPage");

// Використовуйте
builder.Services.Configure<FeaturesOptions>(
    builder.Configuration.GetSection("Features"));

// У контролері
public class MyController : ControllerBase
{
    private readonly FeaturesOptions _features;
    
    public MyController(IOptions<FeaturesOptions> features)
    {
        _features = features.Value;
    }
}

6. Різні конфігурації для різних середовищ

СередовищеSwaggerDetailed ErrorsLog Level
Development✅ Enabled✅ EnabledDebug
Staging✅ Enabled✅ EnabledInformation
Production❌ Disabled❌ DisabledWarning

Передавайте через змінні оточення:

# Development
-e Features__EnableSwagger=true -e Features__EnableDetailedErrors=true

# Production
-e Features__EnableSwagger=false -e Features__EnableDetailedErrors=false

Резюме

Ключові концепції:

  1. 12-Factor App — "Store config in the environment". Конфігурація має бути за межами коду та передаватися через змінні оточення.
  2. Один образ — багато конфігурацій — той самий Docker-образ використовується у development, staging та production з різними змінними оточення.
  3. ARG vs ENV — ARG для build-time параметризації, ENV для runtime конфігурації.
  4. ASP.NET Core конфігурація — використовуйте __ (подвійне підкреслення) для вкладених секцій: ConnectionStrings__DefaultConnection.
  5. Безпека секретів — ніколи не хардкодьте секрети у Dockerfile або appsettings.json. Передавайте через -e, --env-file або Docker Secrets.

Способи передачі змінних:

СпосібUse CaseБезпека
-e VAR=valueОдна-дві змінні⚠️ Видно у docker inspect
--env-file .envБагато змінних⚠️ Файл на диску
ENV у DockerfileЗначення за замовчуванням✅ Для non-sensitive
Docker SecretsProduction секрети✅ Зашифровано
Secrets ManagerEnterprise production✅ Централізовано, ротація

Best Practices:

  • Використовуйте .env.example для документації
  • Додайте .env до .gitignore
  • Валідуйте конфігурацію при старті
  • Логуйте конфігурацію (без секретів)
  • Використовуйте Options Pattern для strongly-typed конфігурації
  • Різні конфігурації для різних середовищ

Що далі:

У наступній статті ми розглянемо Docker Compose — інструмент для декларативного управління multi-container застосунками. Ви навчитеся описувати всю архітектуру (API + Database + Cache) у одному YAML-файлі та керувати нею однією командою.


Практичні завдання

Рівень 1: Базове розуміння

Завдання 1.1: Передача змінних через -e

Запустіть офіційний образ Nginx з кастомним портом через змінну оточення.

docker run -d \
  --name nginx-custom \
  -e NGINX_PORT=8080 \
  -p 8080:8080 \
  nginx:alpine

Перевірка:

docker exec nginx-custom printenv NGINX_PORT
# Має вивести: 8080

Завдання 1.2: Використання --env-file

Створіть файл .env з трьома змінними та запустіть контейнер Alpine, що виводить ці змінні.

Створити .env:

APP_NAME=MyApp
APP_VERSION=1.0.0
APP_ENV=Development

Запустити:

docker run --rm --env-file .env alpine sh -c 'echo "App: $APP_NAME v$APP_VERSION ($APP_ENV)"'

Очікуваний вивід:

App: MyApp v1.0.0 (Development)

Завдання 1.3: ENV у Dockerfile

Створіть Dockerfile з змінними за замовчуванням та перезапишіть їх при запуску.

Dockerfile:

FROM alpine:latest
ENV MESSAGE="Hello from Dockerfile"
ENV COUNT=5
CMD echo "$MESSAGE" && echo "Count: $COUNT"

Побудувати та запустити:

# З значеннями за замовчуванням
docker build -t env-test .
docker run env-test

# Перезаписати при запуску
docker run -e MESSAGE="Custom message" -e COUNT=10 env-test

Рівень 2: Практичне застосування

Завдання 2.1: Конфігурація .NET API

Створіть простий ASP.NET Core Web API, що читає конфігурацію з змінних оточення.

Вимоги:

  • Endpoint /api/config повертає поточну конфігурацію
  • Connection string передається через змінну оточення
  • Різні значення для Development та Production

Program.cs:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/api/config", (IConfiguration config) => new
{
    Environment = config["ASPNETCORE_ENVIRONMENT"],
    Database = config.GetConnectionString("DefaultConnection")?.Split(';')[0],
    LogLevel = config["Logging:LogLevel:Default"]
});

app.Run();

Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY bin/Release/net8.0/publish/ .
ENV ASPNETCORE_URLS=http://0.0.0.0:5000
ENTRYPOINT ["dotnet", "MyApp.dll"]

Запустити з різними конфігураціями:

# Development
docker run -d -p 5000:5000 \
  -e ASPNETCORE_ENVIRONMENT=Development \
  -e ConnectionStrings__DefaultConnection="Host=localhost;Database=dev" \
  myapp:latest

# Production
docker run -d -p 5001:5000 \
  -e ASPNETCORE_ENVIRONMENT=Production \
  -e ConnectionStrings__DefaultConnection="Host=prod-db;Database=prod" \
  myapp:latest

Завдання 2.2: ARG для параметризації збірки

Створіть Dockerfile, що приймає версію .NET як ARG.

Dockerfile:

ARG DOTNET_VERSION=8.0
FROM mcr.microsoft.com/dotnet/aspnet:${DOTNET_VERSION}

ARG BUILD_DATE
ARG VERSION=1.0.0

LABEL version="${VERSION}"
LABEL build-date="${BUILD_DATE}"

WORKDIR /app
COPY . .
ENTRYPOINT ["dotnet", "MyApp.dll"]

Побудувати з різними версіями:

# .NET 8.0
docker build --build-arg DOTNET_VERSION=8.0 --build-arg VERSION=1.0.0 -t myapp:8.0 .

# .NET 8.0 Alpine
docker build --build-arg DOTNET_VERSION=8.0-alpine --build-arg VERSION=1.0.1 -t myapp:8.0-alpine .

Завдання 2.3: Secrets через файли

Реалізуйте читання секретів з файлів замість змінних оточення.

Створити файл з секретом:

echo "supersecret123" > db_password.txt
chmod 600 db_password.txt

C# код для читання:

var dbPasswordPath = "/run/secrets/db_password";
string dbPassword = File.Exists(dbPasswordPath) 
    ? File.ReadAllText(dbPasswordPath).Trim()
    : builder.Configuration["DATABASE_PASSWORD"];

Запустити:

docker run -d \
  -v $(pwd)/db_password.txt:/run/secrets/db_password:ro \
  myapp:latest

Рівень 3: Production-Ready конфігурація

Завдання 3.1: Multi-environment setup

Створіть повноцінний ASP.NET Core API з підтримкою трьох середовищ: Development, Staging, Production.

Вимоги:

  • Різні connection strings для кожного середовища
  • Swagger увімкнено лише у Development та Staging
  • Detailed errors лише у Development
  • Різні рівні логування
  • Валідація конфігурації при старті

Створити три .env файли:

  • .env.development
  • .env.staging
  • .env.production

Запустити з різними конфігураціями:

docker run -d --env-file .env.development -p 5000:5000 myapp:latest
docker run -d --env-file .env.staging -p 5001:5000 myapp:latest
docker run -d --env-file .env.production -p 5002:5000 myapp:latest

Завдання 3.2: Інтеграція з Secrets Manager

Створіть скрипт, що завантажує секрети з AWS Secrets Manager та передає у Docker.

get-secrets.sh:

#!/bin/bash
DB_PASSWORD=$(aws secretsmanager get-secret-value --secret-id prod/db/password --query SecretString --output text)
API_KEY=$(aws secretsmanager get-secret-value --secret-id prod/api/key --query SecretString --output text)

docker run -d \
  -e DATABASE_PASSWORD="$DB_PASSWORD" \
  -e API_KEY="$API_KEY" \
  myapp:latest

Завдання 3.3: Конфігурація через docker-compose

Створіть docker-compose.yml з підтримкою різних середовищ через override files.

docker-compose.yml (базова конфігурація):

version: '3.8'
services:
  api:
    image: myapp:latest
    environment:
      - ASPNETCORE_ENVIRONMENT=${ENV:-Development}

docker-compose.override.yml (Development):

version: '3.8'
services:
  api:
    environment:
      - ConnectionStrings__DefaultConnection=Host=db;Database=dev
      - Features__EnableSwagger=true

docker-compose.prod.yml (Production):

version: '3.8'
services:
  api:
    environment:
      - ConnectionStrings__DefaultConnection=${DB_CONNECTION_STRING}
      - Features__EnableSwagger=false

Запуск:

# Development
docker compose up -d

# Production
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

Підказка: Для завдань рівня 3 використовуйте приклади з розділу "Практичний приклад" як основу. Комбінуйте різні техніки (ENV, ARG, secrets, validation) для створення production-ready конфігурації.