Article 28부터 31까지 만든 route, SQLite 저장소, background worker, readiness를 따로 확인하는 것만으로는 배포 경계를 보장할 수 없습니다. 실제 listener가 요청을 받았지만 worker가 결과를 저장하지 못할 수 있고, 종료 응답 뒤에 수락한 작업이 사라질 수도 있습니다. 마지막 단계에서는 HTTP, queue, database, process lifecycle을 한 번에 통과하는 테스트가 필요합니다.
이 capstone fixture는 Rust 2024와 고정된 Axum 0.8.6, Tokio 1.53.1, SQLx 0.8.6을 사용합니다. 테스트마다 임시 SQLite file과 127.0.0.1:0 listener를 만들고 실제 HTTP client로 요청합니다. 성공 경로만 확인하지 않습니다. worker failure, 중복 입력, 없는 endpoint, 잘못된 database path, 종료 중 drain까지 같은 실행 파일을 거쳐 검사합니다.
1. unit test보다 바깥의 경계를 정합니다
Router에 oneshot 요청을 보내면 routing, extractor, middleware를 빠르게 검증할 수 있습니다. 하지만 listener bind, connection 종료, process shutdown은 지나가지 않습니다. 이 글의 fixture는 ephemeral port에 실제 server를 띄우고 응답을 받습니다. 외부 network는 사용하지 않으며 HTTP check 자체는 scripted://ok, scripted://fail, scripted://slow로 결정적으로 재현합니다.
테스트 하나가 하나의 임시 directory를 소유합니다. migration을 적용한 file database, server task, worker task가 그 안에서 시작됩니다. 공유 port나 공유 database가 없으므로 병렬 실행 순서가 결과를 바꾸지 않습니다. RunningApp이 listener address와 shutdown handle을 반환해 test가 OS가 선택한 port만 사용하게 합니다.
2. API, queue, database를 한 번에 지나갑니다
성공 테스트는 POST /endpoints로 row를 만든 뒤 POST /endpoints/{id}/checks를 호출합니다. handler는 bounded Tokio channel에 ID를 넣고 202 Accepted를 반환합니다. worker는 SQLite에서 URL을 다시 읽고 outcome을 계산한 다음 endpoint 최신 상태와 check history를 한 transaction에 저장합니다. 마지막 GET은 저장된 204를 확인합니다.
이 흐름에서는 handler의 mock repository를 통과하지 않습니다. migration, SQL placeholder, serialization, queue ownership, worker transaction이 모두 실제 코드입니다. 반대로 외부 DNS나 TLS는 대상이 아닙니다. container deployment gate가 third-party network 상태에 흔들리지 않게 의도적으로 끊은 경계입니다.
scripted://fail은 status 없이 scripted transport failure를 저장합니다. 실패 결과도 history row이므로 관측에서 사라지지 않습니다. 중복 ID는 409, 없는 endpoint의 check 요청은 404, directory를 database file로 넘긴 startup은 오류를 반환합니다. 실패를 panic이나 timeout으로 뭉개지 않고 어느 경계가 거절했는지 남깁니다.
3. 종료는 HTTP를 닫은 뒤 수락한 작업을 drain합니다
정상 종료에는 순서가 있습니다. readiness를 먼저 false로 바꾸고 graceful-shutdown future를 완료합니다. Axum server가 새 connection을 받지 않고 기존 request를 마칠 때까지 기다립니다. Router가 drop되면 마지막 queue sender도 사라집니다. worker는 channel에 남은 ID를 모두 처리하고 recv()가 None을 반환한 뒤 종료합니다. 마지막으로 join handle을 기다립니다.
#[tokio::test]
async fn shutdown_stops_http_and_drains_accepted_work() {
let harness = Harness::start().await;
harness.create("slow", "scripted://slow").await;
assert_eq!(harness.enqueue("slow").await.status(), StatusCode::ACCEPTED);
let address = harness.app.address;
let report = harness.app.shutdown().await.unwrap();
assert_eq!(report.completed_checks, 1);
assert!(harness.client
.get(format!("http://{address}/health/ready"))
.send().await.is_err());
}
이 테스트는 느린 scripted check를 enqueue한 직후 종료합니다. 종료 결과의 완료 수가 1이어야 하며 listener에는 다시 연결할 수 없어야 합니다. 실행 뒤 database를 새 pool로 열어 204 history가 남았는지도 별도로 검사합니다. shutdown method가 반환됐다는 사실만 확인해서는 drain을 증명할 수 없습니다.
종료 deadline은 fixture에 넣지 않았습니다. 실제 서비스는 제한 시간이 지난 뒤 남은 HTTP check를 취소할지, process를 강제 종료할지 정해야 합니다. 여기서는 이미 수락한 짧은 작업을 잃지 않는 계약을 고정합니다.
4. lockfile을 release gate의 입력으로 취급합니다
Cargo.toml의 exact version만으로 transitive dependency graph가 고정되지는 않습니다. fixture는 생성된 Cargo.lock을 저장하고 모든 CI·release 명령에 --locked를 붙입니다. lockfile이 없거나 resolver가 바꾸려 하면 build가 실패합니다.
cd examples/article-32-axum-integration-testing-container
cargo fmt --all -- --check
cargo check --locked --all-targets --all-features
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo test --locked --all-features
cargo test --locked --release --all-features
cargo build --locked --release
debug test와 release test를 모두 실행합니다. optimization 때문에 달라지는 동작을 놓치지 않으면서 release binary가 실제로 link되는지도 확인합니다. 여덟 테스트는 API/database round trip, worker 성공·실패, 입력 실패, readiness, startup failure, shutdown drain, Dockerfile 구조를 다룹니다.
--locked는 dependency resolution을 재현하는 장치입니다. compiler, linker, base image, build timestamp까지 같게 만들지는 않습니다. byte-identical artifact가 필요하면 toolchain과 base image digest, build environment, timestamp 정책을 추가로 고정해야 합니다.
5. build stage와 runtime stage를 분리합니다
builder stage에는 Rust toolchain과 dependency source가 필요하지만 runtime image에는 binary와 health probe만 있으면 됩니다. multi-stage Dockerfile은 release binary 하나만 다음 stage로 복사합니다. runtime에서는 numeric UID 10001로 전환하고 writable data를 /data에 한정합니다.
FROM rust:1.98.1-bookworm@sha256:ae1a730a949f727611a5c684e1e26e5a9bb9885b34f65a442744ca8a61c86ca5 AS builder
WORKDIR /work
COPY Cargo.toml Cargo.lock ./
COPY migrations ./migrations
COPY src ./src
RUN cargo build --locked --release
FROM debian:bookworm-slim@sha256:88200866dfff7ea7f5cbcb6ec7c8a701889efe6fe859fe64d6990e4b07ea4171 AS runtime
RUN mkdir -p /data && chown 10001:10001 /data
COPY --from=builder /work/target/release/article-32-axum-integration-testing-container /usr/local/bin/monitor-api
USER 10001:10001
ENV BIND_ADDR=0.0.0.0:3000 DATABASE_PATH=/data/monitor.sqlite
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=2s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/monitor-api", "--healthcheck"]
ENTRYPOINT ["/usr/local/bin/monitor-api"]
HEALTHCHECK는 release binary의 --healthcheck mode로 /health/ready를 호출합니다. migration, pool, worker가 준비된 뒤에만 application이 listener를 반환하므로 container가 traffic을 받기 전에 startup failure를 드러낼 수 있습니다. probe가 존재해도 orchestrator의 readiness 설정이 자동으로 생기는 것은 아닙니다. 사용하는 platform에서 이 command와 interval을 연결해야 합니다.
두 FROM은 확인한 multi-platform manifest digest까지 고정합니다. Cargo lockfile과 base manifest가 함께 바뀌지 않으면 같은 dependency와 base filesystem을 선택합니다. 다만 compiler output의 byte identity에는 host architecture, BuildKit, linker, timestamp 같은 입력도 영향을 줄 수 있습니다.
6. 작은 runbook으로 운영 경로를 닫습니다
image build와 실행은 fixture directory에서 수행합니다. SQLite file은 named volume에 둡니다.
docker build --pull --tag endpoint-monitor:article-32 .
docker run --rm --name endpoint-monitor \
-p 3000:3000 \
-v endpoint-monitor-data:/data \
endpoint-monitor:article-32
준비 상태, endpoint 생성, check enqueue, 결과 조회를 차례로 확인합니다.
curl --fail http://127.0.0.1:3000/health/ready
curl --fail -H 'content-type: application/json' \
-d '{"id":"demo","url":"scripted://ok"}' \
http://127.0.0.1:3000/endpoints
curl --fail -X POST http://127.0.0.1:3000/endpoints/demo/checks
curl --fail http://127.0.0.1:3000/endpoints/demo/checks
docker stop endpoint-monitor
docker stop은 기본적으로 process에 종료 signal을 보내므로 binary의 signal path가 Axum shutdown과 worker drain을 실행합니다. readiness가 실패하면 먼저 container log와 /data 쓰기 권한을 확인합니다. migration을 바꾸기 전에는 volume을 backup하고 이미 적용한 migration file은 수정하지 않습니다.
fixture의 HTTP checker는 scripted adapter입니다. production DNS, TLS, timeout, proxy header는 실제 adapter와 배포 환경에서 별도 검증해야 합니다. 그래도 이 capstone은 이전 글의 계약이 하나의 process에서 연결되는지 보여 줍니다. API가 수락한 작업은 database history가 되거나 명시적인 실패로 남고, 정상 종료는 그 경계가 닫힐 때까지 기다립니다.
전체 소스 코드
이 글의 전체 실행 가능한 소스는 GitHub의 Chapter 32 프로젝트에서 확인할 수 있습니다.
답글 남기기