= [Dev] Deploying [Dev] `deploy.sh` 는 로컬에서 빌드해 서버에 새 릴리스로 올린다. 서버에 이미 `ssh` 가 되고 거기서 `sudo -n` 이 되는 기계에서 돌린다. [[[#!Vim bash AHAWIKI_DEPLOY_HOST= AHAWIKI_HEALTH_HOST= bash deploy.sh ]]] 어디에 배포하는지는 전부 환경변수에서 온다. 이 저장소는 공개라서 자기 기계 이름을 하나도 적지 않는다. 그것이 경계의 전부이고, 한 문장으로 적을 가치가 있다: '''배포가 어떻게 도는지는 이 저장소가 갖고, 그것이 어느 기계에서 일어나는지는 운영을 담는 쪽이 갖는다.''' 단계·순서·헬스체크·태그 형식·롤백은 전부 여기, 그 모양을 결정하는 코드 옆에 있다. 호스트명·계정·앞단 리버스 프록시는 저쪽에 있고, 이것을 부르는 데는 값들과 한 줄이면 된다: [[[#!Vim bash AHAWIKI_DEPLOY_HOST=… AHAWIKI_HEALTH_HOST=… AHAWIKI_VERIFY_URLS="… …" bash /path/to/AhaWiki/deploy.sh ]]] 절차의 사본을 저쪽에도 두면 같은 사실이 두 곳에 있게 되고, 반드시 갈라진다 — 이것이 대체한 사본은 아무도 읽지 않는 디렉터리에 쓸 만큼 낡아 있었다. [[[#!Table tsv `AHAWIKI_DEPLOY_HOST` 필수 — ssh 호스트 또는 alias `AHAWIKI_HEALTH_HOST` 필수 — 위키 중 하나가 답하는 도메인. `AHAWIKI_VERIFY_URLS` 를 안 주면 `https://<이 값>/` 이 확인 대상이 된다. 이름과 달리 5단계의 `/hc` 폴링은 안 쓴다 — `/hc` 는 Host 헤더가 필요 없다. 그 뒤 프록시 기다림에는 첫 확인 URL 로 쓰인다 `AHAWIKI_REMOTE_ROOT` `/opt/ahawiki` `AHAWIKI_SERVICE_USER` `ahawiki` `AHAWIKI_PORTS` `10001 10000` — 재시작 순서 `AHAWIKI_VERIFY_URLS` 끝나고 확인할 공개 URL 들, 공백 구분. 첫 URL 은 5단계에서도 쓴다 — 인스턴스 하나를 재시작한 뒤 프록시가 다시 답할 때까지 이것을 기다린다 `AHAWIKI_KEEP_RELEASES` `3` `SKIP_TAG` `1` 이면 배포 태그 생략 ]]] == 무엇을 하는가 릴리스는 `releases/` 아래 나란히 놓이고 `current` 가 그중 하나를 가리키는 심볼릭 링크라서, 배포는 링크를 옮기는 것이고 롤백은 도로 옮기는 것이다: [[[#!Vim bash H=; S= ssh $H "sudo -n -u ahawiki ln -sfn /opt/ahawiki/releases/ /opt/ahawiki/current" for p in 10001 10000; do ssh $H "sudo -n systemctl restart ahawiki@$p" until [ "$(ssh $H "curl -s -o /dev/null -w '%{http_code}' --max-time 5 http://127.0.0.1:$p/hc")" = 200 ]; do sleep 3; done until [ "$(curl -sL -o /dev/null -w '%{http_code}' --max-time 15 https://$S/)" = 200 ]; do sleep 3; done done ]]] "하나 재시작, `sleep 20`, 다른 하나 재시작" 보다 길고, 두 `until` 줄이 그 이유다. 롤백은 이미 무언가 잘못됐을 때 도는데, 그때는 20초가 모자랐다는 것을 발견하기에 최악의 순간이다 — 인스턴스가 4초 만에 돌아왔는데 20초를 앉아 기다리는 것도 마찬가지다. 두 줄은 `deploy.sh` 가 기다리는 두 조건 그대로이고(이유는 아래), 둘째 줄은 군더더기가 아니다: loopback 에서 답하는 인스턴스가 곧 프록시가 트래픽을 보내 주는 인스턴스인 것은 아니다. 이 문서의 롤백 절차는 2026-08-16 까지 `sleep 20` 이라고 적혀 있었다 — 배포 경로가 초 세기를 그만둔 지 한참 뒤까지. `cache/` 와 `logs/` 는 `shared/` 에 살고 각 릴리스에 링크로 들어간다. 어느 릴리스보다도 오래 살기 때문이다. systemd 인스턴스 둘(`ahawiki@`)이 리버스 프록시 뒤에 있다. '''둘 다 요청을 받는다''' — 2026-09-10 에 `[[Uptime]]` 을 여덟 번 렌더링하니 JVM 시작 시각 둘이 5:3 으로 번갈아 나왔다. `/w/` 를 연달아 부르면 봇 차단에 걸리므로 `/w/` 밖의 경로로 잰다: [[[#!Vim bash for i in 1 2 3 4 5 6 7 8; do curl -s -X POST -H 'content-type: application/json' -d '{"comment":"[[Uptime]]"}' https://ahawiki.net/api/renderAhaMark/FrontPage; echo; sleep 1; done | sort | uniq -c ]]] 그래서 인스턴스마다 따로 있는 메모리 상태는 서로를 모른다 — 캐시는 [Dev Cache] 의 «인스턴스가 둘이다», 실시간 이벤트는 [Dev WebSocket]. `AHAWIKI_KEEP_RELEASES` 를 넘는 옛 릴리스는 마지막에 지워진다. `current` 가 가리키는 것은 건너뛴다. '''스크립트의 모든 원격 블록은 `set -e` 로 시작하고, 지우는 블록이 그것이 여전히 그런지 확인해야 하는 이유다.''' 없으면 `releases/` 로의 `cd` 가 실패해도 아무것도 멈추지 않는다: 루프는 로그인 사용자의 홈 디렉터리를 목록하며 계속 돌고, 거기서는 "이것이 current 인가" 검사에 아무것도 걸리지 않아, 눈에 띄는 가장 오래된 항목들에 `sudo rm -rf` 를 돌린다. 실패가 아무도 종료코드를 읽지 않는 명령 안에 있어서, 배포는 어느 쪽이든 성공을 보고한다. == 왜 이런 모양인가 각각이 한 번씩 배포를 부순 값이다. * '''한 대씩, 그리고 프록시가 동의해야 한다.''' 둘을 같이 재시작하면 살아 있는 upstream 풀이 비어 독자가 502 를 본다. 다음 대를 내리기 전에 앞 대가 답해야 하고, 끝내 답하지 않으면 스크립트는 다른 대가 아직 서비스하는 채로 멈춘다. 자기 답만으로는 부족하다. 죽은 upstream 을 떨어뜨리는 프록시는 정해진 간격 동안 그것을 빼놓으므로, 인스턴스가 loopback 에서는 서비스 중인데 프록시는 여전히 아무것도 보내 주지 않을 수 있다 — 그 틈에 다른 대를 내리면 로테이션에 아무것도 남지 않는다. 그 외에는 멀쩡했던 배포에서 1초쯤의 502 로 값을 치렀다. 그래서 인스턴스 사이에서 스크립트는 그 간격에 맞춘 수를 자는 대신 '''프록시를 통한''' 요청이 돌아오기를 기다린다 — 수를 자면 그 간격이 두 곳에 적히게 된다. * '''헬스체크는 `/hc` 를 찌른다. 위키 페이지가 아니다.''' `/hc` 는 `SELECT 1` 로 DB 를, 그리고 디스크 여유를 확인하고 `OK` 를 답한다 — 재시작이 깨뜨릴 수 있는 것들이다. 사이트 매칭을 타지 않으니 `Host` 헤더도 필요 없다. 그리고 '''`/w/` 밖에 있어야 한다.''' `IpRateLimiter` 는 `/w/` 아래를 페이지 조회로, 나머지를 사람 신호로 세고, 짧은 창 안에서 페이지만 여러 번 요청한 주소를 차단한다(임계값과 그 이름은 [Dev BotDetection]). `deploy.sh` 의 폴링 간격(`sleep 3`)으로 `/w/FrontPage` 만 부르는 것이 정확히 그 정의다. 2026-09-04 에 이 루프가 다섯 번째 폴링에서 '''자기를 차단'''했다 — 남은 폴링은 403 과 tarpit 을 받았고, 인스턴스가 독자에게 정상 서비스하는 동안 배포는 `never became healthy` 로 죽었다. 차단은 `IpDeny` 에 행으로 남고 두 인스턴스가 그 테이블을 공유하므로, 고치지 않았다면 보관 기간 내내 모든 배포가 같은 자리에서 실패했다. 기동이 15초 안에 끝나면 네 번으로 통과한다. 그래서 몇 달간 멀쩡했고, 릴리스가 무거워지자 걸렸다. 폴링 창은 `AHAWIKI_HEALTH_TRIES`(기본 60 × 3초 = 180초)다. 2026-09-15 에는 두 인스턴스가 같이 뜨며 CPU 를 다투자 차가운 JVM+Play 기동이 첫 대에서 90초(옛 30회 고정)를 넘겨, 멀쩡한 기동을 배포가 실패로 보고하고 한 대를 옛 릴리스에 남긴 채 멈췄다 — 그때는 `current` 가 이미 새 릴리스를 가리키므로, 남은 한 대를 손으로 재시작해(새 릴리스를 집는다) 마쳤다. 더 느린 장비는 이 값을 올린다. '''사이트가 실제로 그려지는지'''는 5단계의 프록시 기다림과 6단계가 프록시를 통해, 화이트리스트에 든 주소에서 확인한다 — `/hc` 폴링이 넓게 볼 이유가 없다. 7단계는 태그일 뿐 아무것도 확인하지 않는다. * '''외부 확인은 리다이렉트를 따라간다.''' 303 을 답하는 첫 페이지는 그것이 어디로 가는지에 대해 아무 말도 하지 않는다. 뒤의 모든 페이지가 500 인 동안 303 으로 통과한 배포가 있었다. * '''외부 확인은 재시도한다.''' 인스턴스가 답한다고 프록시가 알아챈 것이 아니다. 죽은 upstream 을 정해진 간격 동안 떨어뜨리는 프록시는 회복한 뒤에도 남은 간격 동안 계속 떨어뜨리므로, 마지막 재시작이 헬스체크를 통과한 순간에 확인하면 멀쩡한 배포에서 502 를 읽는다 — 실제로 일어났고, 좋은 릴리스의 태그 하나를 값으로 치렀다. * '''업로드는 rsync 가 아니라 `tar` over ssh 다.''' Git Bash 에서 MSYS2 rsync 를 부르면 두 MSYS 런타임을 건너느라 인자가 뭉개져 도착하고, 아무것도 복사하기 전에 죽는다. * '''태그는 맨 마지막에 쓴다.''' 검증 뒤에 써서, "이것이 빌드됐다" 가 아니라 "이것이 서버에 닿아 답했다" 를 뜻하게 한다. * '''`conf/evolutions` 가 바뀐 배포는 백업을 먼저 본다.''' 스크립트는 이것을 하지 않는다 — 어느 데이터베이스인지가 운영 쪽 사실이라서다. 확인 명령과 멈춰야 할 값은 운영 저장소 `ahawiki/README.md` 에 있고, 읽기 전용 한 줄이다. 2026-09-02 에 evolution 둘이 이것 없이 나갔다. 안전망은 있었지만 그것을 안 것은 배포 뒤였고, 그날 두 번째 배포는 실제로 실패해 되돌려야 했다([Dev Database]). == JDK 를 갈면 재시작 전까지 프로세스 실행이 막힌다 돌고 있는 JVM 아래에서 JDK 패키지가 갈리면, 그 프로세스는 '''자식 프로세스를 하나도 띄우지 못한다.''' 예외는 `IOException: Cannot run program "sh": error=0, Failed to exec spawn helper` 이고, 까닭은 JVM 이 fork 할 때 쓰는 `jspawnhelper` 가 제가 떠 있는 JDK 의 것이라야 하는데 그 파일이 이미 다른 것으로 갈려 있기 때문이다. 돌고 있는 프로세스의 `/proc//exe` 가 `(deleted)` 로 보이는 것이 신호다. [[[#!Vim bash ssh "sudo -n readlink -f /proc/\$(systemctl show ahawiki@10001 -p MainPID --value)/exe" ]]] 2026-09-25 에 실제로 그 상태였다. 인스턴스는 9-20 19:06 부터 돌고 있었고, JDK 는 9-23 06:29 에 '''`unattended-upgrade`''' 가 갈았다(`/var/log/apt/history.log`: openjdk-17 17.0.20+8 → 17.0.20.1+1). 즉 사람이 한 일이 아니고, 다음 JDK 보안 업데이트에도 같은 일이 난다. AhaWiki 에서 자식 프로세스를 띄우는 곳은 `InterpreterVim` 의 `vi` 하나뿐이라, 증상은 '''캐시에 없는 Vim 블록을 그리는 문서가 전부 500''' 이었다 — 이미 캐시에 있는 블록은 멀쩡해서 사이트는 정상으로 보였다. 3일간 아무도 몰랐다. '''고치는 방법은 재시작뿐이다.''' 배포는 어차피 재시작하므로 배포 한 번이 곧 치료다. 다시 나지 않게 하려면 둘 중 하나인데 아직 어느 쪽도 하지 않았다. * 서버 쪽: apt 가 openjdk 를 올리면 `ahawiki@*` 를 재시작하는 hook(`DPkg::Post-Invoke`). 운영 저장소의 일이다. * 앱 쪽: JVM 을 `-Djdk.lang.Process.launchMechanism=VFORK` 로 띄운다. `jspawnhelper` 를 안 쓰므로 JDK 가 갈려도 프로세스를 띄울 수 있다. 이 앱이 띄우는 프로세스는 `vi` 하나뿐이라 부담이 없다. systemd unit 의 한 줄이라 이것도 운영 쪽이다. 그 사이에는 `InterpreterVim` 이 강조 없이 본문을 보여 주는 것으로 버틴다([InterpreterVim]). == 비밀이 든 설정을 conf/ 밖에 `sbt stage` 는 `conf/` 아래 모든 파일을 릴리스에 복사한다. gitignore 는 거기 관여하지 못한다 — git 이 무엇을 추적하는지를 다스릴 뿐, 빌드가 무엇을 패키징하는지는 아니다 — 그래서 거기 둔 로컬 설정은 배포마다 서버로 따라간다. 가정이 아니다. 개발 DB 비밀번호와 `play.http.secret.key` 가 릴리스 세 개에, world readable 로, 앱이 열지도 않는 파일에 앉아 있었다: 앱은 릴리스 밖을 가리키는 절대 경로 `-Dconfig.file` 로 시작된다. 지금 구조가 시작된 뒤로 배포마다 나가고 있었다. `build.sbt` 는 이제 git 에게 `conf/` 아래 무엇을 추적하는지 물어 그것만 패키징한다. ignore 된 파일은 실리지 않는다 — 모두가 이미 그런 줄 알았던 바로 그것이다. 무엇을 뺐는지도 말한다: [[[#!Vim text [info] conf/: not packaged (untracked): conf/something.local.conf ]]] 파일명 패턴을 먼저 시도했고, git 이 없는 빌드의 폴백으로만 남겼다. 패턴은 누군가 떠올린 모양들을 잡는데, 이 일의 발단이 된 파일은 그중 어느 모양도 아니었다: 호스트명을 따서 지은 이름이었다. 그 폴백은 2026-08-15 에 처음 실제로 돌려 봤다 — git 을 `PATH` 에서 빼고, 미끼 파일 둘을 심어서: 하나는 `*.local.*`, 하나는 호스트명을 딴 이름. 설계대로 동작했고 한계도 위에 적은 그대로다: 첫째는 빠졌고 '''둘째는 실렸다.''' 그래서 폴백은 경고하고, 이제 패키징하려는 `conf/` 최상위 목록도 찍는다. "릴리스를 확인하라" 는 '''뺀 것'''의 목록으로는 답할 수 없기 때문이다 — 걱정할 파일은 정의상 패턴이 알아보지 못한 그 파일이다. [[[#!Vim text [warn] conf/: git unavailable, falling back to name patterns. Check the release for local configs. [warn] conf/: packaged on the name rule alone: conf/application.conf [warn] conf/: packaged on the name rule alone: conf/base.conf ... ]]] 여섯 줄쯤이고, 낯선 설정 하나는 거기 끼면 눈에 띈다. 하위 디렉터리는 그 목록에서 뺐다 — 기본 페이지와 evolution 전부는 하나를 숨기기에 충분히 길다. git 이 있으면 이것들은 하나도 나타나지 않는다. 보통의 빌드가 전부 그쪽이다. 배포 설정이 `conf/` 에 살아야 할 이유는 아무것도 없다 — 서버는 절대 경로로 설정 위치를 받는다 — 그러니 가장 안전한 자리는 여전히 아예 다른 곳이다. == 전에 여기 있던 것 2026-08-12 까지 이 저장소에는 다른 배포 장치 둘이 실려 있었고 둘 다 더는 서버와 맞지 않았다: 옛 경로에 빌드를 덮어 복사하는 스크립트 하나, 서버에서 빌드해 pm2 로 띄우는 GitHub Actions 워크플로 하나. 운영은 `current` 에서 도는 systemd 서비스로 옮겨 간 뒤라, 스크립트는 아무도 읽지 않는 디렉터리에 썼고 워크플로는 돌고 있는 서비스와 포트를 두고 경합했을 것이다. 둘 다, 의존하던 pm2 launch 스크립트·process 파일과 함께 제거했다.