chore: add Docker containerization and app initialization with lifecycle management

This commit is contained in:
ulflow_phattt2901 2025-05-22 11:15:19 +07:00
parent 5387d3b4cf
commit e86a866fb6
4 changed files with 270 additions and 30 deletions

62
.dockerignore Normal file
View File

@ -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

View File

@ -1,54 +1,96 @@
# Dockerfile # syntax=docker/dockerfile:1.4
# Production-ready multi-stage build for server deployment # Production-ready multi-stage build for server deployment
# Build stage # Build arguments
FROM golang:1.23-alpine AS builder 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 RUN apk add --no-cache git make gcc libc-dev
# Set working directory # Set working directory
WORKDIR /build WORKDIR /build
# Copy go.mod and go.sum files # Enable Go modules
COPY go.mod go.sum* ./ 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 # Download dependencies
RUN go mod download RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
# Copy source code # Copy source code
COPY . . COPY . .
# Create the configs directory in the build context # Create the configs directory in the build context
RUN mkdir -p /build/configs && \ RUN mkdir -p /build/configs && \
cp -r configs/* /build/configs/ 2>/dev/null || : cp -r configs/* /build/configs/ 2>/dev/null || :
# Build the application with optimizations # 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 # --- Final Stage ---
FROM alpine:3.19 FROM alpine:${ALPINE_VERSION}
# Add necessary runtime packages # Re-declare ARG to use in this stage
RUN apk add --no-cache ca-certificates tzdata ARG APP_USER=appuser
ARG APP_GROUP=appgroup
ARG APP_HOME=/app
# Set timezone # Set environment variables
ENV TZ=Asia/Ho_Chi_Minh ENV TZ=Asia/Ho_Chi_Minh \
APP_USER=${APP_USER} \
APP_GROUP=${APP_GROUP} \
APP_HOME=${APP_HOME}
# Create non-root user # Install runtime dependencies
RUN adduser -D -g '' appuser RUN apk add --no-cache \
ca-certificates \
tzdata \
tini \
&& rm -rf /var/cache/apk/*
# Create app directories # Create app user and group
RUN mkdir -p /app/config /app/logs /app/storage RUN addgroup -S ${APP_GROUP} && \
WORKDIR /app adduser -S -G ${APP_GROUP} -h ${APP_HOME} -D ${APP_USER}
# Copy binary and configs # Create necessary directories
COPY --from=builder /build/bin/app /app/ RUN mkdir -p ${APP_HOME}/configs ${APP_HOME}/logs ${APP_HOME}/storage
COPY --from=builder /build/configs /app/configs
# Set ownership # Switch to app directory
RUN chown -R appuser:appuser /app 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 # Switch to non-root user
USER appuser USER ${APP_USER}
# Default command
CMD ["./app"]
# Expose port # Expose port
EXPOSE 3000 EXPOSE 3000
@ -57,4 +99,4 @@ EXPOSE 3000
ENV APP_ENV=production ENV APP_ENV=production
# Command to run the application # Command to run the application
ENTRYPOINT ["./app"] ENTRYPOINT ["/sbin/tini", "--"]

View File

@ -32,13 +32,29 @@ func (s *HTTPService) Name() string {
} }
func (s *HTTPService) Start() error { 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() { 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 { 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 { func (s *HTTPService) Shutdown(ctx context.Context) error {
@ -109,11 +125,15 @@ func main() {
// Register HTTP service with the lifecycle manager // Register HTTP service with the lifecycle manager
httpService := NewHTTPService(cfg) httpService := NewHTTPService(cfg)
if httpService == nil {
logger.Fatal("Failed to create HTTP service")
}
lifecycleMgr.Register(httpService) lifecycleMgr.Register(httpService)
// Start all services // Start all services
logger.Info("Đang khởi động các dịch vụ...")
if err := lifecycleMgr.Start(); err != nil { 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 // Handle OS signals for graceful shutdown in a separate goroutine

116
docs/review.md Normal file
View File

@ -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 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.