← 개발 로그 목록

website: DB GUI 셀프서비스 스킬에 SSH 터널링 트러블슈팅 3건 반영하기

/ 5분 분량 / 개발 로그

DB 조회용 GUI(sqlite-web)를 팀원이 편하게 켤 수 있게 만들다가 예상 못한 문제를 세 번 연달아 만났고, 그 과정을 스킬 문서와 learnings에 정리해서 dev에서 main으로 승격했다.

nologin 계정에 forced command를 걸었는데 안 먹힌 이유

dbtunnel 계정은 SSH 터널(조회 전용)만 허용하고 로그인 셸 접근은 막고 싶어서, authorized_keys에 command="echo tunnel-only-account"를 걸고 셸은 nologin으로 뒀다. 실제 키로 접속 테스트를 해보니 이 안내 문구 대신 nologin 기본 메시지("This account is currently not available.")만 떴다. 왜 그런지 찾아보니 sshd가 forced command를 실행할 때 내부적으로 사용자셸 -c "forced-command" 형태로 호출하는데, 셸이 nologin이면 인자를 통째로 무시하고 자기 메시지만 찍는 거였다. 버그는 아니고 오히려 의도보다 강한 이중 방어였다 — nologin이 exec 채널을 독자적으로 완전히 막고 있어서 command= 문구가 실행되든 안 되든 어차피 셸 접근은 안 된다. 반면 포트포워딩(-L)은 로그인 셸을 거치지 않고 sshd가 직접 처리하므로 이 문제와 무관하게 정상 동작했다. 커스텀 메시지가 꼭 필요하면 nologin 대신 /bin/sh -c 'echo ...; exit 1' 같은 최소 셸을 써야 한다는 걸 learnings에 남겼다.

GHCR public 이미지인데 denied

sqlite-web을 붙이려고 ghcr.io/coleifer/sqlite-web:latest를 pull했는데 계속 denied가 났다. public 이미지라 권한 문제일 리 없다고 생각했는데, 원인은 우리 CD(cd.yml)가 배포마다 docker login ghcr.io를 secrets.GITHUB_TOKEN으로 새로 거는 구조였고, 이 토큰이 likelion-khu-official/website 소속 패키지에만 스코프돼 있던 것이었다. GHCR은 인증정보가 실려 있으면 그 identity의 명시적 권한만 보고 판단하지, 대상 패키지가 public이라고 익명 접근으로 자동 전환해주지 않는다. docker logout ghcr.io 후 재시도하니 바로 됐다. CD가 매 배포마다 로그인을 새로 걸기 때문에 이렇게 임시로 로그아웃해도 다음 배포가 알아서 되돌려놓는다는 점도 확인했다.

arm64 호스트에서 exec format error

로그인 문제를 풀고 나니 컨테이너가 Restarting (255)를 반복했다. docker compose ps엔 원인이 안 보이고 docker compose logs를 봐야 exec /usr/local/bin/sqlite_wsgi: exec format error가 드러났다. 이전에 backend Dockerfile에서 겪은 arm64 플랫폼 불일치와 같은 계열이지만, 이번엔 우리가 빌드하는 이미지가 아니라 커뮤니티 이미지라 Dockerfile을 고칠 수 없었다. 그래서 재빌드 대신 docker run --privileged --rm tonistiigi/binfmt --install all로 QEMU 에뮬레이션을 호스트에 등록하는 쪽을 택했다. 저트래픽 admin 도구라 에뮬레이션 성능 저하는 무의미한 수준이었다. 다만 이건 컨테이너 하나의 설정이 아니라 호스트 커널 전역 설정이라, 이후 다른 amd64 이미지도 이 호스트에서 자동으로 에뮬레이션 실행된다는 점을 기억해둬야 한다.

스킬 문서에 반영한 실제 운영 방식

이 세 가지를 겪고 나니 "GUI 열어줘"라는 요청이 왔을 때 Claude가 그 자리에서 바로 처리할 수 있는 흐름을 정리해야겠다 싶었다. Windows는 WSL을 거치는 게 당연하다고 생각했는데, 실제로 해보니 Git Bash의 경로 변환과 DrvFs 권한(/mnt/c가 chmod 안 먹는 문제) 때문에 WSL 경유가 오히려 더 복잡했고, PowerShell 내장 ssh.exe로 바로 되는 걸 확인했다. 그래서 Windows는 네이티브 OpenSSH를 기본으로 안내하도록 바꿨다.

또 하나, GUI 터널만 열고 조작(dbclient)은 나중에 따로 요청하면 그때 처리하는 방식으로 처음 만들었는데, 사용자가 dbclient도 같이 뜨길 기대했는데 안 떠서 지적한 일이 있었다. db-dev-ui.sh의 원래 설계 의도(tmux 분할창에 조회+조작 같이)를 그대로 살려서, tmux가 없는 환경에서도 처음부터 GUI와 dbclient를 페어로 열도록 수정했다. GUI는 SELECT만 되고 DML/DDL은 안 된다는 걸 매번 헷갈릴 수 있어서, 이 표를 요청이 올 때마다 같이 출력하도록 스킬에 못 박았다.

세션 종료 처리에서는 실수를 하나 했다. pkill -f "ssh ..."처럼 문자열 매칭으로 죽이려다가, 그 kill 명령 자체가 실행되는 순간 자기 자신의 프로세스 목록과 매칭돼서 의도치 않게 죽는 사고가 났다. 그래서 포트로 소유 프로세스를 찾아 죽이는 방식(lsof -ti tcp:<PORT> | xargs kill, Windows는 Get-NetTCPConnection)으로 바꿨다. 문자열 매칭은 편해 보이지만 실행 시점에 자기 자신을 포함할 수 있다는 걸 이번에 확인한 셈이다.

결과적으로 SKILL.md에는 30줄 넘게 추가됐고, learnings.md에는 이번에 겪은 SSH forced command, GHCR 스코프, arm64 에뮬레이션 세 가지를 다음에 똑같은 삽질을 반복하지 않도록 기록해뒀다.