Урок 32: Docker — multi-stage, distroless, docker-compose

Урок 32. Docker — multi-stage, distroless, docker-compose

🔄 Node.js → Go (ключевые отличия):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-docker && cd go-docker
go mod init go-docker

# Создаём простое HTTP-приложение
mkdir -p cmd/server

💻 Файл: cmd/server/main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"
)

// AppInfo — информация о приложении
type AppInfo struct {
    Service   string `json:"service"`
    Version   string `json:"version"`
    Hostname  string `json:"hostname"`
    Time      string `json:"time"`
}

var version = "dev" // Заполняется при сборке через -ldflags

func main() {
    log.SetFlags(log.LstdFlags | log.Lmicroseconds)

    hostname, _ := os.Hostname()

    mux := http.NewServeMux()

    // Health check
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    // Info
    mux.HandleFunc("GET /info", func(w http.ResponseWriter, r *http.Request) {
        info := AppInfo{
            Service:  "go-docker-demo",
            Version:  version,
            Hostname: hostname,
            Time:     time.Now().Format(time.RFC3339),
        }
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(info)
    })

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    log.Printf("🚀 Сервер запущен на :%s (версия: %s)", port, version)

    if err := http.ListenAndServe(":"+port, mux); err != nil {
        log.Fatalf("Ошибка сервера: %v", err)
    }
}

🐳 Файл: Dockerfile (Multi-stage)

# ╔══════════════════════════════════════════════════════════╗
# ║  ЭТАП 1: СБОРКА (builder)                              ║
# ║  Используем полный образ Go с компилятором              ║
# ╚══════════════════════════════════════════════════════════╝
FROM golang:1.22-alpine AS builder

# Устанавливаем git (нужен для go mod)
RUN apk add --no-cache git ca-certificates

# Создаём рабочую директорию
WORKDIR /app

# Копируем ТОЛЬКО файлы зависимостей (кэширование слоёв!)
COPY go.mod go.sum ./
RUN go mod download && go mod verify

# Копируем исходный код
COPY . .

# Собираем бинарник
# CGO_ENABLED=0 — отключаем CGO (чистый Go, без libc)
# -ldflags="-w -s" — убираем отладочную информацию (уменьшаем размер)
# -ldflags="-X main.version=..." — встраиваем версию
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build \
    -ldflags="-w -s -X main.version=${VERSION}" \
    -o /app/server \
    ./cmd/server

# ╔══════════════════════════════════════════════════════════╗
# ║  ЭТАП 2: ФИНАЛЬНЫЙ ОБРАЗ (production)                  ║
# ║  Варианты (раскомментируйте нужный):                    ║
# ╚══════════════════════════════════════════════════════════╝

# --- ВАРИАНТ А: Distroless (рекомендовано, ~8MB) ---
FROM gcr.io/distroless/static-debian12:nonroot AS production
# nonroot — запускается от пользователя 65532 (не root!)

COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Healthcheck (curl в distroless нет, используем скрипт или Kubernetes probes)
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD ["/server"] || exit 1

EXPOSE 8080
USER nonroot
ENTRYPOINT ["/server"]


# --- ВАРИАНТ Б: Scratch (минимальный, ~3MB бинарник + образ) ---
# FROM scratch
# COPY --from=builder /app/server /server
# COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# EXPOSE 8080
# ENTRYPOINT ["/server"]


# --- ВАРИАНТ В: Alpine (если нужны shell-утилиты, ~12MB) ---
# FROM alpine:3.19
# RUN apk --no-cache add ca-certificates tzdata curl
# COPY --from=builder /app/server /server
# HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
#     CMD curl -f http://localhost:8080/health || exit 1
# EXPOSE 8080
# ENTRYPOINT ["/server"]


# --- ВАРИАНТ Г: Для разработки (Hot Reload через Air) ---
# FROM golang:1.22-alpine AS development
# RUN apk add --no-cache git
# RUN go install github.com/cosmtrek/air@latest
# WORKDIR /app
# COPY go.mod go.sum ./
# RUN go mod download
# CMD ["air", "-c", ".air.toml"]

🐳 Файл: docker-compose.yml

version: '3.9'

services:
  # ╔══════════════════════════════════════════════════════╗
  # ║  GO-СЕРВИС                                          ║
  # ╚══════════════════════════════════════════════════════╝
  app:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        VERSION: "1.0.0"
    image: go-docker-app:latest
    container_name: go-app
    ports:
      - "8080:8080"
    environment:
      - PORT=8080
      - DATABASE_URL=postgres://postgres:secret@postgres:5432/appdb?sslmode=disable
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    # Resource limits
    deploy:
      resources:
        limits:
          cpus: '0.5'
          memory: '128M'
        reservations:
          cpus: '0.1'
          memory: '64M'

  # ╔══════════════════════════════════════════════════════╗
  # ║  POSTGRESQL                                          ║
  # ╚══════════════════════════════════════════════════════╝
  postgres:
    image: postgres:16-alpine
    container_name: go-postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d  # Авто-миграции при первом запуске
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: '256M'

  # ╔══════════════════════════════════════════════════════╗
  # ║  REDIS                                                ║
  # ╚══════════════════════════════════════════════════════╝
  redis:
    image: redis:7-alpine
    container_name: go-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped
    deploy:
      resources:
        limits:
          memory: '64M'

  # ╔══════════════════════════════════════════════════════╗
  # ║  PROMETHEUS (опционально)                            ║
  # ╚══════════════════════════════════════════════════════╝
  prometheus:
    image: prom/prometheus:latest
    container_name: go-prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
      - prometheus_data:/prometheus
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:
  prometheus_data:

🐳 Файл: .dockerignore

# Бинарники
*.exe
*.exe~
*.dll
*.so
*.dylib
/server
/client

# Директории
.git/
.github/
.vscode/
.idea/
vendor/
tmp/
data/

# Файлы
*.test
*.out
*.prof
.env.local
.env.production
docker-compose.override.yml
README.md
Makefile

# Кэш Go
.cache/

🐳 Файл: prometheus.yml

global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'go-app'
    static_configs:
      - targets: ['app:8080']

🐳 Файл: Makefile

# ╔══════════════════════════════════════════════════════════╗
# ║  MAKEFILE — удобные команды для Docker                  ║
# ╚══════════════════════════════════════════════════════════╝

.PHONY: help build run stop clean logs lint test

help: ## Показать справку
	@grep -E '^[a-zA-Z_-]+:.*?## .*$' $(MAKEFILE_LIST) | sort | \
		awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $1, $2}'

build: ## Собрать Docker-образ
	docker build --build-arg VERSION=$(git describe --tags --always) -t go-docker-app:latest .

run: ## Запустить все сервисы
	docker-compose up -d

stop: ## Остановить все сервисы
	docker-compose down

restart: stop run ## Перезапустить все сервисы

logs: ## Показать логи
	docker-compose logs -f app

shell: ## Зайти в контейнер (если alpine)
	docker-compose exec app sh

clean: ## Очистить всё
	docker-compose down -v
	docker rmi go-docker-app:latest 2>/dev/null || true

lint: ## Линтер
	docker run --rm -v $(pwd):/app -w /app golangci/golangci-lint:latest golangci-lint run

test: ## Тесты
	go test -v -race -cover ./...

test-docker: ## Тесты в Docker
	docker-compose -f docker-compose.test.yml up --abort-on-container-exit --exit-code-from tests

🚀 Запуск

# Сборка Docker-образа
docker build -t go-docker-app:latest .

# Сборка с версией
docker build --build-arg VERSION=1.0.0 -t go-docker-app:1.0.0 .

# Размер образа (distroless)
docker images go-docker-app

# Запуск через docker-compose
docker-compose up -d

# Проверка
curl http://localhost:8080/health
curl http://localhost:8080/info

# Логи
docker-compose logs -f app

# Остановка
docker-compose down

# Остановка с удалением томов
docker-compose down -v

📊 Сравнение размеров образов

Базовый образРазмер бинарникаРазмер образаБезопасность
Scratch~3 MB~3 MBМаксимальная (нет ничего лишнего)
Distroless~3 MB~8 MBВысокая (ca-certificates, nonroot)
Alpine~3 MB~12 MBСредняя (есть shell, пакеты)
Ubuntu/Debian~3 MB~100 MBНиже (много пакетов)
Node.js (сравнение)~150 MB (slim)
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →