I set up my first CI pipeline today. It failed on the first real run —
not because of a typo, but because the pipeline wasn’t actually testing
what my app needed. Figuring out why turned into a short debugging
exercise that was more useful than if everything had just passed.
What CI/CD actually means
The idea is simple: instead of manually checking “does this still work?”
every time you push code, you let a server do it automatically.
CI (Continuous Integration) is that automatic check — build the project,
run it, confirm nothing broke. CD (Continuous Deployment) goes a step
further and automatically ships the working version somewhere. This
project only covers CI: making sure the app builds and responds correctly
on every push to main.
The workflow file
GitHub looks for automation instructions in one specific place:
.github/workflows/. Any YAML file there gets picked up automatically.
name: Docker Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Start services with docker compose
run: docker compose up -d --build
- name: Wait for app to start
run: sleep 5
- name: Check app responds
run: curl --fail http://localhost:5000
- name: Show logs on failure
if: failure()
run: docker compose logs
- name: Stop services
run: docker compose down
Enter fullscreen mode Exit fullscreen mode
A quick walk through what happens on every push:
-
on: push: branches: [main]— this runs automatically whenever something is pushed tomain. -
runs-on: ubuntu-latest— GitHub spins up a fresh, temporary Ubuntu machine just for this job. -
Checkout codepulls the repository onto that machine — without it, there’d be nothing to build. -
docker compose up -d --buildstarts the whole stack, rebuilding the image fresh each time. - A short
sleepgives the app time to actually start listening before anything tries to reach it. -
curl --failis the actual test — if the app doesn’t respond correctly, this step fails and the whole pipeline turns red. - The logs step only runs
if: failure()— it stays silent when everything’s fine, and prints container logs when something isn’t. -
docker compose downcleans up either way.
The first version — and why it failed
My first attempt at this workflow didn’t use docker compose at all —
I just reused the command from before I’d added Redis to the project:
- name: Run container
run: docker run -d -p 5000:5000 --name test-container hello-flask
Enter fullscreen mode Exit fullscreen mode
It ran. It failed. The error:
curl: (22) The requested URL returned error: 500
Enter fullscreen mode Exit fullscreen mode
A 500 is a specific kind of failure — it means the server responded, but
something broke while handling the request. That ruled out a timing
issue right away; if the app hadn’t started yet, curl would have said
“connection refused,” not returned an actual HTTP error.
Looking at app.py narrowed it down further. There’s exactly one thing
in that file that can throw an unhandled error: the call to Redis.
count = cache.incr("hits")
Enter fullscreen mode Exit fullscreen mode
And that’s when it clicked — the app had been updated to require Redis,
but the CI workflow was still starting the app on its own, the way it
used to run before Redis was part of the picture. The container had
nothing to talk to.
The fix — and what it says about testing
The fix was straightforward once the cause was clear: stop starting the
app in isolation, and start the whole stack the same way it actually
runs.
- name: Start services with docker compose
run: docker compose up -d --build
Enter fullscreen mode Exit fullscreen mode
This single line replaced the manual docker run step. Instead of
starting one container in isolation, it reads docker-compose.yml and
brings up everything the app actually depends on — in this case, both
web and redis. Once Redis was actually there to answer, the app
stopped failing.
The bigger lesson wasn’t really about Docker or YAML syntax. It was that
a CI check is only useful if it tests the thing you actually run. My
first version passed a build check, but it wasn’t testing my app — it
was testing an older version of it that no longer existed in the code.
What actually mattered
- A 500 error and a connection failure mean different things — the error code itself is a clue
- CI configuration can quietly drift out of sync with the actual app, especially when you copy an old command instead of updating it
- Adding a logging step for failures (
if: failure()) cost nothing to write and will save time the next time something breaks - A pipeline that always passes isn’t necessarily testing anything meaningful — this one only became useful once it could actually fail for the right reason
답글 남기기