Rysunek płytki D1 mini z modułem ESP-12Fna niskim poziomie

Docker

Jak używam Dockera do lokalnego programowania

Konfiguracja Dockera na lokalnym komputerze — od zera, krok po kroku.

Lokalnie korzystam właściwie wyłącznie z Dockera — i to nieprzypadkowo. Jeśli piszecie w Go, C#, C, PHP, Ruby czy czymkolwiek innym, to kiedyś trzeba było to po prostu zainstalować na swoim komputerze. A potem przychodzi problem: potrzebujecie Node'a w jednej wersji do jednego projektu i w zupełnie innej do drugiego, więc trzeba się między tymi wersjami przełączać, trzymać jakieś menadżery wersji i tak dalej. Cały ten problem rozwiązuje Docker.

Prawdziwa historia z produkcji

To nie jest tylko wygoda do zabawy lokalnie — w bardziej profesjonalnym programowaniu Docker też się bardzo przydaje, i opowiem wam, jak to wyglądało u mnie na żywym przykładzie.

Mieliśmy aplikację napisaną w Ruby on Rails, która zaczęła się rozrastać. Ruby jest naprawdę fajnym językiem pod względem składni — moim zdaniem jednym z najpiękniejszych — ale jeśli chodzi o wydajność, to już nie jest jakiś szał. Do prostych rzeczy nie ma nic lepszego, ale gdy pojawia się coś bardziej obciążającego, np. procesowanie obrazków czy dodawanie znaku wodnego, to przy większej skali zaczyna to zjadać sporo pamięci i czasu.

Rozwiązaniem było przeniesienie takiej funkcjonalności do osobnej aplikacji w Node.js. Stworzyliśmy nową aplikację Node'a obok istniejącej, trzymając wszystko w jednym repozytorium — takie monorepo, gdzie mamy kilka różnych aplikacji: jedna do wysyłki maili, druga do obrazków, trzecia do komunikacji z AI, czwarta do czegoś jeszcze innego. Super w tym jest to, że możemy w każdej chwili np. wywalić aplikację do obrazków i przepisać ją całkowicie w innym języku, i nie stanowi to żadnego problemu — bo każda z nich żyje we własnym, odizolowanym kontenerze.

Budowa konfiguracji od zera

Pokażę wam, jak od zera ustawić taki projekt. Docker instaluje się bez problemu na Macu, Ubuntu czy Windowsie, więc nie będę się nad tym rozwodził — zakładam, że już go macie zainstalowanego.

Zaczynamy od nowego katalogu i otwieramy go w edytorze (ja korzystam z Visual Studio Code). W pustym projekcie potrzebujemy dwóch rzeczy: pliku docker-compose.yml i osobnych katalogów na każdy z naszych serwisów — w moim przykładzie node-hello, ruby-hello i go-hello. W realnym, produkcyjnym projekcie równie dobrze mogłyby się tu znaleźć np. image-processor czy generate-pdf — cała logika zostaje taka sama, bez względu na to, ile serwisów dorzucicie.

docker-compose.yml

Zaczynamy od sekcji services, w której wypisujemy wszystkie serwisy, jakie nas interesują:

docker-compose.yml
services:
  node-hello:
    container_name: node-hello
    build:
      context: ./node-hello
    command: 'tail -f /dev/null'
    volumes:
      - ./node-hello:/app

Kilka rzeczy wartych wyjaśnienia:

  • container_name — opcjonalny, ale fajnie go mieć, bo konfiguracja się lepiej czyta.
  • build.context — mówi Dockerowi, gdzie znaleźć katalog z Dockerfile, z którego ma zbudować obraz.
  • command: 'tail -f /dev/null' — to moim zdaniem najważniejsza linijka tutaj. Czasem nie chcemy, żeby kontener od razu uruchamiał naszą aplikację — chcemy, żeby po prostu „żył” w tle, gotowy na to, żebyśmy sami wygodnie do niego wchodzili. tail -f oznacza „wyświetlaj w kółko zawartość pliku”, a /dev/null to plik, który zawsze jest pusty — więc kontener w kółko „wyświetla nic”, dzięki czemu może działać bez końca, nie robiąc nic konkretnego, dopóki my mu czegoś nie każemy.
  • volumes — to jest kluczowe, żeby kontener widział zmiany w naszym kodzie. Mówimy Dockerowi: to, co mam lokalnie w node-hello, zamontuj wewnątrz kontenera jako /app. Dzięki temu edytuję pliki u siebie, a kontener od razu widzi te zmiany, bez przebudowywania obrazu.
Plik docker-compose.yml z serwisem node-hello otwarty w Visual Studio Code — container_name, build context, command tail -f /dev/null i volumes
docker-compose.yml w edytorze

Dockerfile

Każdy serwis ma też swój Dockerfile — w moim przypadku bardzo prosty:

node-hello/Dockerfile
FROM node:24.1.0-alpine

RUN mkdir /app
WORKDIR /app
COPY . /app

Bierzemy gotowy obraz node z internetu (takich obrazów jest mnóstwo — gołe języki, gotowe frameworki typu Postgres, Rails czy Django, dosłownie wszystko), tworzymy w nim katalog /app, ustawiamy go jako katalog roboczy i kopiujemy do niego zawartość naszego lokalnego folderu.

Dockerfile serwisu node-hello w Visual Studio Code — obraz node:24.1.0-alpine, RUN mkdir /app, WORKDIR /app i COPY . /app
Dockerfile dla node-hello

Uruchamianie i wchodzenie do kontenera

Mając to wszystko gotowe, budujemy i uruchamiamy kontenery jedną komendą z głównego katalogu:

docker compose up -d --build

Docker ściąga obraz z internetu, buduje go na podstawie naszego Dockerfile, i uruchamia. Możemy sprawdzić, co w tej chwili działa, poleceniem docker ps.

Następny krok — wejście do środka kontenera:

docker exec -it node-hello sh

-it to tryb interaktywny z terminalem, node-hello to nazwa kontenera, a na końcu podajemy powłokę, jakiej chcemy użyć — czasem jest dostępny bash, a jeśli nie, zawsze powinien być sh, taka bardziej prymitywna, ale zawsze obecna powłoka.

Terminal z wynikiem docker compose up -d, listą kontenerów z docker ps i komendą docker exec -it node-hello sh
docker compose up, docker ps i wejście do kontenera

I jesteśmy w środku — Node jest tam już zainstalowany, więc możemy od razu pisać i uruchamiać kod:

docker exec node-hello node index.js

gdzie index.js to po prostu:

node-hello/index.js
console.log("siemanko")

To samo dla Ruby i Go

W dzisiejszych czasach raczej nie piszę już takich powtarzalnych rzeczy ręcznie — po prostu poprosiłem swojego asystenta AI, żeby dopisał identyczną konfigurację dla ruby-hello i go-hello, na wzór tego, co już miałem dla Node'a. Finalne pliki wyglądają tak:

ruby-hello/Dockerfile
FROM ruby:3.4.4-alpine

RUN mkdir /app
WORKDIR /app
COPY . /app
ruby-hello/main.rb
puts "siemanko"
go-hello/Dockerfile
FROM golang:1.24.4-alpine

RUN mkdir /app
WORKDIR /app
COPY . /app
go-hello/main.go
package main

import "fmt"

func main() {
	fmt.Println("siemanko")
}

Uruchamiam wszystko na raz tą samą komendą docker compose up -d --build, a potem wchodzę do każdego kontenera osobno, dokładnie tak samo jak przy Node'ie:

docker exec ruby-hello ruby main.rb
docker exec go-hello go run main.go

Warto zwrócić uwagę na jedną rzecz, która moim zdaniem jest w tym wszystkim najfajniejsza: wchodząc do kontenera ruby-hello i próbując sprawdzić wersję Node'a, dostaniemy informację, że go tam po prostu nie ma — bo to jest kontener Ruby'ego, nic więcej. Każde środowisko jest w pełni odizolowane od pozostałych.

Podsumowanie

Dzięki takiemu podejściu mogę bardzo łatwo pisać aplikacje w dowolnym języku i używać ich lokalnie, bez zaśmiecania własnego systemu niczym. Można też bez problemu wrzucić do takiego kontenera np. całą aplikację frontendową w Reakcie — to nie stanowi żadnej różnicy. Takie kontenery da się w każdej chwili zabić, usunąć i zbudować od nowa, więc zdecydowanie jest to mój preferowany sposób pracy nad kodem lokalnie — i, co ważne, dokładnie to samo podejście sprawdza się też na produkcji.