새 기능을 한 대에서만 켜서 시험하기로 했다. 켜기는 한 대에서만 켰는데 다른 대에서 문제가 났다.
Table of contents
Open Table of contents
상황 — 한 대에서만 켰다
환경 변수로 갈랐다.
web01 NEW_MATCHING=on
web02 NEW_MATCHING=off
web03 NEW_MATCHING=off
NEW_MATCHING 을 켠 web01 에서만 새 코드가 돈다고 생각했다.
배포는 세 대에 다 나갔으므로 코드는 전부에 있고 켜진 것만 하나였는데 그 차이를 그때는 구분하지 않았다.
켠다는 말이 그 코드가 그 대에만 있다는 뜻으로 읽혔다. 실제로는 어디에 있느냐와 어디서 도느냐가 따로 정해진다.
증상 — 꺼진 대에서 문제가 났다
web02 에서 오류가 났다.
Fatal error: Class 'MatchingV2' not found
NEW_MATCHING 이 꺼져 있는데 MatchingV2 를 찾고 있었다.
$engines = [
'v1' => new MatchingV1(),
'v2' => new MatchingV2(), // 만들기는 다 한다
];
$engine = $engines[Feature::on('new_matching') ? 'v2' : 'v1'];
Feature::on 으로 고르는데 그 전에 new 가 둘 다 돈다.
MatchingV2.php 가 배포에서 빠져 있었고 꺼진 대에서도 그 줄을 지났다. 켜고 끄는 것은 동작이지 코드의 존재가 아니었다.
web01 에서는 파일이 있으니 아무 일도 안 났다. 켠 쪽이 멀쩡하고 끈 쪽이 죽는 모양이라 처음에는 원인이 안 잡혔다.
원인 — 배포 범위와 켜는 범위
두 범위를 나눠 보면 이렇다.
배포 범위 코드가 어디에 있는가 → 전부
켜는 범위 그 코드가 어디서 도는가 → 한 대
rsync 로 코드가 전부에 나가므로 꺼진 곳에서도 읽힌다.
문법 오류나 없는 파일은 꺼져 있어도 터지고 초기화 부분이 있으면 그것도 꺼진 대에서 함께 돈다.
끈 것이 완전히 꺼졌는지 확인하지 않으면 시험 자체가 성립하지 않고 꺼진 쪽이 영향을 받으면 비교 대상이 아니게 된다.
조치 — 필요할 때만 만들게
고치는 방향은 NEW_MATCHING 이 꺼져 있으면 아무것도 안 건드리게 하는 것이었다.
$engine = Feature::on('new_matching') ? new MatchingV2() : new MatchingV1();
삼항으로 고른 뒤에 new 를 부르면 꺼진 쪽은 MatchingV2 를 아예 안 건드린다.
배열에 둘을 담아 두고 고르는 방식이 읽기에는 깔끔했는데 그 깔끔함이 꺼진 쪽까지 만들게 하는 원인이었다.
플래그를 쓰는 코드에서는 고르는 시점보다 만드는 시점이 앞서지 않아야 한다. 무엇을 쓸지 정한 뒤에 그것만 준비하는 순서가 필요했다.
검증 — 배포가 다 나갔는지
새 파일이 한 대에 안 나간 것이 진짜 원인이었다.
$ for h in web01 web02 web03; do
echo -n "$h "; ssh $h 'ls /app/src/Matching/MatchingV2.php 2>&1'
done
web01 /app/src/Matching/MatchingV2.php
web02 ls: cannot access ...: No such file or directory
web03 /app/src/Matching/MatchingV2.php
web02 에만 MatchingV2.php 가 없었는데 배포는 성공으로 끝나 있었다.
for h in $HOSTS; do
rsync -a --delete release/ $h:/app/ || echo "FAIL: $h"
done
rsync 가 실패해도 다음으로 넘어가고 스크립트는 0으로 끝난다.
fail=0
for h in $HOSTS; do
rsync -a --delete release/ "$h:/app/" || { echo "FAIL: $h"; fail=1; }
done
[ $fail -eq 0 ] || exit 1
fail 을 두어 하나라도 실패하면 배포가 실패로 끝나게 했다.
한 대만 실패하는 경우가 드물지 않다. 드물지 않은데 성공으로 보고되면 그 상태가 며칠씩 남는다.
세 대가 같은 것을 갖고 있는지도 확인하게 했다.
$ for h in $HOSTS; do
echo -n "$h "; ssh $h 'find /app/src -type f -name "*.php" | sort | xargs md5sum | md5sum'
done
web01 3f9a2c11...
web02 3f9a2c11...
web03 3f9a2c11...
md5sum 을 모아 다시 md5sum 하니 하나라도 다르면 값이 달라진다.
일부러 한 대에 파일 하나를 고쳐 놓고 이 검사가 잡는지 봤고 잡았는데 부분 실패를 성공으로 보고하면 그 뒤의 판단이 전부 틀린 전제 위에 선다.
비교 — 서버로 가르기와 사용자로 가르기
한 대에서만 켜는 방식 자체도 다시 봤다.
같은 사용자가 요청마다 다른 대로 간다
→ 어떤 요청은 새 동작, 어떤 요청은 옛 동작
앞단이 요청을 어느 대로 보낼지는 정해져 있지 않다.
거래 관련이라 같은 userId 가 요청마다 다른 결과를 받는 것은 위험했다. 기능이 있다가 없다가 하는 것으로 보이고 그 상태에서 난 오류는 재현도 안 된다.
서버가 아니라 사용자로 가르게 바꿨다.
$on = Feature::onFor('new_matching', $userId);
public static function onFor(string $key, int $userId): bool {
$pct = self::percent($key); // 5 이면 5%
return (crc32($key . ':' . $userId) % 100) < $pct;
}
crc32 로 userId 를 가르니 어느 서버로 가든 같은 결과를 받는다.
percent 로 비율을 늘릴 수 있어 5퍼센트로 시작해 넓히는 방식이 가능해졌고 무엇으로 가르느냐가 시험의 성격을 정하고 있었다.
정리
- 배포 범위와 켜는 범위는 다르다
- 꺼져 있어도 코드는 전부에 나가 있다
- 꺼진 곳에서도 그 줄을 지나고 없는 파일은 그대로 터진다
- 끈 것이 완전히 꺼졌는지 확인해야 시험이 성립한다
- 고르기 전에 둘 다 만드는 코드가 원인이었다
- 필요할 때만 만들게 하면 꺼진 쪽이 안 건드린다
- 한 대만 실패한 배포가 전체 성공으로 끝날 수 있다
- 하나라도 실패하면 실패로 끝내게 한다
- 대들이 같은 파일을 갖고 있는지 해시로 확인한다
- 일부러 어긋나게 해서 그 검사가 잡는지 본다
- 서버로 가르면 같은 사용자가 요청마다 다른 결과를 본다
- 사용자로 가르면 어느 서버로 가든 같고 비율로 늘릴 수 있다