아무 일도 일어나지 않았다
정해진 시각에 배치가 돌아야 했다. 신호를 뽑는 배치, 종가를 추적하는 배치, 진입을 확인하는 배치. 세 건이 연달아 예정돼 있었다.
셋 다 실행되지 않았다.
그런데 어디에도 에러가 없었다. 스케줄러 실행 이력을 열어보니 전부 succeeded였다. 실패 알림도 오지 않았다. 로그상으로는 모든 것이 정상이었고, 실제로는 아무 일도 일어나지 않았다.
이 조합이 진단을 어렵게 만들었다. 실패한 흔적이 없으면 무엇을 고쳐야 할지 알 수 없다.
succeeded가 뜻하는 것
스케줄러가 하는 일은 HTTP 요청을 하나 보내는 것이다. GitHub Actions 워크플로를 외부에서 발동시키는 요청이다.
여기서 succeeded는 요청을 보냈다는 뜻이지 상대가 받아들였다는 뜻이 아니었다. 함수는 요청 식별자 한 줄을 반환하고 성공으로 끝난다. 응답이 무엇이었는지는 별도 테이블에 쌓인다.
그 테이블을 열었다.
{"message":"Not Found"}
404였다.
404를 보고 경로를 의심했다
404는 "없다"는 뜻이다. 그래서 경로를 확인했다. 저장소 이름, 워크플로 파일명, 브랜치 이름. 오타가 있는지 한 글자씩 대조했다.
전부 맞았다. 브라우저에서 같은 주소를 열면 워크플로가 정상적으로 보였다.
여기서 한참을 헤맸다. 눈에 보이는 리소스가 API에서는 없다고 나온다. 경로가 맞는데 404가 온다면 남는 가능성은 인증인데, 인증이 잘못됐으면 401이 와야 한다고 생각했다. 그게 HTTP 규약이 정한 의미다. 401은 "너가 누군지 모르겠다", 403은 "누군지는 알겠는데 권한이 없다", 404는 "그런 건 없다".
이 상식이 진단을 막고 있었다.
GitHub은 권한이 없으면 존재를 숨긴다
GitHub은 권한 없는 요청에 404를 준다. 의도된 동작이다.
403을 주면 "권한은 없지만 그 저장소는 존재한다"는 정보가 새어 나간다. 비공개 저장소의 이름을 무작위로 대입해 존재 여부를 알아내는 공격이 가능해진다. 그래서 권한이 없으면 아예 없는 것처럼 답한다.
따라서 404는 세 가지를 동시에 뜻한다.
- 경로가 틀렸다
- 토큰이 만료됐다
- 토큰에 권한이 없다
셋을 응답만 보고 구분할 수 없다. 나는 첫 번째만 계속 확인하고 있었다.
권한이 0인 토큰
문제는 토큰이었다. 정확히는 토큰의 권한 설정 두 곳이 모두 비어 있었다.
세분화된 권한을 쓰는 토큰은 두 섹션을 따로 채워야 한다.
- Repository access - 어느 저장소에 접근할 것인가
- Repository permissions - 그 저장소에서 무엇을 할 것인가
둘 중 하나만 비어도 동작하지 않는데, 이 토큰은 양쪽이 다 비어 있었다. 저장소 목록도 없고 권한 행도 없는 상태로 발급된 것이다. 문자열은 멀쩡히 존재하고 인증도 통과하지만 아무것도 볼 수 없는 토큰이었다.
그리고 이런 토큰은 자기가 비어 있다고 말해주지 않는다. 어떤 요청을 보내도 404만 돌려준다.
어쩌다 비었나
발급 화면의 마지막 단계에 원인이 있었다.
권한을 지정하고 생성 버튼을 누르면 비밀번호나 2단계 인증을 다시 요구한다. 여기서 취소하거나 창을 닫으면 변경 사항이 반영되지 않는다. 그런데 화면은 토큰 상세 페이지로 넘어간다. 토큰은 만들어져 있고 문자열도 손에 들어온다.
만들어졌으니 됐다고 판단하기 쉬운 지점이다. 나도 그랬다.
다음부터는 이렇게 판정한다
같은 증상을 만났을 때 추측을 반복하지 않도록 분기표를 만들었다. 두 번의 요청으로 원인이 갈린다.
1단계 - 저장소 자체에 접근되는가
GET https://api.github.com/repos/{owner}/{repo}
Authorization: Bearer {TOKEN}
2단계 - 워크플로를 발동시킬 수 있는가
POST https://api.github.com/repos/{owner}/{repo}/actions/workflows/{file}.yml/dispatches
Authorization: Bearer {TOKEN}
Content-Type: application/json
{"ref":"main"}
| 1단계 | 2단계 | 진단 |
|---|---|---|
| 200 | 204 | 정상 |
| 200 | 404 | Actions 권한 부족 (읽기 전용이거나 없음) |
| 404 | - | 저장소 접근 누락, 토큰 만료, 또는 권한 0 |
| 401 | - | 명시적으로 취소된 토큰 |
핵심은 1단계와 2단계를 나눠 보는 것이다. 한 번만 던지면 404가 어느 층에서 나온 건지 모른다. 저장소는 보이는데 워크플로만 안 되면 권한 종류의 문제고, 저장소부터 안 보이면 접근 자체의 문제다.
발급 직후에 이 두 요청을 한 번씩 던져보는 것으로 충분하다. 204가 떨어지고 실제로 워크플로가 발동하면 그때 검증이 끝난 것이다.
필요한 권한은 생각보다 적다
권한을 넉넉히 주고 싶어지지만, 워크플로를 외부에서 발동시키는 용도라면 두 개면 된다.
Actions: Read and writeMetadata: Read-only(자동으로 붙는다)
Contents 권한은 필요 없다. 워크플로 파일을 직접 읽거나 고치는 게 아니라면 주지 않는 편이 낫다.
남은 것
배치 세 건은 복구되지 않았다. 그날의 신호는 발사되지 않았고, 활성 신호 세 종의 종가 추적도 비어 있다. 시장 데이터는 지나가면 그 시점의 판단을 되돌릴 수 없다.
한 가지 다행이었던 것은 감시 알림이 살아 있었다는 점이다. 배치가 돌지 않았다는 사실 자체는 다른 경로로 통보됐다. 그게 없었다면 며칠 더 몰랐을 것이다.
💡 404를 만나면 경로부터 의심하게 된다. 그런데 인증을 요구하는 API에서 404는 "없다"가 아니라 "너에게는 없다"일 수 있다. 권한 문제를 404로 감추는 것은 GitHub만의 방식이 아니다. 경로를 세 번 확인했는데도 404가 나온다면, 그다음은 토큰을 열어볼 차례다.
돌아보면 진단이 오래 걸린 이유는 정보가 부족해서가 아니었다. 401이 와야 한다는 전제를 의심하지 않았기 때문이다. 응답 코드의 의미는 규약이 정하지만, 그 규약을 어떻게 쓸지는 서비스가 정한다.