From e86a866fb690a31c47d6b65c7962bf8f1213c73d Mon Sep 17 00:00:00 2001 From: ulflow_phattt2901 Date: Thu, 22 May 2025 11:15:19 +0700 Subject: [PATCH] chore: add Docker containerization and app initialization with lifecycle management --- .dockerignore | 62 ++++++++++++++++++++++++++ Dockerfile | 94 ++++++++++++++++++++++++++++----------- cmd/app/main.go | 28 ++++++++++-- docs/review.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 30 deletions(-) create mode 100644 .dockerignore create mode 100644 docs/review.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da3acec --- /dev/null +++ b/.dockerignore @@ -0,0 +1,62 @@ +# Version control +.git +.gitignore +.gitattributes + +# Build artifacts +/bin/ +/dist/ + +# IDE specific files +.vscode/ +.idea/ +*.swp +*.swo + +# Environment files (except .env.example) +.env +!.env.example + +# Log files +*.log +logs/ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Test files +*_test.go +/test/ +/coverage.txt + +# Dependency directories +/vendor/ +/node_modules/ + +# Local development files +docker-compose.override.yml + +# Build cache +.cache/ +.air.toml + +# Temporary files +*.tmp +*.temp + +# Local configuration overrides +configs/local.* + +# Documentation +/docs/ +*.md + +# Ignore Dockerfile and docker-compose files (if you're building from source) +# !Dockerfile +# !docker-compose*.yml diff --git a/Dockerfile b/Dockerfile index 2f923a5..30bdcf2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,54 +1,96 @@ -# Dockerfile +# syntax=docker/dockerfile:1.4 # Production-ready multi-stage build for server deployment -# Build stage -FROM golang:1.23-alpine AS builder +# Build arguments +ARG GO_VERSION=1.23.6 +ARG ALPINE_VERSION=3.19 +ARG APP_USER=appuser +ARG APP_GROUP=appgroup -# Install necessary build tools +# --- Builder Stage --- +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION}-alpine AS builder + +# Install build dependencies RUN apk add --no-cache git make gcc libc-dev # Set working directory WORKDIR /build -# Copy go.mod and go.sum files -COPY go.mod go.sum* ./ +# Enable Go modules +ENV GO111MODULE=on \ + CGO_ENABLED=0 \ + GOOS=linux \ + GOARCH=amd64 + +# Copy dependency files first for better layer caching +COPY go.mod go.sum ./ + # Download dependencies -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download # Copy source code COPY . . + + # Create the configs directory in the build context RUN mkdir -p /build/configs && \ cp -r configs/* /build/configs/ 2>/dev/null || : # Build the application with optimizations -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /build/bin/app ./cmd/app +RUN --mount=type=cache,target=/root/.cache/go-build \ + go build -ldflags="-w -s -X 'main.Version=$(git describe --tags --always 2>/dev/null || echo 'dev')'" \ + -o /build/bin/app ./cmd/app -# Final stage -FROM alpine:3.19 +# --- Final Stage --- +FROM alpine:${ALPINE_VERSION} -# Add necessary runtime packages -RUN apk add --no-cache ca-certificates tzdata +# Re-declare ARG to use in this stage +ARG APP_USER=appuser +ARG APP_GROUP=appgroup +ARG APP_HOME=/app -# Set timezone -ENV TZ=Asia/Ho_Chi_Minh +# Set environment variables +ENV TZ=Asia/Ho_Chi_Minh \ + APP_USER=${APP_USER} \ + APP_GROUP=${APP_GROUP} \ + APP_HOME=${APP_HOME} -# Create non-root user -RUN adduser -D -g '' appuser +# Install runtime dependencies +RUN apk add --no-cache \ + ca-certificates \ + tzdata \ + tini \ + && rm -rf /var/cache/apk/* -# Create app directories -RUN mkdir -p /app/config /app/logs /app/storage -WORKDIR /app +# Create app user and group +RUN addgroup -S ${APP_GROUP} && \ + adduser -S -G ${APP_GROUP} -h ${APP_HOME} -D ${APP_USER} -# Copy binary and configs -COPY --from=builder /build/bin/app /app/ -COPY --from=builder /build/configs /app/configs -# Set ownership -RUN chown -R appuser:appuser /app +# Create necessary directories +RUN mkdir -p ${APP_HOME}/configs ${APP_HOME}/logs ${APP_HOME}/storage + +# Switch to app directory +WORKDIR ${APP_HOME} + +# Copy binary and configs from builder +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /build/bin/app . +COPY --from=builder --chown=${APP_USER}:${APP_GROUP} /build/configs ./configs/ + +# Set file permissions +RUN chmod +x ./app && \ + chown -R ${APP_USER}:${APP_GROUP} ${APP_HOME} + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1 # Switch to non-root user -USER appuser +USER ${APP_USER} + +# Default command +CMD ["./app"] # Expose port EXPOSE 3000 @@ -57,4 +99,4 @@ EXPOSE 3000 ENV APP_ENV=production # Command to run the application -ENTRYPOINT ["./app"] +ENTRYPOINT ["/sbin/tini", "--"] diff --git a/cmd/app/main.go b/cmd/app/main.go index 63a18fb..f68babe 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -32,13 +32,29 @@ func (s *HTTPService) Name() string { } func (s *HTTPService) Start() error { - // Start the server in a goroutine + // Tạo channel để nhận lỗi từ goroutine + errChan := make(chan error, 1) + + // Khởi động server trong goroutine go func() { + logger.Infof("Đang khởi động %s trên %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port) if err := s.server.Start(); err != nil { - logger.WithError(err).Error("HTTP server error") + logger.WithError(err).Error("Lỗi HTTP server") + errChan <- fmt.Errorf("lỗi HTTP server: %w", err) + return } + errChan <- nil }() - return nil + + // Chờ server khởi động hoặc báo lỗi + select { + case err := <-errChan: + return err // Trả về lỗi nếu có + case <-time.After(5 * time.Second): + // Nếu sau 5 giây không có lỗi, coi như server đã khởi động thành công + logger.Infof("%s đã khởi động thành công trên %s:%d", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port) + return nil + } } func (s *HTTPService) Shutdown(ctx context.Context) error { @@ -109,11 +125,15 @@ func main() { // Register HTTP service with the lifecycle manager httpService := NewHTTPService(cfg) + if httpService == nil { + logger.Fatal("Failed to create HTTP service") + } lifecycleMgr.Register(httpService) // Start all services + logger.Info("Đang khởi động các dịch vụ...") if err := lifecycleMgr.Start(); err != nil { - logger.WithError(err).Fatal("Failed to start services") + logger.WithError(err).Fatal("Lỗi nghiêm trọng: Không thể khởi động các dịch vụ") } // Handle OS signals for graceful shutdown in a separate goroutine diff --git a/docs/review.md b/docs/review.md new file mode 100644 index 0000000..2962122 --- /dev/null +++ b/docs/review.md @@ -0,0 +1,116 @@ +# Đánh giá file `main.go`: Điểm bất thường và Đề xuất cải thiện + +Dựa trên việc xem xét file `main.go` và các log liên quan, chúng ta đã xác định một số điểm trong thiết kế có thể dẫn đến hành vi khởi động không ổn định và log khó hiểu. + +## I. Các điểm bất thường đã xác định + +1. **`HTTPService.Start()` trả về ngay lập tức và che giấu lỗi khởi động Server:** + * **Mô tả:** Hàm `HTTPService.Start()` khởi chạy server HTTP (ví dụ: `s.server.Start()` của Gin) trong một goroutine riêng biệt và ngay lập tức trả về `nil` cho `lifecycleMgr`. + ```go + // Trong HTTPService.Start() + go func() { + if err := s.server.Start(); err != nil { + logger.WithError(err).Error("HTTP server error") + } + }() + return nil // HTTPService.Start() trả về nil ngay + ``` + * **Hậu quả:** + * `lifecycleMgr` nhận được `nil` và cho rằng `HTTPService` đã khởi động thành công, ngay cả khi `s.server.Start()` trong goroutine gặp lỗi nghiêm trọng (ví dụ: cổng đã được sử dụng, lỗi cấu hình server) ngay sau đó. + * Lỗi thực sự của việc khởi động server HTTP không được truyền trở lại cho `lifecycleMgr`, khiến việc quản lý vòng đời và chẩn đoán lỗi trở nên khó khăn. + +2. **Log "Application stopped" có thể xuất hiện không đúng thứ tự hoặc quá sớm:** + * **Mô tả:** Dòng log `logger.Info("Application stopped")` nằm ở cuối hàm `main()` và chỉ được thực thi sau khi `lifecycleMgr.Wait()` kết thúc. + * **Hậu quả:** + * Do `HTTPService.Start()` trả về ngay và goroutine của server có thể lỗi và thoát một cách âm thầm, `lifecycleMgr.Wait()` có thể kết thúc sớm hơn dự kiến. + * Điều này dẫn đến "race condition" trong logging: log "Application stopped" có thể xuất hiện trước cả các log từ bên trong `s.server.Start()` (như "Starting HTTP server"), gây nhầm lẫn khi phân tích log. + +3. **`lifecycleMgr` không nhận biết được trạng thái lỗi thực sự của HTTP Server:** + * **Mô tả:** Vì `HTTPService.Start()` không phản ánh lỗi thực sự của việc khởi động server, `lifecycleMgr` không có thông tin chính xác về việc liệu `HTTPService` có đang hoạt động hay không. + * **Hậu quả:** + * Ứng dụng có thể "chết yểu" ở lần thử khởi động đầu tiên mà không có lỗi rõ ràng từ `lifecycleMgr`. + * Điều này có thể khiến các cơ chế bên ngoài (như Docker health checks hoặc trình giám sát tiến trình) phải khởi động lại container nhiều lần cho đến khi một lần khởi động ngẫu nhiên thành công (nếu lỗi mang tính tạm thời). + +## II. Đề xuất cải thiện + +Mục tiêu chính của các đề xuất này là đảm bảo rằng trạng thái khởi động thực sự của các service (đặc biệt là HTTP server) được phản ánh chính xác cho `lifecycleMgr`, giúp việc quản lý vòng đời và gỡ lỗi hiệu quả hơn. + +1. **Cải thiện `HTTPService.Start()` để phản ánh trạng thái khởi động thực tế:** + + * **Phương án 1: Biến `HTTPService.Start()` thành hàm Blocking (Ưu tiên nếu phù hợp):** + * Nếu `s.server.Start()` (ví dụ: `gin.Engine.Run()`) là một hàm blocking, hãy để `HTTPService.Start()` gọi trực tiếp nó mà không cần goroutine riêng. + * Nếu `s.server.Start()` trả về lỗi, `HTTPService.Start()` cũng phải trả về lỗi đó. + ```go + // Đề xuất cho HTTPService.Start() + func (s *HTTPService) Start() error { + logger.Infof("Attempting to start %s on %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port) + // Giả sử s.server.Start() là blocking và trả về lỗi nếu không khởi động được + if err := s.server.Start(); err != nil { + // Lỗi này sẽ được lifecycleMgr bắt và xử lý + return fmt.Errorf("failed to start %s: %w", s.Name(), err) + } + // Nếu s.server.Start() trả về mà không lỗi (thường chỉ khi shutdown), + // điều này có thể không bao giờ đạt tới nếu nó block mãi mãi. + // Hoặc nếu nó được thiết kế để trả về nil khi shutdown thành công từ bên ngoài. + return nil + } + ``` + * `lifecycleMgr` có thể sẽ cần tự quản lý việc chạy các hàm `Start()` blocking này trong các goroutine riêng của nó. + + * **Phương án 2: Sử dụng Channel để giao tiếp trạng thái từ Goroutine:** + * Nếu bắt buộc phải giữ `s.server.Start()` trong goroutine bên trong `HTTPService.Start()`, hãy sử dụng một channel để goroutine đó báo cáo lại trạng thái (khởi động thành công hoặc lỗi) cho `HTTPService.Start()`. + * `HTTPService.Start()` sẽ đợi tín hiệu này và trả về lỗi tương ứng nếu có. + ```go + // Đề xuất cho HTTPService.Start() với channel + func (s *HTTPService) Start() error { + logger.Infof("Attempting to start %s on %s:%d...", s.Name(), s.cfg.Server.Host, s.cfg.Server.Port) + errChan := make(chan error, 1) // Buffered channel + + go func() { + // Gửi lỗi (hoặc nil nếu thành công) vào channel + // Cần đảm bảo s.server.Start() hoặc logic bao quanh nó có thể báo hiệu thành công sớm + // Ví dụ, nếu server có một callback "onListenSuccess" hoặc tương tự + err := s.server.Start() // Giả sử hàm này block cho đến khi có lỗi hoặc shutdown + if err != nil && err != stdHttp.ErrServerClosed { // stdHttp từ "net/http" + logger.WithError(err).Errorf("Error running %s in goroutine", s.Name()) + errChan <- fmt.Errorf("%s run error: %w", s.Name(), err) + return + } + logger.Infof("%s goroutine finished cleanly.", s.Name()) + errChan <- nil // Báo hiệu kết thúc không lỗi (hoặc shutdown bình thường) + }() + + // Đợi tín hiệu từ goroutine hoặc timeout + // Cần một cách để goroutine báo "đã sẵn sàng" thay vì chỉ đợi lỗi/kết thúc + // Ví dụ, server có thể gửi nil vào errChan ngay khi lắng nghe thành công. + // Đoạn code dưới đây là một ví dụ đơn giản hóa việc chờ lỗi khởi động ban đầu. + // Một giải pháp tốt hơn là server có cơ chế báo "ready". + select { + case err := <-errChan: + if err != nil { + return fmt.Errorf("failed to start %s (from channel): %w", s.Name(), err) + } + logger.Infof("%s reported successful start or clean exit via channel.", s.Name()) + return nil // Thành công hoặc goroutine đã kết thúc không lỗi nghiêm trọng ban đầu + case <-time.After(10 * time.Second): // Timeout nếu không có tín hiệu sớm + // Giả định rằng nếu không có lỗi ngay, server đang chạy. + // Đây là một giả định cần được kiểm chứng cẩn thận với hành vi của s.server.Start() + logger.Warnf("Timeout waiting for %s to signal start/error, assuming it's running or will manage its own lifecycle.", s.Name()) + return nil + } + } + ``` + **Lưu ý quan trọng cho Phương án 2:** Cần có một cơ chế rõ ràng để goroutine của server báo hiệu rằng nó đã **khởi động và lắng nghe thành công** (ví dụ: gửi `nil` vào `errChan` ngay khi `ListenAnServe` bắt đầu thành công), chứ không chỉ đợi đến khi có lỗi hoặc server dừng hẳn. + +2. **Đảm bảo `lifecycleMgr` xử lý lỗi khởi động Service một cách nghiêm ngặt:** + * Sau khi `HTTPService.Start()` (và các service khác) được sửa để trả về lỗi một cách chính xác, hãy đảm bảo rằng `lifecycleMgr.Start()` sẽ dừng toàn bộ quá trình nếu bất kỳ service nào không khởi động được. + ```go + // Trong hàm main() + if err := lifecycleMgr.Start(); err != nil { + // Sử dụng Fatal để log lỗi và thoát ứng dụng ngay lập tức + logger.WithError(err).Fatalf("Critical failure: One or more services failed to start. Application shutting down.") + } + ``` + Điều này sẽ ngăn ứng dụng chạy trong trạng thái không ổn định và cung cấp thông tin lỗi rõ ràng ngay khi khởi động. + +Bằng cách áp dụng những cải tiến này, bạn sẽ có một hệ thống khởi động mạnh mẽ hơn, dễ chẩn đoán lỗi hơn và các thông điệp log sẽ phản ánh chính xác hơn những gì đang thực sự xảy ra trong ứng dụng.