Aurora Libros is built, optimized and hardened. What is missing is the thing that separates a platform that works from a platform you can operate: knowing what is happening right now, what happened last night at 3:14, and why the API container restarted four times on Tuesday.
Contents
- The stdout/stderr rule, revisited
- The logging drivers
- Global and per-service configuration
- Mandatory rotation: the full-disk arithmetic
- Structured JSON logs
- Centralized aggregation: the pattern
- Loki, Promtail and Grafana in the
observabilityprofile - Useful LogQL queries
- Metrics:
docker statsand its limits - The daemon's metrics API
- cAdvisor, node-exporter and Prometheus
- The metrics that really get watched
- Alerts
- The rich health endpoint
- Distributed tracing: the next step
- The stdout/stderr rule, revisited
A containerized application does not write log files: it writes to standard output and standard error, and lets the platform do the rest.
The reason is concrete: the container is ephemeral. A file in /var/log/app.log disappears when the container is recreated, forces you to mount a volume just for that, demands its own rotation and cannot be read with docker logs. By writing to stdout, the process only produces the events; collecting them, rotating them and shipping them where they belong is the logging driver's responsibility.
docker compose exec aurora-api sh -c 'ls -la /proc/1/fd/1 /proc/1/fd/2'
# /proc/1/fd/1 -> pipe:[482913]
# /proc/1/fd/2 -> pipe:[482914]The process's outputs are pipes to the daemon, not files. At the other end, the driver decides their destination.
- The logging drivers
| Driver | Destination | docker logs |
When to use it |
|---|---|---|---|
json-file |
/var/lib/docker/containers/<id>/*-json.log |
Yes | The default. Fine once rotation is configured |
local |
Its own compressed binary format | Yes | Better performance and rotation on by default |
journald |
The host's systemd-journald |
Yes | systemd hosts; integrates with journalctl |
syslog |
A local or remote syslog server | No | Existing syslog infrastructure |
fluentd |
A Fluentd/Fluent Bit daemon | No | Flexible routing to several destinations |
gelf |
Graylog / Logstash (UDP) | No | Graylog or ELK stacks |
awslogs, gcplogs |
CloudWatch, Cloud Logging | No | Workloads native to that cloud |
none |
Discarded | No | Noisy containers whose logs are worthless |
The limitation marked "No" is important, and it takes people by surprise at the worst possible moment: with syslog, fluentd, gelf or awslogs, docker logs stops working. If the central system is down, you have no way left to see what is going on.
The usual strategy avoids that dead end: keep json-file or local with rotation and collect the files with an external agent (Promtail, Fluent Bit, Vector). That way you keep docker logs for immediate diagnosis and get central aggregation for history.
- Global and per-service configuration
The default for the whole host goes in /etc/docker/daemon.json, and anything specific goes in Compose:
After sudo systemctl restart docker, verify it with docker info --format '{{.LoggingDriver}}'. Careful: changing the driver only affects containers created afterwards; existing ones keep theirs until they are recreated.
services:
aurora-api:
logging:
driver: json-file
options: { max-size: "50m", max-file: "5", tag: "{{.Name}}/{{.ID}}" }
aurora-cache:
logging:
driver: none # Redis with --loglevel notice adds nothing here
- Mandatory rotation: the full-disk arithmetic
Without rotation, json-file grows without limit. Let's do the arithmetic with Aurora Libros: the API logs one line per request, about 200 bytes of JSON, with 50 requests per second at peak and an average of 15/s across the day.
Almost 8 GB a month from a single service, in a file that is never truncated. With four services and a 40 GB disk, the platform goes down from a full disk in under two months. And a full-disk failure is particularly nasty: PostgreSQL stops accepting writes, Docker cannot create containers, and the logging system itself cannot record the problem.
sudo du -sh /var/lib/docker/containers/*/ | sort -rh | head -2
docker inspect aurora-libros-aurora-api-1 --format '{{.HostConfig.LogConfig}}'
# 2.1G /var/lib/docker/containers/a91f3c.../
# {json-file map[max-file:5 max-size:50m]}With max-size: 50m and max-file: 5, the ceiling per container is 250 MB, and you always have the last few days. Two warnings: max-file without max-size does nothing, and the local driver ships with rotation enabled by default (100 MB across 5 files), which makes it the safest option if you forget to configure it.
- Structured JSON logs
A free-text log forces you to write regular expressions for everything. A JSON log is queried by field.
// api/src/log.js — minimal structured logging, no dependencies
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
const currentLevel = LEVELS[process.env.LOG_LEVEL ?? 'info'] ?? 2;
function emit(level, message, extra = {}) {
if (LEVELS[level] > currentLevel) return;
const line = { ts: new Date().toISOString(), level, service: 'aurora-api',
version: process.env.APP_VERSION ?? '1.3.0', message, ...extra };
process[level === 'error' ? 'stderr' : 'stdout'].write(JSON.stringify(line) + '\n');
}
module.exports = Object.fromEntries(
Object.keys(LEVELS).map(n => [n, (m, e) => emit(n, m, e)])
);// api/src/server.js — request identifier and access logging
const { randomUUID } = require('node:crypto');
const log = require('./log');
app.use((req, res, next) => {
req.id = req.get('x-request-id') ?? randomUUID(); // reuse the front end's if one arrives
res.set('x-request-id', req.id); // and hand it back to the client
const start = process.hrtime.bigint();
res.on('finish', () => {
const ms = Number(process.hrtime.bigint() - start) / 1e6;
log.info('request', { req_id: req.id, method: req.method,
path: req.route?.path ?? req.path, status: res.statusCode, ms: Number(ms.toFixed(1)) });
});
next();
});
app.get('/books', async (req, res) => {
const cached = await cache.get('books:all');
log.debug(cached ? 'cache hit' : 'cache miss', { req_id: req.id, key: 'books:all' });
if (cached) return res.json({ source: 'cache', ...JSON.parse(cached) });
// ... query PostgreSQL, log.debug('db query', {...}) and write to the cache
});curl -s -o /dev/null http://localhost:8080/api/books
docker compose logs aurora-api --tail 2 --no-log-prefix | jq -c{"ts":"2026-08-05T10:14:02.881Z","level":"debug","service":"aurora-api","version":"1.3.0","message":"cache miss","req_id":"7f3a...","key":"books:all"}
{"ts":"2026-08-05T10:14:02.914Z","level":"info","service":"aurora-api","version":"1.3.0","message":"request","req_id":"7f3a...","method":"GET","path":"/books","status":200,"ms":33.2}The req_id is the key piece: it travels through the x-request-id header, the response hands it back, and it appears on every line belonging to that request. When a user reports an error at 10:14 with that identifier, you recover the full trace of their request with a single query instead of reading ten thousand lines.
- Centralized aggregation: the pattern
docker logs is fine for one container on one machine. With several services, several nodes and containers that get recreated, the events need to leave the host and outlive the container that produced them.
flowchart LR A["aurora-api<br/>JSON on stdout"] --> D["json-file driver<br/>/var/lib/docker/containers"] B["aurora-db"] --> D C["aurora-web"] --> D D --> P["Promtail<br/>(reads and labels)"] P --> L["Loki<br/>(indexes by label)"] L --> G["Grafana<br/>LogQL queries"] M["cAdvisor + node-exporter"] --> PR["Prometheus"] PR --> G PR --> AL["Alertmanager"]
Loki indexes only the labels (service, level, host) and compresses the rest, which makes it far cheaper than a stack that indexes every word. The alternatives: ELK/OpenSearch (Elasticsearch + Logstash + Kibana), more powerful for full-text search and considerably hungrier for resources; and the clouds' managed services.
- Loki, Promtail and Grafana in the
observability profile
observability profile# compose.observability.yaml — Aurora Libros' "observability" profile
services:
loki:
image: grafana/loki:3.3.0
profiles: [observability]
volumes: [loki-data:/loki]
networks: [monitoring]
promtail:
image: grafana/promtail:3.3.0
profiles: [observability]
command: ["-config.file=/etc/promtail/config.yaml"]
volumes:
- ./observability/promtail.yaml:/etc/promtail/config.yaml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro # only to read labels
depends_on: [loki]
networks: [monitoring]
prometheus:
image: prom/prometheus:v3.1.0
profiles: [observability]
volumes:
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./observability/alerts.yml:/etc/prometheus/alerts.yml:ro
- prom-data:/prometheus
networks: [monitoring, backend]
cadvisor:
image: gcr.io/cadvisor/cadvisor:v0.52.0
profiles: [observability]
volumes: ["/:/rootfs:ro", "/var/run:/var/run:ro", "/sys:/sys:ro", "/var/lib/docker/:/var/lib/docker:ro"]
devices: ["/dev/kmsg"]
networks: [monitoring]
node-exporter:
image: prom/node-exporter:v1.8.2
profiles: [observability]
command: ["--path.rootfs=/host"]
pid: host
volumes: ["/:/host:ro,rslave"]
networks: [monitoring]
grafana:
image: grafana/grafana:11.5.0
profiles: [observability]
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin
secrets: [grafana_admin]
volumes:
- ./observability/datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
- grafana-data:/var/lib/grafana
ports: ["3001:3000"]
depends_on: [loki, prometheus]
networks: [monitoring]
volumes: { loki-data: {}, prom-data: {}, grafana-data: {} }
networks: { monitoring: { driver: bridge } }# observability/prometheus.yml
global: { scrape_interval: 15s }
rule_files: ["/etc/prometheus/alerts.yml"]
scrape_configs:
- { job_name: cadvisor, static_configs: [{ targets: ["cadvisor:8080"] }] }
- { job_name: node, static_configs: [{ targets: ["node-exporter:9100"] }] }
- { job_name: docker-daemon, static_configs: [{ targets: ["172.17.0.1:9323"] }] }
- { job_name: aurora-api, metrics_path: /metrics,
static_configs: [{ targets: ["aurora-api:3000"] }] }docker compose -f compose.yaml -f compose.observability.yaml --profile observability up -d
docker compose --profile observability ps --format "{{.Service}}" | tr '\n' ' '
# grafana prometheus cadvisor loki promtail node-exporter aurora-api aurora-db ...Having all of this live in a profile (lesson 04-06) is deliberate: docker compose up -d still brings up nothing but the platform, and observability is added when it is needed, without eating resources on anybody's laptop.
Security warning. Promtail mounts
docker.sockread-only in order to read labels, and cAdvisor has broad access to the host. These are real concessions (lesson 05-03): in production, use a filtered socket proxy and validate this configuration with your security officer.
- Useful LogQL queries
# API errors in the last hour
{compose_service="aurora-api"} | json | level="error"
# The full trace of one specific request
{compose_service="aurora-api"} | json | req_id="7f3a1c9d-4e28-4a1b-9c33-1f0e7b2d5a64"
# Slow requests: over 500 ms
{compose_service="aurora-api"} | json | ms > 500 | line_format "{{.path}} {{.ms}}ms"
# Rate of 5xx responses per minute
sum(rate({compose_service="aurora-api"} | json | status >= 500 [1m]))
# Cache hit ratio
sum(count_over_time({compose_service="aurora-api"} | json | message="cache hit" [5m]))
/ sum(count_over_time({compose_service="aurora-api"} | json | message=~"cache (hit|miss)" [5m]))The second query is the one that justifies all the work in section 5: with an identifier handed to you by the user, you retrieve their exact request from among millions of lines in under a second. The last one turns an architectural decision —the cache-aside pattern— into an observable metric: if the hit ratio drops, something is wrong with Redis or with invalidation.
- Metrics:
docker stats and its limits
docker stats and its limitsNAME CPU % MEM USAGE / LIMIT MEM %
aurora-libros-aurora-api-1 2.14% 84.2MiB / 512MiB 16.45%
aurora-libros-aurora-db-1 0.88% 412.7MiB / 2GiB 20.15%
aurora-libros-aurora-cache-1 0.31% 9.8MiB / 256MiB 3.83%Its limits, and why it is not enough: it is a snapshot with no history, it has no alerts, it does not aggregate several hosts, and somebody has to be watching. It answers "what is happening now"; not "what happened last night".
- The daemon's metrics API
The daemon itself exposes metrics in Prometheus format if you enable it in daemon.json:
sudo systemctl restart docker
curl -s http://localhost:9323/metrics | grep '^engine_daemon_container_states' | head -3engine_daemon_container_states_containers{state="running"} 6
engine_daemon_container_states_containers{state="paused"} 0
engine_daemon_container_states_containers{state="stopped"} 2These are the daemon's metrics, not each container's: how many containers are in each state, operation timings, engine version. Useful for watching Docker's own health. And expose that port only on your management network: it carries no authentication.
- cAdvisor, node-exporter and Prometheus
cAdvisor reads the cgroups (lesson 05-07) and publishes per-container metrics; node-exporter does the same for the host.
curl -s http://localhost:9090/api/v1/query --data-urlencode \
'query=rate(container_cpu_usage_seconds_total{name=~"aurora.*"}[5m])' | jq -r '.data.result[].metric.name'Grafana queries both sources, and you do not have to invent the panels: dashboards 193 (Docker/cAdvisor) and 1860 (Node Exporter Full) from the public catalog cover 90% of what you need.
- The metrics that really get watched
| Metric | Expression | Why it matters |
|---|---|---|
| Memory against the limit | container_memory_usage_bytes / container_spec_memory_limit_bytes |
Close to 100% → OOM kill (code 137) |
| CPU against the limit | rate(container_cpu_usage_seconds_total[5m]) |
Throttling and high latency |
| Restarts | changes(container_start_time_seconds[1h]) |
A container that restarts on its own is failing |
| Health status | /health, docker inspect .State.Health |
Distinguishes "started" from "ready" |
| p95 latency | histogram_quantile(0.95, ...) |
The average lies; the 95th percentile does not |
| Error rate | rate(requests{status=~"5.."}[5m]) |
The most direct signal that something broke |
| Disk and DB connections | node_filesystem_avail_bytes, pg_stat_activity |
Logs filling the disk; an exhausted pool that takes down the API |
Notice the pattern: what matters is almost never the absolute value but the relationship to the limit you set back in lesson 03-07. 400 MB of memory says nothing; 400 MB against a 512 MB limit says an OOM is close.
- Alerts
# observability/alerts.yml
groups:
- name: aurora
rules:
- alert: ContainerDown
expr: absent(container_last_seen{name="aurora-libros-aurora-api-1"}) == 1
for: 2m
labels: { severity: critical, notify: oncall }
annotations: { summary: "aurora-api has not responded for 2 minutes" }
- alert: MemoryNearLimit
expr: container_memory_usage_bytes{name=~"aurora.*"}
/ container_spec_memory_limit_bytes{name=~"aurora.*"} > 0.9
for: 5m
labels: { severity: high, notify: team }
annotations: { summary: "{{ $labels.name }} at {{ $value | humanizePercentage }} of its limit" }
- alert: HighErrorRate
expr: sum(rate(aurora_requests_total{status=~"5.."}[5m]))
/ sum(rate(aurora_requests_total[5m])) > 0.05
for: 3m
labels: { severity: critical, notify: oncall }
annotations: { summary: "More than 5% of requests are returning 5xx" }
- alert: RepeatedRestarts
expr: changes(container_start_time_seconds{name=~"aurora.*"}[15m]) > 3
for: 1m
labels: { severity: high, notify: team }Two things make the difference between an alert that gets acted on and one that gets ignored. The first is for: without it, a two-second spike wakes somebody up in the middle of the night. The second is who it notifies: oncall for whatever demands immediate action (a service down, massive errors) and team for what can wait until tomorrow (high memory, restarts). An alert that leads to no concrete action should be deleted; the worst possible state for an alerting system is a team that ignores it out of habit.
- The rich health endpoint
/health started out returning {"status":"ok"}. That only tells you the process starts. A useful endpoint checks its dependencies:
app.get('/health', async (req, res) => {
const start = Date.now();
const check = async (name, fn) => {
const t = Date.now();
try { await fn(); return { name, status: 'ok', ms: Date.now() - t }; }
catch (e) { return { name, status: 'error', ms: Date.now() - t, detail: e.message }; }
};
const checks = await Promise.all([
check('db', () => db.query('SELECT 1')),
check('cache', () => cache.ping())
]);
const healthy = checks.every(c => c.status === 'ok');
if (!healthy) log.error('degraded health', { checks });
res.status(healthy ? 200 : 503).json({
status: healthy ? 'ok' : 'degraded', version: process.env.APP_VERSION ?? '1.3.0',
uptime_s: Math.round(process.uptime()), ms: Date.now() - start, dependencies: checks
});
});curl -s http://localhost:8080/api/health | jq -c
docker compose stop aurora-cache && sleep 2
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/health
docker compose start aurora-cache{"status":"ok","version":"1.3.0","uptime_s":1842,"ms":4,"dependencies":[{"name":"db","status":"ok","ms":2},{"name":"cache","status":"ok","ms":1}]}
503The 503 is the important part: the Dockerfile's HEALTHCHECK detects it, the container goes unhealthy, depends_on: service_healthy will not start whatever depends on it, and the load balancer stops sending it traffic. With a /health that only ever returned ok, none of that would happen.
A design nuance: distinguish liveness (should this process be restarted?) from readiness (can it receive traffic?). If Redis goes down, the API should not restart in a loop, only stop advertising itself as ready; the explicit distinction arrives with Kubernetes (lesson 06-05).
- Distributed tracing: the next step
Logs and metrics answer "what happened" and "how much"; traces answer "where did the time go". A request to /books crosses nginx, Express, Redis and PostgreSQL, and a trace measures each leg separately. OpenTelemetry is the standard: with Node's automatic instrumentation all you need is one package and a few variables (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT: http://tempo:4318, OTEL_TRACES_SAMPLER_ARG: "0.1" to sample 10%).
The req_id from section 5 is, in fact, a hand-rolled precursor of the trace_id. If you adopt OpenTelemetry, replace it with the trace identifier and you will have logs, metrics and traces correlated by the same field.
Common Mistakes and Tips
Writing logs to files inside the container. They are lost when it is recreated and docker logs never sees them. Always to stdout/stderr.
Not configuring rotation. 8 GB a month per service and an outage from a full disk that also prevents the cause from being logged.
Switching the driver to fluentd or syslog and losing docker logs. If the central system fails, you go blind. Keep json-file/local and collect with an agent.
Logging personal data or secrets. A log containing passwords or customer data is a data-protection incident. Filter at the source and check it with your compliance officer.
Alerting on absolute values, or alerting without for. 400 MB means nothing without the limit next to it, and a two-second spike that wakes somebody at night teaches the team to ignore alerts.
A /health that only says ok. It does not distinguish "the process is alive" from "the service works". Check the dependencies.
Tip: set up observability before you need it. On the day of the incident there is no time to install Prometheus, and the data you need most is from the preceding hours, which nobody can recover anymore.
Exercises
Exercise 1. Work out aurora-api's log growth with your own traffic: measure the log file's size, generate 500 requests, measure again and extrapolate to a month. Then configure rotation and prove that the ceiling is respected.
Exercise 2. Add the req_id and structured logging to aurora-api, generate traffic, and prove that you can reconstruct the full trace of one specific request from the identifier returned in the response header.
Exercise 3. Bring up the observability profile, drive aurora-api's memory usage close to its limit, and check in Prometheus that the alert's expression fires. Explain why the alert uses a ratio and not an absolute value.
Solutions
Solution 1.
id=$(docker compose ps -q aurora-api); f=/var/lib/docker/containers/$id/$id-json.log
before=$(sudo stat -c %s "$f")
for i in $(seq 1 500); do curl -s -o /dev/null http://localhost:8080/api/books; done
p=$(( ($(sudo stat -c %s "$f") - before) / 500 ))
echo "bytes/request: $p"
echo "at 15 req/s: $(( p * 15 * 86400 / 1048576 )) MB/day"
echo "per month: $(( p * 15 * 86400 * 30 / 1073741824 )) GB"Seven gigabytes a month from a single service, measured rather than estimated. Now the limit:
docker compose up -d --force-recreate aurora-api
for i in $(seq 1 60000); do curl -s -o /dev/null http://localhost:8080/api/health; done
id=$(docker compose ps -q aurora-api)
sudo du -ch /var/lib/docker/containers/$id/*json.log* | tail -4The ceiling holds: three files, none above 10 MB, 25 MB in total no matter what. The oldest lines have been lost, and that is exactly the point: recent logs are the ones that help you diagnose, and history is Loki's responsibility, not the host disk's. The detail to remember is that max-size without max-file limits a single file and max-file without max-size limits nothing at all: you need both.
Solution 2.
docker compose up -d --build aurora-api
rid=$(curl -s -D - -o /dev/null http://localhost:8080/api/books | grep -i '^x-request-id' | tr -d '\r' | awk '{print $2}')
echo "request: $rid"
docker compose logs aurora-api --no-log-prefix --tail 200 | jq -c "select(.req_id==\"$rid\")"request: 7f3a1c9d-4e28-4a1b-9c33-1f0e7b2d5a64
{"ts":"...T10:14:02.874Z","level":"debug","message":"cache miss","req_id":"7f3a1c9d-...","key":"books:all"}
{"ts":"...T10:14:02.901Z","level":"debug","message":"db query","req_id":"7f3a1c9d-...","rows":9,"ms":24.8}
{"ts":"...T10:14:02.914Z","level":"info","message":"request","req_id":"7f3a1c9d-...","method":"GET","path":"/books","status":200,"ms":33.2}Three lines that tell the whole story of that request: the cache did not have the key, PostgreSQL was queried and returned 9 rows in 24.8 ms, and the response went out with a 200 in 33.2 ms overall. With those numbers you can state that 75% of the time went into the database, without guessing anything.
What makes this genuinely operable is that the identifier goes out to the world in the x-request-id header. The user reporting a problem can give you that code —or the front end can include it in its error message— and you retrieve their exact request from among millions. And because the header is also accepted on the way in, if nginx or the front end already generates one, the API reuses it and the trace is chained end to end.
Solution 3.
docker compose -f compose.yaml -f compose.observability.yaml --profile observability up -d
Q='container_memory_usage_bytes{name=~"aurora.*"} / container_spec_memory_limit_bytes{name=~"aurora.*"}'
curl -s http://localhost:9090/api/v1/query --data-urlencode "query=$Q" \
| jq -r '.data.result[] | "\(.metric.name) \(.value[1])"' | head -1
# Usage is forced with a test endpoint that holds on to memory
for i in $(seq 1 40); do curl -s -o /dev/null "http://localhost:8080/api/tests/memory?mb=12"; done
sleep 70
curl -s http://localhost:9090/api/v1/query --data-urlencode "query=$Q > 0.9" \
| jq -r '.data.result[] | "\(.metric.name) \(.value[1])"'
curl -s http://localhost:9090/api/v1/alerts | jq -r '.data.alerts[] | "\(.labels.alertname) \(.state)"'The expression returns a result (94% of the limit) and the alert goes to pending. After five minutes of the condition holding —the rule's for: 5m— it would move to firing and reach Alertmanager. That delay is deliberate: a memory spike during a one-off import should not page anybody; a sustained trend at 94% should.
And the reason for using a ratio rather than an absolute value is twofold. The first is about meaning: 480 MB is catastrophic for aurora-api (a 512 MB limit, one step away from the OOM killer and exit code 137) and completely normal for aurora-db (a 2 GB limit). An alert with a fixed threshold either floods you with false positives or detects nothing. The second is about maintenance: the day you raise the API's limit to 1 GB, the ratio-based rule is still correct without being touched, whereas an absolute threshold would have to be remembered and changed. And that "remembered" is precisely what never happens.
Conclusion
Aurora Libros is no longer an opaque box in motion. You know why a containerized application writes to stdout/stderr and not to files, you know the eight logging drivers and the trap half of them hide: with syslog, fluentd or gelf, docker logs stops working, so the solid strategy is json-file or local with rotation plus an agent that collects. And you have done the arithmetic that convinces anybody: 214 bytes per request is 7 GB a month from a single service, and without max-size and max-file that ends in a full-disk outage which also prevents its own cause from being logged.
Your API now emits structured JSON logs with level, service, version and a req_id that travels in the x-request-id header and lets you reconstruct the full trace of one specific request from among millions of lines. On top of that you build aggregation with Loki, Promtail and Grafana in an observability profile that stays out of the way in development, with LogQL queries ranging from "errors in the last hour" to the cache hit ratio, which turns an architectural decision into an observable metric.
On the metrics side you know docker stats' limits, the daemon's Prometheus endpoint, and cAdvisor and node-exporter feeding Prometheus and Grafana. And you know what to watch: memory and CPU against the limit —never in absolute terms—, restarts, health, p95 latency, error rate and disk; with alerts that carry a for and that distinguish who they wake up. You close with a /health that checks PostgreSQL and Redis and returns 503 when something fails, making the HEALTHCHECK, depends_on and the load balancer react on their own, and with OpenTelemetry noted down as the next step.
In lesson 05-07, the last of the module, the circle closes: you will go down to the Linux kernel to see that a container does not exist. It is an ordinary process with three mechanisms on top —namespaces, cgroups and a layered filesystem— and you are going to touch each of them: the seven namespaces with lsns and nsenter, aurora-db's cgroups v2 files verifying that they match exactly the limits you set in 03-07, the real OverlayFS mount with its copy-on-write, capabilities decoded with capsh, and exactly what runc does when it starts a container.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
