FAILURE: Build failed with an exception.
* What went wrong:
A build operation failed.
Could not move temporary workspace
위와 오류를 마주했던 경험이 있을 것이다.
10페이지가 넘어가는 원성들
이 오류는 8.6 부터 시작해서 9.0까지 특정 안티바이러스 프로그램을 사용하는 윈도우 환경에서만 발생했다. 리눅스나 맥 유저들은 겪어본 적이 없어서 어떤 문제인지 공감되지 않을 수 있지만, 윈도우 유저에게는 꽤나 심각한 문제였다. workspace 동기화가 막혀버리고 gradle 빌드가 아에 막혀버려서 거의 모든 태스크를 실행할 수 없어진다. 개인 PC라면 잠시 안티바이러스 프로그램을 비활성화할 수도 있지만 직장에서는 보통 중앙 정책으로 관리하기 때문에 불가능한 경우가 많다. 다들 어쩔 수 없이 마지막으로 동작하는 버전인 8.5로 내려서 사용했던 기억이 있다.
결론부터 말하자면, 7/31자로 해당 오류에 대한 PR이 master 브랜치에 머지되었고 9.1 RC로 등록되었다.(아쉽게도 9.0 릴리즈에는 들어가지 못했다) 이제 왜 이런 문제가 발생했고 어떻게 해결했는지를 알아보자.
8.5버전의 동작방법
gradle은 의존성을 최대한 캐시하려고 시도한다. 오래된 옛날의 libs 폴더나 node진영 npm의 node_modules와 같은 방식을 사용하지 않고 중앙 저장소에서 의존성을 캐시한다. 이때 의존성의 경로는 불변하고 고유해야만 여러 프로젝트에서 동일하게 참조할 수 있다. 8.5까지는 다음과 같은 단순한 방식으로 불변성을 제공하였다. 원본 소스코드는 링크를 참조바란다.
package org.gradle.internal.execution.workspace.impl;
// import 생략
public class DefaultImmutableWorkspaceProvider implements WorkspaceProvider, Closeable {
// 기타 로직 생략
@Override
public <T> T withWorkspace(String path, WorkspaceAction<T> action) {
return cache.withFileLock(() -> {
File workspace = new File(baseDirectory, path);
fileAccessTracker.markAccessed(workspace);
return action.executeInWorkspace(workspace, executionHistoryStore);
});
}
}
단순히 파일 자체에 대한 lock을 걸고 이후 Action을 진행하는 방식이었다. 하지만 8.6부터 문제가 발생했는데...
8.6버전의 동작방법
8.6.0에서부터는 DefaultImmutableWorkspaceProvider가 없어지고 ImmutableWorkspaceProvider의 새 구현체인 CacheBasedImmutableWorkpaceProvider 가 등장한다. 소스코드를 살펴보자
package org.gradle.internal.execution.workspace.impl;
// import 생략
public class CacheBasedImmutableWorkspaceProvider implements ImmutableWorkspaceProvider, Closeable {
@Override
public ImmutableWorkspace getWorkspace(String path) {
File immutableWorkspace = new File(baseDirectory, path);
fileAccessTracker.markAccessed(immutableWorkspace);
return new ImmutableWorkspace() {
@Override
public File getImmutableLocation() {
return immutableWorkspace;
}
@Override
public <T> T withTemporaryWorkspace(TemporaryWorkspaceAction<T> action) {
// TODO Use Files.createTemporaryDirectory() instead
String temporaryLocation = path + "-" + UUID.randomUUID();
File temporaryWorkspace = new File(baseDirectory, temporaryLocation);
return action.executeInTemporaryWorkspace(temporaryWorkspace);
}
};
}
}
package org.gradle.internal.execution.steps;
// import 생략
public class AssignImmutableWorkspaceStep<C extends IdentityContext> implements Step<C, WorkspaceResult> {
private WorkspaceResult executeInTemporaryWorkspace(UnitOfWork work, C context, ImmutableWorkspace workspace) {
return workspace.withTemporaryWorkspace(temporaryWorkspace -> {
WorkspaceContext workspaceContext = new WorkspaceContext(context, temporaryWorkspace, null, true);
// We don't need to invalidate the temporary workspace, as there is surely nothing there yet,
// but we still want to record that this build is writing to the given location, so that
// file system watching won't care about it
fileSystemAccess.invalidate(ImmutableList.of(temporaryWorkspace.getAbsolutePath()));
// There is no previous execution in the immutable case
PreviousExecutionContext previousExecutionContext = new PreviousExecutionContext(workspaceContext, null);
CachingResult delegateResult = delegate.execute(work, previousExecutionContext);
if (delegateResult.getExecution().isSuccessful()) {
// Store workspace metadata
// TODO Capture in the type system the fact that we always have an after-execution output state here
@SuppressWarnings("OptionalGetWithoutIsPresent")
ExecutionOutputState executionOutputState = delegateResult.getAfterExecutionOutputState().get();
ImmutableListMultimap<String, HashCode> outputHashes = calculateOutputHashes(executionOutputState.getOutputFilesProducedByWork());
ImmutableWorkspaceMetadata metadata = new ImmutableWorkspaceMetadata(executionOutputState.getOriginMetadata(), outputHashes);
workspaceMetadataStore.storeWorkspaceMetadata(temporaryWorkspace, metadata);
return moveTemporaryWorkspaceToImmutableLocation(
new WorkspaceMoveHandler(work, workspace, temporaryWorkspace, workspace.getImmutableLocation(), delegateResult)); // 이 구문에서 임시 workspace를 영속화한다.
} else {
// TODO Do not capture a null workspace in case of a failure
return new WorkspaceResult(delegateResult, null);
}
});
}
}
이 구현체는 UUID를 사용하여 임시 파일을 생성하고 이후 처리는 해당 임시 workspace에서 수행한 후 불변하면서 고유한 path로 이동하는 방식이다. 파일 lock을 거는 방식보다 효율적이지만 이 구현에는 생각지 못한 문제가 있었는데...
안티바이러스 소프트웨어와의 충돌
안티바이러스 소프트웨어, 한국에서는 주로 바이러스 백신이라고 부르는 프로그램들은 대부분 실시간 감시를 지원한다. 모든 백신이 그렇진 않지만 많은 백신들은 새 파일이 생성되면 일단 의심하고 본다. 수상쩍은 사이트나 악성 광고에서 원치 않는 파일을 받아본 적이 있다면 백신이 이를 감지하고 경고창을 띄우며 처리하던 기억이 있을 것이다. 물론 우리가 사용하는 자바 라이브러리들은 대부분 백신에서 안전하다고 판정하기 때문에 의존성 설치 과정에서 경고창을 마주하진 않았을 것이다. 하지만 이 실시간 감시 자체에 문제가 있었다. 실시간 감시를 위해서는 짧은 시간이지만 백신이 파일에 대한 락을 획득하고 내용을 분석해야만 하기 때문이다. 이 기능이 상술한 임시 파일 구현과 맞물리면서 moveTemporaryWorkspaceToImmutableLocation 를 실행하는 도중 파일을 이동하지 못하는 오류가 발생한다고 추정할 수 있다.
왜 이런 오류가 발생했는가?
Gradle 8.6 이전에는 '파일 잠금' 방식이 사용되었습니다. 하지만 8.6 버전에서 추가된 작업으로 인해, 하나의 Gradle 사용자 홈에서 많은 데몬이 실행되는 통합 테스트에서 회귀가 발생했습니다(이는 일반적인 사용 사례는 아님).
gradle 멤버 중 한명인 Anže Sodja 에 따르면 파일 락 방식을 적용 시 gradle 8.6에 새로 추가한 기능때문에 통합테스트 시나리오에서 성능 저하가 발생했다고 한다. 이를 피하기 위해서 UUID를 사용하는 새 구현을 도입했는데, 윈도우 환경에서 백신의 실시간 감시와 충돌하는 상황까지는 상정하지 못한 듯 하다. 원문: 링크
Before Gradle 8.6 we used "file lock" approach. But due to some extra work we added in 8.6, we had some regression with integration tests where a lot of daemons were running in the same Gradle user home (which is not really normal use case). Thus, we decided to implement a new approach that has better performance. Unfortunately on Windows there are other problems with this new approach. The issue is actually unrelated to tasks, but it's normally caused by artifact transforms that cache result in Gradle user home.
최종 해결
8.6이 릴리즈 된 이후로 해당 오류에 대한 이슈 #31438은 꽤 많은 코멘트가 달렸고 나도 오류의 피해자인지라 해당 이슈에 알림도 켜놨었다. 처음에는 윈도우에서는 기존의 파일 락을 사용하는 방식으로 돌아갈지에 대한 논의도 있었던 듯 하나 최종적으로는 서브디렉토리를 생성하고 해당 디렉토리에 락을 거는 방식으로 결정되었다. 소스코드를 살펴보자
package org.gradle.internal.execution.steps;
//import 생략
public class AssignImmutableWorkspaceStep<C extends IdentityContext> implements Step<C, WorkspaceResult> {
enum LockingStrategy {
WORKSPACE_LOCK,
ATOMIC_MOVE
}
public AssignImmutableWorkspaceStep(
Deleter deleter,
FileSystemAccess fileSystemAccess,
ImmutableWorkspaceMetadataStore workspaceMetadataStore,
OutputSnapshotter outputSnapshotter,
Step<? super PreviousExecutionContext, ? extends CachingResult> delegate
) {
this(deleter, fileSystemAccess, workspaceMetadataStore, outputSnapshotter, delegate,
OperatingSystem.current().isWindows() // windows 환경이면 WORKSPACE_LOCK을 사용한다.
? LockingStrategy.WORKSPACE_LOCK
: LockingStrategy.ATOMIC_MOVE
);
}
@Override
public WorkspaceResult execute(UnitOfWork work, C context) {
ImmutableWorkspaceProvider workspaceProvider = ((ImmutableUnitOfWork) work).getWorkspaceProvider();
String uniqueId = context.getIdentity().getUniqueId();
if (lockingStrategy == LockingStrategy.WORKSPACE_LOCK) {
LockingImmutableWorkspace workspace = workspaceProvider.getLockingWorkspace(uniqueId);
return workspace.withWorkspaceLock(() ->
loadImmutableWorkspaceIfExists(work, workspace)
.orElseGet(() -> {
deleteStaleFiles(workspace.getImmutableLocation());
return executeInWorkspace(work, context, workspace.getImmutableLocation());
})
);
} else {
AtomicMoveImmutableWorkspace workspace = workspaceProvider.getAtomicMoveWorkspace(uniqueId);
return loadImmutableWorkspaceIfExists(work, workspace)
.orElseGet(() -> executeInTemporaryWorkspace(work, context, workspace));
}
}
}
```java
package org.gradle.internal.execution.workspace.impl;
// import 생략
public class CacheBasedImmutableWorkspaceProvider implements ImmutableWorkspaceProvider, Closeable {
@Override
public AtomicMoveImmutableWorkspace getAtomicMoveWorkspace(String path) {
File immutableWorkspace = new File(baseDirectory, path);
fileAccessTracker.markAccessed(immutableWorkspace);
return new AtomicMoveImmutableWorkspace() {
@Override
public File getImmutableLocation() {
return immutableWorkspace;
}
@Override
public <T> T withTemporaryWorkspace(TemporaryWorkspaceAction<T> action) {
// TODO Use Files.createTemporaryDirectory() instead
String temporaryLocation = path + "-" + UUID.randomUUID();
File temporaryWorkspace = new File(baseDirectory, temporaryLocation);
return action.executeInTemporaryWorkspace(temporaryWorkspace);
}
};
}
@Override
public LockingImmutableWorkspace getLockingWorkspace(String path) {
File workspaceBaseDir = new File(baseDirectory, path);
fileAccessTracker.markAccessed(workspaceBaseDir);
// We use a subdirectory for the workspace to avoid snapshotting of a file lock
File workspace = new File(workspaceBaseDir, "workspace");
return new LockingImmutableWorkspace() {
@Override
public File getImmutableLocation() {
return workspace;
}
@Override
public <T> T withWorkspaceLock(Supplier<T> supplier) {
CacheContainer cacheContainer = keyCaches.computeIfAbsent(path, cache ->
new CacheContainer(unscopedCacheBuilderFactory.cache(workspaceBaseDir)
.withInitialLockMode(FileLockManager.LockMode.OnDemand)
.open()));
return cacheContainer.withFileLock(supplier);
}
};
}
}
이제 CacheBasedImmutableWorkspaceProvider에서 getLockingWorkspace 라는 윈도우 환경의 구현을 제공한다. 이 구현은 ${workspaceBaseDir}\workspace 라는 폴더를 만들고 해당 서브폴더 전체에 락을 획득해 다른 프로그램의 접근을 거부하는 방식으로 백신의 실시간 감시가 접근하지 못하도록 막는다.
여담
해당 PR이 9.1.0-RC1에 들어갔으니 드디어 8.5버전을 벗어날 희망이 생겼다. gradle 8.6이 2024년 8월에 릴리즈되었으니 장장 1년을 넘어서야 겨우 해결되는 셈이다. gradle init이나 스프링 이니셜라이저 등도 사용자의 환경을 고려하지 않고 최신 버전으로 잡아주는 바람에 많은 사람들이 고통을 겪었으리라 예상한다. 아직까지 8.5 버전을 사용하는만큼 9.1이 빨리 출시되거나 혹은 이전 버전에 백포트라도 되었으면 하는 심정이다.