Search code examples
dockergo

Rebuilding golang app in docker upon file change


I am building a golang backend with gin and I want to set it up as a service in docker. I want that when I make changes to the code the app should rebuild with the changes. I have tried to use 2 golang packages github.com/cespare/reflex and github.com/cortesi/modd.

For the cespare/reflex package I found an unsolved github issue where if you use a graceful shutdown for gin which I use the app doesn't get the SIGTERM/SIGINT from reflex. So I moved on to the second package. See below:

My Dockerfile for the golang backend is:

FROM golang:1.22.5-alpine3.20 AS builder

WORKDIR /go/src/app

ENV GO111MODULE=on

RUN go install github.com/cortesi/modd/cmd/modd@latest

COPY go.mod .
COPY go.sum .

RUN go mod download

COPY . .

RUN go build -o ./run .

FROM alpine:3.20
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /go/src/app/run .

EXPOSE 8080
CMD ["./run"]

My docker-compose.yaml file is partially:

services:
  goapp:
    build:
      context: ./src
      target: builder
    image: goapp
    [...]
    volumes:
      - ./src:/go/src/app
    command: modd -f .docker/modd.conf

My modd.conf file:

**/*.go {
    prep: echo "file changed"
    prep: go build -o ./run .
    daemon +sigterm: ./run
}

I also tried to add prep: pkill -f run || true at the beginning of the modd.conf but it just kills the container.

With the configuration above modd doesn't detect any changes made to the code.

If you have any solution to what I try to acomplish please let me know. I am also ok with solutions that use other packages then the 2 mentioned.


Solution

  • There is no need for third party apps any more. Since docker compose version 2.22.0, you can simply use docker compose up --watch.

    Basically, you instruct docker to watch over various files and folders and instruct it regarding the action to take if one of those files or folders change.

    The project

    Imagine a simple application setup like this:

    .
    ├── Dockerfile
    ├── app
    │   ├── go.mod
    │   └── main.go
    ├── docker-compose.yaml
    └── resources
        └── sample.html
    

    main.go

    We are writing a very simplistic web server:

    package main
    
    import (
        "flag"
        "log/slog"
        "net/http"
    )
    
    var bindAddress = flag.String("bind", ":8080", "HTTP server bind address")
    var dir = flag.String("dir", "./", "Directory to serve")
    
    
    // Do not use this code in production. There are security implications which are not mitigated for the sake of brevity. You have been warned!
    func main() {
        flag.Parse()
        slog.Info("Starting server", "addr", *bindAddress, "dir", *dir)
    
        http.Handle("GET /files/", http.StripPrefix("/files/", http.FileServer(http.Dir(*dir))))
        err := http.ListenAndServe(*bindAddress, nil)
        if err != nil && err != http.ErrServerClosed {
            slog.Error("Failed to start server", "err", err)
        }
    }
    

    Note: Do NOT use the code above in production. There are security implications which are not mitigated for the sake of brevity. You have been warned!

    The important parts are the docker-compose.yaml and the Dockerfile.

    Dockerfile

    First, the Dockerfile so we understand what is going to happen:

    FROM golang:1.22-alpine3.20 AS builder
    WORKDIR /app
    ADD app/ .
    RUN go build -o mycoolapp .
    
    FROM alpine:3.20
    COPY --from=builder /app/mycoolapp /usr/local/bin/mycoolapp
    COPY resources/sample.html /usr/local/share/mycoolapp/sample.html
    ENTRYPOINT [ "/usr/local/bin/mycoolapp" ]
    CMD [ "-dir","/usr/local/share/mycoolapp/" ]
    

    So basically our simple webserver is built and will read the content it serves from /usr/local/share/mycoolapp/ in the service container.

    docker-compose.yaml

    services:
      my-service:
        image: my-service:latest
        build:
          context: .
          dockerfile: Dockerfile
        develop:
          watch:
            - action: sync
              path: resources/
              target: /usr/local/share/mycoolapp/
            - action: rebuild
              path: app/
        ports:
          - "8080:8080"
        environment:
          - MY_ENV_VAR=foo
    

    So what happens when we "start" this compose file (docker compose up --watch)?

    1. If my-service:latest does not exist on your machine, it is built.
    2. If a file is changed, added or removed in resources, this will be reflected by docker syncing those changes to the directory /usr/local/share/mycoolapp/ in the service container, which is the default directory from which the webserver within our container is configured to serve files. However, the files are not added to the image.
    3. If a file is changed, added or removed in app, a full rebuild is triggered as if docker compose build was called.