Sincronização concorrente de arquivos com Go e MinIO
Baixe um bucket MinIO para um diretório local usando cinco workers Go. O exemplo preserva os caminhos aninhados, grava cada arquivo em um arquivo temporário antes de substituir uma cópia existente e retorna um erro se uma transferência falhar ou se você pressionar Ctrl+C. É um programa de download unidirecional: a cada execução, todos os objetos são baixados novamente, e exclusões remotas não removem arquivos locais.
Configurar o servidor MinIO
Use Bash no Linux com um sistema de arquivos local que diferencie maiúsculas de minúsculas,
Go 1.26.8 no seu PATH, e
curl e diff instalados. Este guia foi testado com
Go 1.26.8 e MinIO Go SDK v7.0.95. As portas 9000 e 9001 devem estar livres.
Em 3 de outubro de 2026, o repositório da comunidade MinIO está arquivado e não recebe mais manutenção. A distribuição da comunidade está disponível apenas como código-fonte. O servidor com versão fixa abaixo é um ambiente local isolado para aprendizado, não uma recomendação para uma nova implantação em produção. Ele usa a instalação a partir do código-fonte publicada pela MinIO para essa versão, em vez de uma imagem de contêiner sem versão fixa.
Cole o conteúdo abaixo no shell a partir do diretório em que você quer criar um novo diretório
minio-sync. O subshell mantém intactos o diretório atual e as opções do shell,
e a existência do destino interrompe a configuração antes da instalação. A compilação do servidor
pode levar vários minutos:
(
set -e
mkdir minio-sync
cd minio-sync
GOBIN="$PWD/bin" GOWORK=off GOTOOLCHAIN=local \
go install github.com/minio/minio@RELEASE.2025-10-15T17-29-55Z
)
Inicie o servidor nesse terminal e deixe-o em execução:
(
cd minio-sync &&
MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin \
./bin/minio server ./data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001
)
Os dois listeners se vinculam à interface de loopback. Essas credenciais amplamente conhecidas e o
HTTP sem criptografia devem ser usados somente neste ambiente de testes privado. Pressione Ctrl+C
nesse terminal quando terminar; o servidor para, e seu diretório data
continua disponível para outra execução.
Inicializar o projeto
Em um segundo terminal, comece no mesmo diretório pai. Confirme que o servidor está pronto e, então,
inicialize o módulo e instale o SDK com a versão fixa. GOWORK=off impede que um
workspace Go que contenha o projeto altere a seleção de módulos do exemplo:
(
set -e
curl -fsS http://127.0.0.1:9000/minio/health/ready
cd minio-sync
GOWORK=off GOTOOLCHAIN=local go mod init minio-sync
GOWORK=off GOTOOLCHAIN=local go get github.com/minio/minio-go/v7@v7.0.95
)
Preencher o bucket
Crie minio-sync/_seed/ e salve o conteúdo abaixo como _seed/main.go.
Ele grava arquivos de entrada conhecidos em sample-input/ e faz upload deles para
my-sync-bucket: 300 arquivos de texto, um arquivo de texto em um caminho aninhado,
um arquivo vazio e um arquivo binário. O diretório com sublinhado mantém esse executável separado
fora do pacote principal.
// _seed/main.go
package main
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func main() {
if err := seed(); err != nil {
log.Fatal(err)
}
log.Println("Uploaded 303 sample objects")
}
func seed() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, err := minio.New("localhost:9000", &minio.Options{
Creds: credentials.NewStaticV4("minioadmin", "minioadmin", ""),
Secure: false,
})
if err != nil {
return err
}
const bucket = "my-sync-bucket"
exists, err := client.BucketExists(ctx, bucket)
if err != nil {
return err
}
if !exists {
if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
return err
}
}
files := map[string][]byte{
"notes/hello.txt": []byte("Hello from MinIO\n"),
"empty.bin": {},
"binary.bin": {0, 255, 128, 13, 10, 42, 0, 10},
}
for i := 0; i < 300; i++ {
files[fmt.Sprintf("logs/object-%03d.txt", i)] = []byte(fmt.Sprintf("object %d\n", i))
}
for key, body := range files {
filename := filepath.Join("sample-input", filepath.FromSlash(key))
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
return err
}
if err := os.WriteFile(filename, body, 0o600); err != nil {
return err
}
if _, err := client.FPutObject(ctx, bucket, key, filename, minio.PutObjectOptions{}); err != nil {
return fmt.Errorf("upload %q: %w", key, err)
}
}
return nil
}
Execute a carga inicial a partir do diretório pai:
(
cd minio-sync &&
GOWORK=off GOTOOLCHAIN=local go run ./_seed
)
Ela informa Uploaded 303 sample objects. Executá-la novamente substitui essas chaves e os
arquivos de entrada de exemplo; use-a somente com este servidor e diretório dedicados. Mantenha os
dados do servidor e sample-input/ inalterados durante o download e a comparação dos
resultados.
Configurar o cliente MinIO
Salve o conteúdo abaixo como minio-sync/client.go. O servidor local usa HTTP, então
useSSL é false. A versão mínima de TLS no transporte
só tem efeito quando useSSL é true e o endpoint
atende via HTTPS. BucketExists deve retornar true e nenhum erro antes de iniciar o
download:
// client.go
package main
import (
"context"
"crypto/tls"
"fmt"
"net/http"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
// bucketName matches the bucket populated by _seed/main.go.
const bucketName = "my-sync-bucket"
func createMinioClient(ctx context.Context) (*minio.Client, error) {
endpoint := "localhost:9000"
accessKeyID := "minioadmin" // Use environment variables in production
secretAccessKey := "minioadmin" // Use environment variables in production
useSSL := false // true once your endpoint terminates TLS
// Only exercised when useSSL is true
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
IdleConnTimeout: 90 * time.Second,
}
// Initialize minio client
opts := &minio.Options{
Creds: credentials.NewStaticV4(accessKeyID, secretAccessKey, ""),
Secure: useSSL,
Transport: transport,
}
client, err := minio.New(endpoint, opts)
if err != nil {
return nil, err
}
// BucketExists reports a bucket that is simply absent as (false, nil), so the boolean
// has to be checked too. Ignoring it turns a typo in the bucket name into a sync that
// quietly downloads nothing.
exists, err := client.BucketExists(ctx, bucketName)
if err != nil {
return nil, fmt.Errorf("failed to reach bucket %q: %w", bucketName, err)
}
if !exists {
return nil, fmt.Errorf("bucket %q does not exist", bucketName)
}
return client, nil
}
Implementar downloads concorrentes de arquivos
Salve o conteúdo abaixo como minio-sync/sync.go. Cinco workers leem tarefas enquanto
uma goroutine separada consome os resultados; esperar a conclusão da listagem para consumir os
resultados causaria um deadlock em um bucket maior que os buffers dos canais. Cada objeto tem um
tempo limite de 10 minutos. Os erros são contabilizados, com a chave do primeiro objeto cujo download
falhou incluída no diagnóstico final.
A referência de GetObject do SDK
explica que os erros frequentemente surgem durante a leitura do fluxo. Por isso, verificar
io.Copy é tão importante quanto verificar GetObject.
Uma transferência que falha remove seu arquivo temporário e mantém a cópia concluída anteriormente.
// sync.go
package main
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/minio/minio-go/v7"
)
type DownloadResult struct {
ObjectName string
Error error
}
func downloadFiles(ctx context.Context, client *minio.Client, bucketName string, outputDir string) error {
// 0700, because these are private copies. The process umask would otherwise usually
// leave them group- and world-readable.
if err := os.MkdirAll(outputDir, 0o700); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
}
// Resolve the directory once so the per-object checks below compare against a path
// that has no symlinks left in it.
root, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("failed to resolve output directory: %w", err)
}
// Cancelling here unblocks every worker if we bail out early
ctx, cancel := context.WithCancel(ctx)
defer cancel()
jobs := make(chan string, 100)
results := make(chan DownloadResult, 100)
var wg sync.WaitGroup
workerCount := 5
// Start workers
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for objectName := range jobs {
err := downloadObject(ctx, client, bucketName, objectName, root)
select {
case results <- DownloadResult{ObjectName: objectName, Error: err}:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(results)
}()
// Collect results while the producer is still queueing. Draining only after
// close(jobs) deadlocks as soon as the buffers fill: workers block on
// `results <-`, stop reading `jobs`, and the producer blocks on `jobs <-`.
//
// Only a count and the first error are kept. A bucket can hold millions of objects,
// and appending every failure to a slice would grow without bound in exactly the run
// where things are going worst.
type failures struct {
count int
first error
}
collected := make(chan failures, 1)
go func() {
var seen failures
for result := range results {
if result.Error == nil {
continue
}
seen.count++
if seen.first == nil {
seen.first = fmt.Errorf("%s: %w", result.ObjectName, result.Error)
}
}
collected <- seen
}()
// List and queue objects. The deferred close lets the workers drain and exit
// on every path, including the early returns below.
listErr := func() error {
defer close(jobs)
opts := minio.ListObjectsOptions{Recursive: true}
for obj := range client.ListObjects(ctx, bucketName, opts) {
if obj.Err != nil {
// Cancel immediately so in-flight downloads stop now rather than running
// to completion behind a listing that has already failed.
cancel()
return fmt.Errorf("error listing objects: %w", obj.Err)
}
// Empty directory markers contain no file bytes to download.
if obj.Size == 0 && strings.HasSuffix(obj.Key, "/") {
continue
}
select {
case jobs <- obj.Key:
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}()
// Always drain first, so every worker has finished before we report anything.
seen := <-collected
if listErr != nil {
return listErr
}
// A cancelled context ends the listing loop without an error of its own, and workers
// that were cut off never deliver a result. Without this check a cancelled sync would
// be indistinguishable from an empty bucket that synced cleanly.
if err := ctx.Err(); err != nil {
return fmt.Errorf("sync did not complete: %w", err)
}
if seen.count > 0 {
return fmt.Errorf("encountered %d download errors, first: %w", seen.count, seen.first)
}
return nil
}
// resolveOutputPath maps a server-supplied object key onto a path inside outputDir, which
// must already be symlink-free (see filepath.EvalSymlinks above).
//
// Keys are rejected rather than cleaned. `a/./b.txt` and `a/b/../b.txt` are different keys
// that both clean to `a/b.txt`, so cleaning them would let one object silently overwrite
// another, and lexical checks alone cannot see a symlink that is already on disk.
func resolveOutputPath(outputDir, objectName string) (string, error) {
if objectName == "" || objectName != path.Clean(objectName) || strings.Contains(objectName, `\`) {
return "", fmt.Errorf("refusing non-canonical object key: %q", objectName)
}
if !filepath.IsLocal(filepath.FromSlash(objectName)) {
return "", fmt.Errorf("refusing unsafe object key: %q", objectName)
}
current := outputDir
for _, element := range strings.Split(objectName, "/") {
current = filepath.Join(current, element)
info, err := os.Lstat(current)
if err != nil {
if os.IsNotExist(err) {
continue // Nothing here yet, so nothing can redirect the write
}
return "", fmt.Errorf("failed to inspect %q: %w", current, err)
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("refusing object key through a symlink: %q", objectName)
}
}
return current, nil
}
func downloadObject(ctx context.Context, client *minio.Client, bucket, objectName, outputDir string) error {
outputPath, err := resolveOutputPath(outputDir, objectName)
if err != nil {
return err
}
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
// Get object
obj, err := client.GetObject(ctx, bucket, objectName, minio.GetObjectOptions{})
if err != nil {
return fmt.Errorf("failed to get object: %w", err)
}
defer obj.Close()
if err := os.MkdirAll(filepath.Dir(outputPath), 0o700); err != nil {
return fmt.Errorf("failed to create directories: %w", err)
}
// Download to a sibling temp file so a failed sync never truncates the copy
// that a previous run completed, and never leaves a half-written file behind.
// os.CreateTemp already creates the file 0600.
temp, err := os.CreateTemp(filepath.Dir(outputPath), filepath.Base(outputPath)+".part-*")
if err != nil {
return fmt.Errorf("failed to create temporary file: %w", err)
}
tempPath := temp.Name()
defer func() {
temp.Close()
os.Remove(tempPath) // No-op once the rename below succeeded
}()
if _, err := io.Copy(temp, obj); err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
if err := temp.Sync(); err != nil {
return fmt.Errorf("failed to flush file: %w", err)
}
if err := temp.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
// Rename is atomic within a filesystem, so readers see either the old file or
// the complete new one
if err := os.Rename(tempPath, outputPath); err != nil {
return fmt.Errorf("failed to publish file: %w", err)
}
return nil
}
Tratamento de erros e solução de problemas
Marcadores de diretórios vazios, que têm zero bytes e uma chave terminada em
/, são ignorados. Um arquivo vazio comum é baixado. Chaves que contenham
segmentos de ponto, barras invertidas, caminhos absolutos ou um link simbólico existente no destino
são rejeitadas. Uma chave de arquivo como reports também entra em conflito
com uma chave aninhada como reports/january.txt; dê nomes distintos a esses objetos no
bucket.
Se a inicialização falhar, verifique se as portas 9000 ou 9001 estão ocupadas. Se o cliente não conseguir acessar o bucket, confirme que o servidor continua em execução e que a carga inicial foi concluída com sucesso. Uma falha de permissão na listagem ou no acesso a um objeto retorna um código de saída diferente de zero. Um erro de permissão no destino ou uma colisão entre arquivo e diretório também faz a execução falhar; os downloads concluídos por outros workers permanecem no disco.
Testar sua implementação
Salve o ponto de entrada abaixo como minio-sync/main.go, ao lado de
client.go e sync.go. Seu contexto de sinais cancela as
requisições em andamento ao receber Ctrl+C ou SIGTERM. A mensagem de sucesso só é impressa depois
que a listagem e todos os workers terminam sem erros:
// main.go
package main
import (
"context"
"log"
"os/signal"
"syscall"
)
func main() {
// Ctrl-C cancels the context, which stops the workers and makes downloadFiles
// report the interruption instead of exiting as if the sync had finished.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Create MinIO client
client, err := createMinioClient(ctx)
if err != nil {
log.Fatalf("Failed to create MinIO client: %v", err)
}
// Start download. bucketName comes from client.go.
if err := downloadFiles(ctx, client, bucketName, "./downloads"); err != nil {
log.Fatalf("Download failed: %v", err)
}
log.Println("Download completed successfully")
}
Execute o programa de download a partir do diretório pai:
(
cd minio-sync &&
GOWORK=off GOTOOLCHAIN=local go run .
)
Ele registra Download completed successfully no log e grava os arquivos em
minio-sync/downloads/. Neste bucket de exemplo recém-criado, verifique cada caminho e byte
com:
(
cd minio-sync &&
diff -r sample-input downloads
)
A ausência de saída e o código de saída zero indicam que os diretórios são iguais, incluindo os
arquivos vazios e binários. Um byte diferente ou um arquivo ausente faz
diff falhar. Os 303 objetos excedem a capacidade dos dois buffers de
canal de 100 entradas cada. Execute o programa de download novamente para substituir as cópias
locais; ele baixa todos os objetos outra vez. Arquivos sem relação com as chaves atuais do bucket
permanecem em downloads/.
Considerações de segurança
Reserve downloads/ exclusivamente para este processo. As verificações de
caminho rejeitam links simbólicos existentes, mas não impedem que outro processo altere um caminho
entre a inspeção e a gravação. Elas não constituem um sandbox do sistema de arquivos. Novos
diretórios usam o modo 0700, e os arquivos temporários usam
0600; as permissões dos diretórios existentes não se tornam mais
restritivas. Execute apenas um programa de download por vez nesse diretório.
Para um serviço remoto, substitua as credenciais do ambiente de testes por uma conta limitada às operações necessárias no bucket e use HTTPS. As credenciais root locais fixas deste exemplo são apenas para o servidor descartável. Um serviço de armazenamento com suporte e seus requisitos de implantação são questões separadas deste laboratório com o servidor da comunidade arquivado.
Considerações finais
Cada download bem-sucedido substitui um arquivo local usando um arquivo temporário no mesmo diretório e uma renomeação no mesmo sistema de arquivos local. Uma execução que falha ainda pode conter downloads bem-sucedidos; ela não reverte o bucket. O programa não compara marcas de tempo, não ignora objetos inalterados, não exclui arquivos locais desatualizados nem cria um snapshot de um bucket que esteja mudando. Mantenha o bucket estável durante a execução quando precisar que o resultado represente um conjunto consistente de objetos.
