System Design / Practical · Part 1
ZooKeeper practical with Docker
Bring our zoo to life with three ZooKeeper servers on your computer.
1. Install Docker Desktop
Download Docker Desktop for your operating system, run the installer, and follow its instructions. On Mac, choose the download for your chip: Apple silicon or Intel.
Open Docker Desktop after installation and wait for the engine to start. Docker runs each ZooKeeper server in a container, an isolated environment for the application. Docker Desktop includes Compose, which starts several containers from one configuration file.
2. Check Docker is ready
Open Terminal on macOS or Linux, or PowerShell on Windows. First, check that Docker and Compose are installed:
docker --version
docker compose versionBoth commands should print a version number. These checks confirm installation; the next command checks whether the Docker engine is actually running:
docker infoLook for a Server section with engine details. If you see “Cannot connect to the Docker daemon”, open Docker Desktop, wait for it to finish starting, and run the command again. If Docker is not recognised, reopen your terminal after installation.
Finally, run a small test container:
docker run --rm hello-worldSuccess prints Hello from Docker!. The first run downloads the image and needs internet access.
3. Create the ZooKeeper Compose file
Create a folder named zookeeper-docker. Save the configuration below as compose.yaml inside it. This lesson needs only this one file: three ZooKeeper servers, with no animal applications.
Download compose.yaml ↓ · Download Part 1 (.zip) ↓
services:
zoo1:
image: zookeeper:3.9.5
hostname: zoo1
restart: unless-stopped
ports:
- "127.0.0.1:2181:2181"
environment:
ZOO_MY_ID: "1"
JVMFLAGS: "-Dzookeeper.extendedTypesEnabled=true"
ZOO_SERVERS: "server.1=zoo1:2888:3888;2181 server.2=zoo2:2888:3888;2181 server.3=zoo3:2888:3888;2181"
volumes:
- zoo1-data:/data
- zoo1-log:/datalog
zoo2:
image: zookeeper:3.9.5
hostname: zoo2
restart: unless-stopped
ports:
- "127.0.0.1:2182:2181"
environment:
ZOO_MY_ID: "2"
JVMFLAGS: "-Dzookeeper.extendedTypesEnabled=true"
ZOO_SERVERS: "server.1=zoo1:2888:3888;2181 server.2=zoo2:2888:3888;2181 server.3=zoo3:2888:3888;2181"
volumes:
- zoo2-data:/data
- zoo2-log:/datalog
zoo3:
image: zookeeper:3.9.5
hostname: zoo3
restart: unless-stopped
ports:
- "127.0.0.1:2183:2181"
environment:
ZOO_MY_ID: "3"
JVMFLAGS: "-Dzookeeper.extendedTypesEnabled=true"
ZOO_SERVERS: "server.1=zoo1:2888:3888;2181 server.2=zoo2:2888:3888;2181 server.3=zoo3:2888:3888;2181"
volumes:
- zoo3-data:/data
- zoo3-log:/datalog
volumes:
zoo1-data:
zoo1-log:
zoo2-data:
zoo2-log:
zoo3-data:
zoo3-log:
Each server has a unique ZOO_MY_ID and the same ensemble membership list. They elect one leader and two followers. Named volumes preserve their data. This learning ensemble runs on one computer.
If you already started the previous eight-container example, run docker compose down using its old file first, then replace that file with this version. Saved named volumes are kept.
4. Start the three servers
Open Terminal or PowerShell in the folder containing compose.yaml. Run:
docker compose config --quiet
docker compose up -d
docker compose psExpect three running services: zoo1, zoo2, and zoo3. The first start downloads the image. After a few seconds, check their roles:
docker compose exec zoo1 zkServer.sh status
docker compose exec zoo2 zkServer.sh status
docker compose exec zoo3 zkServer.sh statusExpect one Mode: leader and two Mode: follower results. Any server can be elected leader. If they are still starting, wait and repeat the check.
5. Create znodes from the terminal
Open the ZooKeeper command-line client inside the first container:
docker compose exec zoo1 zkCli.sh -server zoo1:2181Wait for CONNECTED. You are now at the ZooKeeper prompt; enter the following commands there, one line at a time.
Create a persistent water-limit entry
create /zoo
create /zoo/water-limit "1000 L"
ls /zoo
get /zoo/water-limitExpect Created /zoo/water-limit and the value 1000 L. If a path already exists from an earlier run, skip its create command; use set to change its data.
set /zoo/water-limit "800 L"
get /zoo/water-limitThe value is now 800 L. This persistent znode stays after you leave the client.
Create an ephemeral status entry
create -e /zoo/zebra-status "available"
get /zoo/zebra-statusThis entry belongs to your terminal client’s session. Keep this client open for the next step.
Inspect a znode with stat
The stat command shows metadata such as creation time, version, number of children, and session ownership. Run these commands in the same ZooKeeper client while Zebra’s ephemeral entry still exists:
stat /zoo/water-limit
stat /zoo/zebra-statusLook at ephemeralOwner:
ephemeralOwner = 0x0: no client session owns this znode. In our water-limit example, it is a persistent znode.- A nonzero session ID: this is an ephemeral znode owned by that session. Zebra’s status shows this value; yours will vary.
This also applies to the persistent and ephemeral sequential examples. However, 0x0 alone does not distinguish every znode type: in ZooKeeper 3.9.5, TTL and container znodes also report zero to clients, even though they have automatic cleanup rules.
Use get -s /zoo/water-limit when you want both the stored data and its metadata.
6. Observe the data from a second client
Open a second terminal in the same project folder and connect to another server:
docker compose exec zoo2 zkCli.sh -server zoo2:2181At this second ZooKeeper prompt, read the shared entries:
sync /zoo
ls /zoo
get /zoo/water-limit
get /zoo/zebra-statusYou should see the water limit and Zebra’s temporary status. Now type quit in the first ZooKeeper client. In the second client, run:
sync /zoo
ls /zoo
get /zoo/water-limitZebra’s status disappears when its owning session closes, while the water limit remains. After an unexpected disconnection, deletion waits for session expiry. Type quit in the second client when finished.
7. Watch the water limit change
Use two terminal windows in the project folder. The first client watches the water limit; the second changes it. Use the existing /zoo/water-limit znode from step 5.
Terminal 1: register a watch
Open a new ZooKeeper client:
docker compose exec zoo1 zkCli.sh -server zoo1:2181At its ZooKeeper prompt, read the current value and register a watch:
get -w /zoo/water-limitThe command prints the current value and registers a one-time watch. Keep this client open.
Terminal 2: change the value
Open another client:
docker compose exec zoo2 zkCli.sh -server zoo2:2181At this second ZooKeeper prompt, run:
set /zoo/water-limit "600 L"Terminal 1: receive the notification
The first client prints a watch event similar to this:
WATCHER::
WatchedEvent state:SyncConnected type:NodeDataChanged path:/zoo/water-limitThis is the alarm from our story. It tells the client that the data changed; it does not contain the new value. In the first ZooKeeper client, read the latest value and register another watch:
get -w /zoo/water-limitYou should see 600 L. Standard watches fire once, so registering again lets this client hear about another change. In the second client, run:
set /zoo/water-limit "500 L"The first client receives another notification. Type quit in both ZooKeeper clients before stopping the setup.
8. Create sequential znodes
Open a ZooKeeper client again from your normal terminal:
docker compose exec zoo1 zkCli.sh -server zoo1:2181Persistent sequential: numbered announcements
At the ZooKeeper prompt, create a parent and two announcements. If the parent already exists, skip its create command.
create /zoo/announcements
create -s /zoo/announcements/notice- "Water opens at 9 AM"
create -s /zoo/announcements/notice- "Water closes at 6 PM"
ls /zoo/announcementsEach create command prints its full path, such as /zoo/announcements/notice-0000000000. The next gets a higher suffix; your numbers may differ. These entries remain after this client closes. To read one, type get followed by the exact path printed by your create command.
Ephemeral sequential: temporary queue entries
create /zoo/water-queue
create -e -s /zoo/water-queue/animal- "zebra"
create -e -s /zoo/water-queue/animal- "bear"
ls /zoo/water-queueBoth entries receive numbered names. Because this terminal created both, they belong to the same client session and both disappear when it closes. The numbering can support queue logic; ZooKeeper does not automatically grant access to water.
Type quit, reopen the client with the terminal command above, and compare:
ls /zoo/announcements
ls /zoo/water-queueThe persistent announcements remain; the two temporary queue entries are gone.
9. Create a TTL znode
TTL means “time to live”. Use it for a temporary zoo notice. The supplied Compose file already enables TTL on all three servers with -Dzookeeper.extendedTypesEnabled=true, so no configuration changes are needed.
If you still have a ZooKeeper client open from the previous step, use it. Otherwise, open one from your normal terminal:
docker compose exec zoo1 zkCli.sh -server zoo1:2181At the ZooKeeper prompt, run each command separately. First, create a notice with a TTL of 10,000 milliseconds (10 seconds):
create -t 10000 /zoo/temporary-notice "Water trough cleaning"Read it immediately, before its TTL expires:
get /zoo/temporary-noticeLeave it unchanged and without children. After its TTL passes, it becomes eligible for automatic deletion. Cleanup runs periodically, so deletion may take longer than 10 seconds. Wait, then check:
get /zoo/temporary-noticeAfter cleanup, expect Node does not exist. If the notice remains, wait longer and check again. Updating its data with set resets its TTL timer; reading with get does not. Closing the client does not delete a TTL node immediately.
If the path already exists, wait for cleanup before creating it again. Type quit when finished.
10. Monitor quorum and ZAB
Keep the three servers running for this section. Normal logs show elections and connection problems. Optional detailed logging helps you follow a write through ZAB.
Follow server logs
docker compose logs -f --tail=100 zoo1 zoo2 zoo3Keep this terminal open. Docker prefixes each message with its server name. LOOKING means searching for a leader, LEADING means acting as leader, and FOLLOWING means following the elected leader. These are states, not a live count of acknowledgements.
Observe quorum loss and recovery
In another terminal, stop one server:
docker compose stop zoo3Two voting servers remain: a quorum. If the stopped server was leader, wait for a new election. Connect to zoo1 and update the existing water-limit znode:
docker compose exec zoo1 zkCli.sh -server zoo1:2181set /zoo/water-limit "700 L"
get /zoo/water-limitThe read should return 700 L. Leave this client open. In a third normal terminal, stop another server:
docker compose stop zoo2Only zoo1 remains. After failure detection, it cannot commit new writes. Observe connection errors or election activity in the logs. At the existing ZooKeeper prompt, attempt:
set /zoo/water-limit "650 L"The client may wait, disconnect, or report a connection error; the exact output varies. Restore the stopped servers from the third terminal:
docker compose start zoo2 zoo3Wait for election and synchronisation. Reconnect the CLI if needed and read the water limit again. An operation that was still pending may complete after recovery, so check the actual value. Type quit to exit the client; Ctrl+C exits log following without stopping the servers.
Enable detailed ZAB logs
Download these two files and place them beside compose.yaml. They are also included in the updated Part 1 ZIP.
- compose-monitoring.yaml adds logging settings to the existing three servers and keeps TTL enabled.
- logback-zab.xml enables quorum TRACE messages and request-processing DEBUG messages in the console.
Use the extra Compose file to turn detailed logging on:
docker compose -f compose.yaml -f compose-monitoring.yaml config --quiet
docker compose -f compose.yaml -f compose-monitoring.yaml up -dThis recreates the servers with the new logging settings. Wait for the ensemble to recover, then follow fresh logs:
docker compose logs -f --since=1m zoo1 zoo2 zoo3In another terminal, open a fresh client:
docker compose exec zoo1 zkCli.sh -server zoo1:2181At the ZooKeeper prompt, make a change and inspect its metadata:
set /zoo/water-limit "900 L"
get -s /zoo/water-limitThe mZxid field identifies the transaction that last changed this znode’s data. Use that transaction ID to correlate messages across servers; timestamps alone can be misleading.
Proposing::identifies leader proposal activity.Ack zxid:identifies acknowledgement processing at the leader.- Quorum and request-processing messages help follow commit and application activity for the same transaction.
Do not count matching log lines as votes: messages can repeat, and a leader also acknowledges its own proposal. Logs are diagnostic details rather than a guaranteed four-line trace for every write. Wording and detail vary by ZooKeeper version.
Return to normal logging
Exit the CLI and log follower, then recreate the servers using only the base file:
docker compose -f compose.yaml up -d --force-recreateThis removes the optional logging configuration from the running containers and retains named data volumes.
Logging details checked against ZooKeeper 3.9.5 leader implementation and its Logback configuration.
11. Stop the setup
Back in your normal terminal, run:
docker compose downThe containers stop and are removed. Named volumes keep your persistent znodes for the next session.
Command syntax: Apache ZooKeeper CLI guide. TTL configuration: Apache administration guide. Configuration: official ZooKeeper Docker image.
Part 2Add the five zoo services →
Use application containers to register animals and watch the water limit.